Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
514 lines
21 KiB
Python
514 lines
21 KiB
Python
"""Build a knowledge graph (Graph-RAG style) of code / document structure.
|
|
|
|
Two builders:
|
|
- ``build_from_directory`` — stdlib only. Parses Python files with ``ast``
|
|
(files, classes, functions, methods, imports), Markdown/text files by
|
|
heading hierarchy, and JSON files by their own key/array structure. No
|
|
external dependency.
|
|
- ``build_from_codebase_memory`` — best-effort enrichment using the
|
|
codebase-memory-mcp knowledge graph when available; falls back to the local
|
|
builder on any problem.
|
|
|
|
Both return a :class:`StructureGraph` of nodes + edges that the UI lays out and
|
|
renders. A simple layered layout is provided.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
import os
|
|
from collections import defaultdict, deque
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple
|
|
|
|
EXCLUDE_DIRS = {".venv", "__pycache__", ".git", "node_modules", ".pytest_cache",
|
|
".mypy_cache", "dist", "build", ".idea", ".vscode"}
|
|
CODE_SUFFIXES = {".py"}
|
|
DOC_SUFFIXES = {".md", ".markdown", ".txt", ".rst"}
|
|
JSON_SUFFIXES = {".json"}
|
|
DEFAULT_MAX_NODES = 400
|
|
MAX_IMPORTS_PER_FILE = 8
|
|
MAX_JSON_DEPTH = 20 # how many nested levels of keys/arrays to expand
|
|
MAX_JSON_KEYS_PER_LEVEL = 200 # cap per object, so one huge JSON can't flood the graph
|
|
|
|
|
|
@dataclass
|
|
class GNode:
|
|
"""Một node trong đồ thị cấu trúc: thư mục, tệp, lớp, hàm, phương thức hay mục tài liệu."""
|
|
id: str
|
|
label: str
|
|
kind: str # dir | file | class | function | method | module | section
|
|
detail: str = ""
|
|
path: str = "" # absolute file/folder this node maps to (for "open folder")
|
|
|
|
|
|
@dataclass
|
|
class GEdge:
|
|
"""Một cạnh trong đồ thị cấu trúc, kèm LOẠI quan hệ (chứa / định nghĩa / import…)."""
|
|
source: str
|
|
target: str
|
|
type: str = "" # contains | defines | method | imports | subsection
|
|
|
|
|
|
@dataclass
|
|
class StructureGraph:
|
|
"""Đồ thị cấu trúc mã nguồn, có trần số node/cạnh.
|
|
|
|
Chạm trần thì bật cờ ``truncated`` và ngừng thêm — đồ thị quá lớn làm treo
|
|
khung vẽ, thà hiện một phần kèm cảnh báo còn hơn đứng hình.
|
|
"""
|
|
nodes: List[GNode] = field(default_factory=list)
|
|
edges: List[GEdge] = field(default_factory=list)
|
|
truncated: bool = False
|
|
max_nodes: int = 0 # 0 = unlimited
|
|
max_edges: int = 0 # 0 = unlimited
|
|
|
|
def __post_init__(self):
|
|
"""Dựng sẵn tập id node để kiểm tra một cạnh có hợp lệ không trong thời gian
|
|
hằng số, thay vì quét cả danh sách node cho từng cạnh.
|
|
"""
|
|
self._ids = {n.id for n in self.nodes}
|
|
|
|
def add_node(self, node: GNode) -> bool:
|
|
"""Thêm một node; trả về ``False`` nếu trùng id hoặc đã chạm trần."""
|
|
if node.id in self._ids:
|
|
return False
|
|
if self.max_nodes and len(self.nodes) >= self.max_nodes:
|
|
self.truncated = True
|
|
return False
|
|
self.nodes.append(node)
|
|
self._ids.add(node.id)
|
|
return True
|
|
|
|
def add_edge(self, source: str, target: str, type_: str = "") -> None:
|
|
"""Thêm một cạnh; bỏ qua nếu một trong hai đầu chưa có node, hoặc đã chạm trần."""
|
|
if source in self._ids and target in self._ids:
|
|
if self.max_edges and len(self.edges) >= self.max_edges:
|
|
self.truncated = True
|
|
return
|
|
self.edges.append(GEdge(source, target, type_))
|
|
|
|
def has(self, node_id: str) -> bool:
|
|
"""Đồ thị đã có node với id này chưa."""
|
|
return node_id in self._ids
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Local (stdlib) builder
|
|
# --------------------------------------------------------------------------
|
|
def build_from_directory(root, mode: str = "all", max_nodes: int = 0,
|
|
max_edges: int = 0) -> StructureGraph:
|
|
"""Build the structure graph for a folder.
|
|
|
|
mode:
|
|
- 'files' — the WHOLE folder/file tree: Python and docs parsed deeply, and
|
|
every other file type shown as a plain file node (structure only).
|
|
- 'all' — Code & Docs: Python + Markdown/text parsed (no other file types).
|
|
- 'code' — Python files only.
|
|
- 'doc' — Markdown/text docs only.
|
|
|
|
``max_nodes=0`` (default) means no cap — every node/edge is kept.
|
|
"""
|
|
root_path = Path(root).expanduser().resolve()
|
|
graph = StructureGraph(max_nodes=max_nodes, max_edges=max_edges)
|
|
|
|
root_id = f"dir:{root_path}"
|
|
graph.add_node(GNode(root_id, root_path.name or str(root_path), "dir", str(root_path), str(root_path)))
|
|
|
|
want_code = mode in ("files", "all", "code")
|
|
want_doc = mode in ("files", "all", "doc")
|
|
want_json = mode == "files" # deep-parse JSON only in the "All" (files) view
|
|
want_other = mode == "files" # show every remaining file type as a file node
|
|
|
|
for dirpath, dirnames, filenames in os.walk(root_path):
|
|
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".")]
|
|
if graph.truncated:
|
|
break
|
|
dpath = Path(dirpath)
|
|
dir_id = f"dir:{dpath}"
|
|
if dpath != root_path:
|
|
graph.add_node(GNode(dir_id, dpath.name, "dir", str(dpath), str(dpath)))
|
|
parent_id = f"dir:{dpath.parent}"
|
|
graph.add_edge(parent_id, dir_id, "contains")
|
|
|
|
for fname in sorted(filenames):
|
|
suffix = Path(fname).suffix.lower()
|
|
fpath = dpath / fname
|
|
if want_code and suffix in CODE_SUFFIXES:
|
|
_add_python_file(graph, dir_id, fpath, root_path)
|
|
elif want_json and suffix in JSON_SUFFIXES:
|
|
_add_json_file(graph, dir_id, fpath, root_path)
|
|
elif want_doc and suffix in DOC_SUFFIXES:
|
|
_add_doc_file(graph, dir_id, fpath, root_path)
|
|
elif want_other:
|
|
_add_generic_file(graph, dir_id, fpath, root_path)
|
|
if graph.truncated:
|
|
break
|
|
return graph
|
|
|
|
|
|
def _add_generic_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
|
|
"""Add a plain file node (no inner parsing) so the full folder/file tree is
|
|
shown — used for file types beyond Python/docs in 'files' mode."""
|
|
file_id = f"file:{fpath}"
|
|
if graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), str(fpath))):
|
|
graph.add_edge(dir_id, file_id, "contains")
|
|
|
|
|
|
def _rel(path: Path, root: Path) -> str:
|
|
"""Đường dẫn tương đối so với thư mục gốc; nằm ngoài gốc thì trả nguyên đường dẫn."""
|
|
try:
|
|
return str(path.relative_to(root))
|
|
except ValueError:
|
|
return str(path)
|
|
|
|
|
|
def _add_python_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
|
|
"""Thêm một tệp Python vào đồ thị: node tệp, các lớp, hàm, phương thức và import."""
|
|
file_id = f"file:{fpath}"
|
|
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), str(fpath))):
|
|
return
|
|
graph.add_edge(dir_id, file_id, "contains")
|
|
fp = str(fpath)
|
|
try:
|
|
tree = ast.parse(fpath.read_text(encoding="utf-8", errors="replace"))
|
|
except (OSError, SyntaxError, ValueError):
|
|
return
|
|
|
|
for node in tree.body:
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
fn_id = f"{file_id}::{node.name}"
|
|
graph.add_node(GNode(fn_id, node.name + "()", "function", _rel(fpath, root), fp))
|
|
graph.add_edge(file_id, fn_id, "defines")
|
|
elif isinstance(node, ast.ClassDef):
|
|
cls_id = f"{file_id}::{node.name}"
|
|
graph.add_node(GNode(cls_id, node.name, "class", _rel(fpath, root), fp))
|
|
graph.add_edge(file_id, cls_id, "defines")
|
|
for item in node.body:
|
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
m_id = f"{cls_id}.{item.name}"
|
|
graph.add_node(GNode(m_id, item.name + "()", "method", f"{node.name}.{item.name}", fp))
|
|
graph.add_edge(cls_id, m_id, "method")
|
|
|
|
imports = _module_imports(tree)[:MAX_IMPORTS_PER_FILE]
|
|
for mod in imports:
|
|
mod_id = f"mod:{mod}"
|
|
graph.add_node(GNode(mod_id, mod, "module", "import"))
|
|
graph.add_edge(file_id, mod_id, "imports")
|
|
|
|
|
|
def _module_imports(tree: ast.AST) -> List[str]:
|
|
"""Tên các module mà một cây AST import vào."""
|
|
mods: List[str] = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
mods += [a.name.split(".")[0] for a in node.names]
|
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
mods.append(node.module.split(".")[0])
|
|
seen, out = set(), []
|
|
for m in mods:
|
|
if m and m not in seen:
|
|
seen.add(m)
|
|
out.append(m)
|
|
return out
|
|
|
|
|
|
def _add_doc_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
|
|
"""Thêm một tệp tài liệu (Markdown…) vào đồ thị, tách theo cấp tiêu đề."""
|
|
file_id = f"file:{fpath}"
|
|
fp = str(fpath)
|
|
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), fp)):
|
|
return
|
|
graph.add_edge(dir_id, file_id, "contains")
|
|
try:
|
|
lines = fpath.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
except OSError:
|
|
return
|
|
|
|
# Track the most recent heading id at each level to build hierarchy.
|
|
last_at_level: Dict[int, str] = {0: file_id}
|
|
counter = 0
|
|
for line in lines:
|
|
stripped = line.lstrip()
|
|
if not stripped.startswith("#"):
|
|
continue
|
|
level = len(stripped) - len(stripped.lstrip("#"))
|
|
title = stripped[level:].strip()
|
|
if not title or level > 6:
|
|
continue
|
|
counter += 1
|
|
sec_id = f"{file_id}#sec{counter}"
|
|
if not graph.add_node(GNode(sec_id, title[:48], "section", title, fp)):
|
|
break
|
|
parent = next((last_at_level[lv] for lv in range(level - 1, -1, -1) if lv in last_at_level), file_id)
|
|
graph.add_edge(parent, sec_id, "subsection")
|
|
last_at_level[level] = sec_id
|
|
# invalidate deeper levels
|
|
for lv in list(last_at_level):
|
|
if lv > level:
|
|
del last_at_level[lv]
|
|
|
|
|
|
def _add_json_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
|
|
"""Parse a JSON file's OWN structure (object keys / array shape) into
|
|
nodes — the same way Python files become classes/functions and Markdown
|
|
becomes heading sections — instead of showing up as just a flat file
|
|
node with nothing inside it."""
|
|
file_id = f"file:{fpath}"
|
|
fp = str(fpath)
|
|
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), fp)):
|
|
return
|
|
graph.add_edge(dir_id, file_id, "contains")
|
|
try:
|
|
data = json.loads(fpath.read_text(encoding="utf-8", errors="replace"))
|
|
except (OSError, ValueError, RecursionError):
|
|
# RecursionError: a syntactically valid but very deeply nested document
|
|
# (e.g. 1000+ levels of arrays) blows Python's json parser's recursion
|
|
# budget — skip just this file rather than aborting the whole scan.
|
|
return
|
|
_add_json_value(graph, file_id, fp, data, depth=0)
|
|
|
|
|
|
def _json_scalar_preview(value) -> str:
|
|
"""Chuỗi xem trước ngắn cho một giá trị JSON, để nhãn node không quá dài."""
|
|
if isinstance(value, dict):
|
|
return f"{{…}} ({len(value)} keys)"
|
|
if isinstance(value, list):
|
|
return f"[…] ({len(value)} items)"
|
|
return str(value)[:40]
|
|
|
|
|
|
def _add_json_value(graph: StructureGraph, parent_id: str, fp: str, value, depth: int) -> None:
|
|
"""Thêm cấu trúc một giá trị JSON vào đồ thị, chặn ở ``MAX_JSON_DEPTH``.
|
|
|
|
Có trần độ sâu vì JSON lồng sâu sẽ sinh ra hàng nghìn node mà chẳng nói lên
|
|
điều gì về cấu trúc dự án.
|
|
"""
|
|
if depth >= MAX_JSON_DEPTH:
|
|
return
|
|
if isinstance(value, dict):
|
|
# Child ids are the parent's id plus this key's POSITION, never the key
|
|
# text itself — a key that happens to contain the "#" join character
|
|
# (or matches another key's text at a different depth) can otherwise
|
|
# collide with a deeper node's id and silently delete it from the graph.
|
|
for i, (key, val) in enumerate(list(value.items())[:MAX_JSON_KEYS_PER_LEVEL]):
|
|
key_id = f"{parent_id}#{i}"
|
|
# Only dicts and arrays-of-objects get expanded further; a scalar
|
|
# array (e.g. ["a","b","c"]) has nothing more to show, so it's
|
|
# labelled with its preview and left as a leaf like any scalar.
|
|
needs_recursion = isinstance(val, dict) or (
|
|
isinstance(val, list) and val and isinstance(val[0], dict))
|
|
# Always show key: value (even for nested objects/arrays show a preview)
|
|
label = f"{key}: {_json_scalar_preview(val)}"
|
|
if not graph.add_node(GNode(key_id, label[:120], "json_key", str(key), fp)):
|
|
continue
|
|
graph.add_edge(parent_id, key_id, "contains")
|
|
if needs_recursion:
|
|
_add_json_value(graph, key_id, fp, val, depth + 1)
|
|
elif isinstance(value, list) and value and isinstance(value[0], dict):
|
|
# Arrays of objects: show the FIRST element's shape as a representative
|
|
# sample rather than exploding every item (a 500-row JSON array would
|
|
# otherwise flood the graph with near-identical nodes).
|
|
sample_id = f"{parent_id}#0"
|
|
if graph.add_node(GNode(sample_id, f"[0] of {len(value)} (sample)", "json_key",
|
|
"array item sample", fp)):
|
|
graph.add_edge(parent_id, sample_id, "contains")
|
|
_add_json_value(graph, sample_id, fp, value[0], depth + 1)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Codebase-memory builder (best effort, falls back to local)
|
|
# --------------------------------------------------------------------------
|
|
def build_from_codebase_memory(mem, repo_path, mode: str = "all",
|
|
max_nodes: int = 0, max_edges: int = 0) -> StructureGraph:
|
|
"""Try to build from the codebase-memory knowledge graph; fall back local."""
|
|
try:
|
|
graph = StructureGraph(max_nodes=max_nodes, max_edges=max_edges)
|
|
produced = 0
|
|
for label, kind in (("Class", "class"), ("Function", "function")):
|
|
res = mem.call("search_graph", {"label": label, "limit": 150})
|
|
for item in _iter_results(res):
|
|
name = item.get("name") or item.get("label")
|
|
if not name:
|
|
continue
|
|
file = item.get("file") or item.get("path") or ""
|
|
nid = f"{label}:{file}:{name}"
|
|
if graph.add_node(GNode(nid, name, kind, file)):
|
|
produced += 1
|
|
if file:
|
|
fid = f"file:{file}"
|
|
graph.add_node(GNode(fid, Path(file).name, "file", file))
|
|
graph.add_edge(fid, nid, "defines")
|
|
if produced >= 3:
|
|
return graph
|
|
except Exception:
|
|
pass
|
|
return build_from_directory(repo_path, mode=mode)
|
|
|
|
|
|
def _iter_results(res):
|
|
"""Duyệt kết quả trả về từ bộ nhớ mã nguồn, chấp nhận nhiều khuôn khoá khác nhau
|
|
(``results`` / ``nodes`` / ``items`` / ``data``).
|
|
"""
|
|
if isinstance(res, dict):
|
|
for key in ("results", "nodes", "items", "data"):
|
|
val = res.get(key)
|
|
if isinstance(val, list):
|
|
return [x for x in val if isinstance(x, dict)]
|
|
if isinstance(res, list):
|
|
return [x for x in res if isinstance(x, dict)]
|
|
return []
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Layout (layered by distance from roots)
|
|
# --------------------------------------------------------------------------
|
|
def layered_layout(graph: StructureGraph, col_w: int = 280, row_h: int = 64) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, int]]:
|
|
"""Xếp đồ thị thành các cột theo bậc phụ thuộc, trong cột xếp dọc.
|
|
|
|
Trả về (toạ độ từng node, lớp của từng node).
|
|
"""
|
|
indeg = {n.id: 0 for n in graph.nodes}
|
|
adj = defaultdict(list)
|
|
for e in graph.edges:
|
|
if e.target in indeg:
|
|
indeg[e.target] += 1
|
|
adj[e.source].append(e.target)
|
|
|
|
roots = [n.id for n in graph.nodes if indeg.get(n.id, 0) == 0]
|
|
if not roots and graph.nodes:
|
|
roots = [graph.nodes[0].id]
|
|
|
|
level: Dict[str, int] = {}
|
|
dq = deque()
|
|
for r in roots:
|
|
level[r] = 0
|
|
dq.append(r)
|
|
while dq:
|
|
cur = dq.popleft()
|
|
for nxt in adj[cur]:
|
|
if nxt not in level:
|
|
level[nxt] = level[cur] + 1
|
|
dq.append(nxt)
|
|
for n in graph.nodes:
|
|
level.setdefault(n.id, 0)
|
|
|
|
by_level: Dict[int, List[str]] = defaultdict(list)
|
|
for n in graph.nodes:
|
|
by_level[level[n.id]].append(n.id)
|
|
|
|
pos: Dict[str, Tuple[int, int]] = {}
|
|
for lv in sorted(by_level):
|
|
for i, nid in enumerate(by_level[lv]):
|
|
pos[nid] = (lv * col_w, i * row_h)
|
|
return pos, level
|
|
|
|
|
|
def _networkx_layout(graph: StructureGraph, width: int, height: int):
|
|
"""Use networkx spring layout when available (better for heavy graphs)."""
|
|
try:
|
|
import networkx as nx
|
|
except ImportError:
|
|
return None
|
|
try:
|
|
g = nx.Graph()
|
|
g.add_nodes_from(n.id for n in graph.nodes)
|
|
g.add_edges_from((e.source, e.target) for e in graph.edges)
|
|
if g.number_of_nodes() == 0:
|
|
return None
|
|
pos = nx.spring_layout(g, seed=42) # deterministic
|
|
scale = min(width, height) * 0.45
|
|
out = {}
|
|
for nid, (x, y) in pos.items():
|
|
out[nid] = (int(width / 2 + x * scale), int(height / 2 + y * scale))
|
|
return out
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def force_layout(graph: StructureGraph, width: int = 1600, height: int = 1000) -> Dict[str, Tuple[int, int]]:
|
|
"""Layout positions. Prefers networkx spring layout when installed
|
|
(faster/nicer for heavy graphs); otherwise a deterministic pure-Python
|
|
Fruchterman-Reingold fallback (no extra dependency).
|
|
"""
|
|
import math
|
|
|
|
nx_pos = _networkx_layout(graph, width, height)
|
|
if nx_pos is not None:
|
|
return nx_pos
|
|
|
|
ids = [n.id for n in graph.nodes]
|
|
n = len(ids) or 1
|
|
k = math.sqrt((width * height) / n)
|
|
# deterministic initial placement on a circle
|
|
pos = {}
|
|
for i, nid in enumerate(ids):
|
|
ang = 2 * math.pi * i / n
|
|
pos[nid] = [width / 2 + (width / 3) * math.cos(ang),
|
|
height / 2 + (height / 3) * math.sin(ang)]
|
|
|
|
edges = [(e.source, e.target) for e in graph.edges if e.source in pos and e.target in pos]
|
|
iterations = min(220, max(20, 7000 // n))
|
|
t = width / 10.0
|
|
for _ in range(iterations):
|
|
disp = {nid: [0.0, 0.0] for nid in ids}
|
|
for i in range(n):
|
|
a = ids[i]
|
|
ax, ay = pos[a]
|
|
for j in range(i + 1, n):
|
|
b = ids[j]
|
|
dx = ax - pos[b][0]
|
|
dy = ay - pos[b][1]
|
|
dist = math.hypot(dx, dy) or 0.01
|
|
force = k * k / dist
|
|
ux, uy = dx / dist, dy / dist
|
|
disp[a][0] += ux * force
|
|
disp[a][1] += uy * force
|
|
disp[b][0] -= ux * force
|
|
disp[b][1] -= uy * force
|
|
for a, b in edges:
|
|
dx = pos[a][0] - pos[b][0]
|
|
dy = pos[a][1] - pos[b][1]
|
|
dist = math.hypot(dx, dy) or 0.01
|
|
force = dist * dist / k
|
|
ux, uy = dx / dist, dy / dist
|
|
disp[a][0] -= ux * force
|
|
disp[a][1] -= uy * force
|
|
disp[b][0] += ux * force
|
|
disp[b][1] += uy * force
|
|
for nid in ids:
|
|
dx, dy = disp[nid]
|
|
d = math.hypot(dx, dy) or 0.01
|
|
pos[nid][0] += dx / d * min(d, t)
|
|
pos[nid][1] += dy / d * min(d, t)
|
|
t *= 0.95
|
|
return {nid: (int(p[0]), int(p[1])) for nid, p in pos.items()}
|
|
|
|
|
|
NODE_KIND_COLORS = {
|
|
"dir": "#64748b",
|
|
"file": "#3b82f6",
|
|
"class": "#f37021",
|
|
"function": "#22a06b",
|
|
"method": "#8b5cf6",
|
|
"module": "#eab308",
|
|
"section": "#ec4899",
|
|
"json_key": "#06b6d4",
|
|
}
|
|
|
|
# The relationship (edge) types the builders emit, each with a distinct colour
|
|
# so the graph doesn't just show anonymous lines — every edge now carries a
|
|
# defined, colour-coded meaning (shown as a label on the edge + in the legend).
|
|
# contains — a folder/file holds another folder/file (tree structure)
|
|
# defines — a file/module defines a class or top-level function
|
|
# method — a class owns a method
|
|
# imports — a module imports another module
|
|
# subsection — a doc/JSON section nests a subsection/key
|
|
EDGE_KIND_COLORS = {
|
|
"contains": "#7c8aa0",
|
|
"defines": "#22a06b",
|
|
"method": "#8b5cf6",
|
|
"imports": "#eab308",
|
|
"subsection": "#ec4899",
|
|
} |