presentation/graph/
structure_graph_view.py 325 lớp chính + dựng giao diện
graph_qa_widget.py 322 hỏi-đáp trên đồ thị (_ask 119 dòng)
graph_render.py 226 quét, vẽ Qt + D3, xuất ảnh
graph_scene.py 138 node, cạnh, khung nhìn — thuần đồ hoạ
graph_project.py 109 chọn project, đổi tab xem
graph_web.py 38 cờ có dùng được QtWebEngine không
ui/structure_graph_view.py 11 vỏ chuyển tiếp, giữ đường import cũ
BA LẦN CẮT HỎNG, ĐỀU LÀ TÊN CẤP MODULE BỊ BỎ LẠI
------------------------------------------------
_HAS_WEB, QWebEngineView, QWebChannel, _Bridge, _Edge, _Node — tất cả định
nghĩa ở file gốc, dùng ở file mới, nên NameError ngay lúc chạy. Bộ test đơn
vị KHÔNG bắt được cái nào: 756 bài vẫn xanh suốt ba lần. Chỉ
check_graphrag_rescan bắt, vì nó gọi prewarm() thật rồi chờ đồ thị dựng xong.
Sau lần thứ ba tôi bỏ cách đuổi từng lỗi và viết bộ dò tên chưa định nghĩa có
tính đến phạm vi hàm (tham số, biến cục bộ, except-as, comprehension). Nó
tìm ra nốt _fmt_plan và _qcolor còn thiếu ở hai file Co4E đã tách hôm trước —
hai quả mìn chưa nổ.
_HAS_WEB tách hẳn ra graph_web.py: cả structure_graph_view.py lẫn
graph_render.py đều phải hỏi, để ở một trong hai là vòng import.
756 test xanh. 24/24 checker qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
5.4 KiB
Python
139 lines
5.4 KiB
Python
"""Các phần tử vẽ của đồ thị: node, cạnh, khung nhìn — R08-T14.
|
|
|
|
Thuần đồ hoạ Qt, không biết gì về GraphRAG hay agent. Tách riêng vì đây là
|
|
chỗ duy nhất cần mở khi chỉnh cách đồ thị trông ra sao — màu, hình mũi tên,
|
|
cách kéo thả và phóng to.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from PySide6.QtCore import QObject, QPointF, Qt, Slot
|
|
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
|
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsView
|
|
from ...core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
|
|
from ...theme import current_palette
|
|
from ...i18n import tr
|
|
from ...ui.osutil import open_folder, open_location
|
|
|
|
|
|
class _Bridge(QObject):
|
|
"""Exposed to the D3 page so a Shift+click on a node can open its
|
|
storage folder/link (local path or URL — see osutil.open_location)."""
|
|
|
|
@Slot(str)
|
|
def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name
|
|
if path:
|
|
open_location(path)
|
|
|
|
class _Edge(QGraphicsLineItem):
|
|
def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
|
|
super().__init__()
|
|
self.a, self.b = a, b
|
|
self.type = type_
|
|
# Colour the edge by its RELATIONSHIP type (contains/defines/method/…),
|
|
# so the graph shows what each connection MEANS — falling back to the
|
|
# source node's tint for any untyped edge.
|
|
color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor()
|
|
if not color.isValid():
|
|
color = a.brush().color().lighter(130)
|
|
self._color = color
|
|
self.setPen(QPen(color, 1.4))
|
|
self.setZValue(-1)
|
|
# A small label naming the relationship, shown at the edge midpoint.
|
|
self._label = None
|
|
if type_:
|
|
self._label = QGraphicsSimpleTextItem(type_, self)
|
|
self._label.setBrush(QBrush(color.lighter(140)))
|
|
f = QFont()
|
|
f.setPointSize(7)
|
|
self._label.setFont(f)
|
|
self._label.setZValue(0)
|
|
a.edges.append(self)
|
|
b.edges.append(self)
|
|
self.adjust()
|
|
|
|
def adjust(self) -> None:
|
|
pa, pb = self.a.scenePos(), self.b.scenePos()
|
|
self.setLine(pa.x(), pa.y(), pb.x(), pb.y())
|
|
if self._label is not None:
|
|
br = self._label.boundingRect()
|
|
self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2,
|
|
(pa.y() + pb.y()) / 2 - br.height() / 2)
|
|
|
|
class _Node(QGraphicsEllipseItem):
|
|
def __init__(self, data, radius: int):
|
|
super().__init__(-radius, -radius, 2 * radius, 2 * radius)
|
|
self.data = data
|
|
self.edges = []
|
|
tok = current_palette()
|
|
# NODE_KIND_COLORS is a categorical data encoding (one hue per node
|
|
# kind), not UI chrome — it stays fixed across themes on purpose so a
|
|
# given kind is always the same colour. Only the chrome follows tokens.
|
|
color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted))
|
|
self.setBrush(QBrush(color))
|
|
self.setPen(QPen(color.darker(160), 1.5))
|
|
self.setFlags(
|
|
QGraphicsEllipseItem.ItemIsMovable
|
|
| QGraphicsEllipseItem.ItemIsSelectable
|
|
| QGraphicsEllipseItem.ItemSendsGeometryChanges
|
|
)
|
|
self.setZValue(1)
|
|
label = QGraphicsSimpleTextItem(data.label, self)
|
|
label.setBrush(QBrush(QColor(tok.text)))
|
|
label.setPos(radius + 3, -8)
|
|
|
|
def itemChange(self, change, value): # noqa: N802
|
|
if change == QGraphicsEllipseItem.ItemPositionHasChanged:
|
|
for edge in self.edges:
|
|
edge.adjust()
|
|
return super().itemChange(change, value)
|
|
|
|
class _GraphView(QGraphicsView):
|
|
def __init__(self, scene):
|
|
super().__init__(scene)
|
|
self.setDragMode(QGraphicsView.NoDrag)
|
|
self._panning = False
|
|
self._pan_start = QPointF()
|
|
|
|
def wheelEvent(self, e): # noqa: N802
|
|
self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15,
|
|
1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
|
|
|
|
def mousePressEvent(self, e): # noqa: N802
|
|
if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None:
|
|
self._panning = True
|
|
self._pan_start = e.position()
|
|
self.setCursor(Qt.ClosedHandCursor)
|
|
e.accept()
|
|
return
|
|
super().mousePressEvent(e)
|
|
|
|
def mouseMoveEvent(self, e): # noqa: N802
|
|
if self._panning:
|
|
delta = e.position() - self._pan_start
|
|
self._pan_start = e.position()
|
|
self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x()))
|
|
self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y()))
|
|
e.accept()
|
|
return
|
|
super().mouseMoveEvent(e)
|
|
|
|
def mouseReleaseEvent(self, e): # noqa: N802
|
|
if self._panning:
|
|
self._panning = False
|
|
self.setCursor(Qt.ArrowCursor)
|
|
e.accept()
|
|
return
|
|
super().mouseReleaseEvent(e)
|
|
|
|
def mouseDoubleClickEvent(self, e): # noqa: N802
|
|
"""Double-click or Ctrl+click on a node opens its storage folder."""
|
|
item = self.itemAt(e.pos())
|
|
if isinstance(item, _Node) and getattr(item.data, "path", ""):
|
|
open_folder(item.data.path)
|
|
e.accept()
|
|
return
|
|
super().mouseDoubleClickEvent(e)
|
|
|