2185 lines
95 KiB
Python
2185 lines
95 KiB
Python
"""Interactive D3.js graph visualization for code knowledge graphs.
|
|
|
|
Exports graph data to JSON and generates a self-contained HTML file with
|
|
a force-directed D3.js visualization. Dark theme, zoomable, draggable,
|
|
with collapsible file clusters, tooltips, legend, and stats bar.
|
|
|
|
Supports multiple rendering modes for large graphs:
|
|
- ``full`` — render every node (default, current behavior)
|
|
- ``community`` — aggregate by community; double-click to drill down
|
|
- ``file`` — aggregate by file; each file is a node
|
|
- ``auto`` — choose community mode when node count exceeds threshold
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
from collections import Counter, defaultdict
|
|
from dataclasses import asdict
|
|
from pathlib import Path
|
|
|
|
from .graph import GraphStore, edge_to_dict, node_to_dict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _build_name_index(
|
|
nodes: list[dict], seen_qn: set[str]
|
|
) -> dict[str, list[str]]:
|
|
"""Build a mapping from short/module-style names to qualified names.
|
|
|
|
Returns ``{short_name: [qualified_name, ...]}``.
|
|
"""
|
|
index: dict[str, list[str]] = {}
|
|
|
|
def _add(key: str, qn: str) -> None:
|
|
index.setdefault(key, []).append(qn)
|
|
|
|
for n in nodes:
|
|
qn = n["qualified_name"]
|
|
_add(n["name"], qn)
|
|
# Index by "file::name" suffix (e.g. "cli.py::main")
|
|
if "::" in qn:
|
|
_add(qn.rsplit("/", 1)[-1], qn)
|
|
# Index by module-style path (e.g. "merit.cli" or "merit.cli.main")
|
|
fp = n.get("file_path", "")
|
|
if fp:
|
|
mod = fp.replace("/", ".").replace(".py", "")
|
|
if n["kind"] == "File":
|
|
_add(mod, qn)
|
|
# Index by every path suffix so C/C++ bare includes resolve.
|
|
# e.g. "/abs/libs/trading/Foo.hpp" is also indexed as
|
|
# "Foo.hpp", "trading/Foo.hpp", "libs/trading/Foo.hpp", …
|
|
parts = fp.replace("\\", "/").split("/")
|
|
for i in range(len(parts)):
|
|
suffix = "/".join(parts[i:])
|
|
if suffix:
|
|
_add(suffix, qn)
|
|
else:
|
|
_add(mod + "." + n["name"], qn)
|
|
return index
|
|
|
|
|
|
def _resolve_target(
|
|
target: str,
|
|
source: str,
|
|
seen_qn: set[str],
|
|
name_index: dict[str, list[str]],
|
|
) -> str | None:
|
|
"""Try to resolve an unqualified edge target to a full qualified name.
|
|
|
|
Returns the resolved qualified name, or None if unresolvable.
|
|
"""
|
|
# Already fully qualified
|
|
if target in seen_qn:
|
|
return target
|
|
|
|
candidates = name_index.get(target)
|
|
if not candidates:
|
|
return None
|
|
|
|
if len(candidates) == 1:
|
|
return candidates[0]
|
|
|
|
# Disambiguate: prefer node in the same file as the source
|
|
src_file = source.split("::")[0] if "::" in source else source
|
|
same_file = [c for c in candidates if c.startswith(src_file)]
|
|
if len(same_file) == 1:
|
|
return same_file[0]
|
|
|
|
# Prefer node in the same top-level directory
|
|
src_parts = src_file.rsplit("/", 1)[0] if "/" in src_file else ""
|
|
same_dir = [c for c in candidates if c.startswith(src_parts)]
|
|
if len(same_dir) == 1:
|
|
return same_dir[0]
|
|
|
|
# Ambiguous — pick first match rather than dropping the edge
|
|
return candidates[0]
|
|
|
|
|
|
def export_graph_data(store: GraphStore) -> dict:
|
|
"""Export all graph nodes and edges as a JSON-serializable dict.
|
|
|
|
Returns ``{"nodes": [...], "edges": [...], "stats": {...},
|
|
"flows": [...], "communities": [...]}``.
|
|
"""
|
|
nodes = []
|
|
seen_qn: set[str] = set()
|
|
|
|
# Preload community_id mapping from DB (column may not exist in old schemas)
|
|
community_map = store.get_all_community_ids()
|
|
|
|
for file_path in store.get_all_files():
|
|
for gnode in store.get_nodes_by_file(file_path):
|
|
if gnode.qualified_name in seen_qn:
|
|
continue
|
|
seen_qn.add(gnode.qualified_name)
|
|
d = node_to_dict(gnode)
|
|
d["params"] = gnode.params
|
|
d["return_type"] = gnode.return_type
|
|
d["community_id"] = community_map.get(gnode.qualified_name)
|
|
nodes.append(d)
|
|
|
|
name_index = _build_name_index(nodes, seen_qn)
|
|
|
|
all_edges = [edge_to_dict(e) for e in store.get_all_edges()]
|
|
|
|
# Resolve short/unqualified edge targets to full qualified names,
|
|
# then drop edges that still can't be resolved (external/stdlib calls).
|
|
edges = []
|
|
for e in all_edges:
|
|
src = _resolve_target(e["source"], e["source"], seen_qn, name_index)
|
|
tgt = _resolve_target(e["target"], e["source"], seen_qn, name_index)
|
|
if src and tgt:
|
|
e["source"] = src
|
|
e["target"] = tgt
|
|
edges.append(e)
|
|
|
|
stats = store.get_stats()
|
|
|
|
# Include flows (graceful fallback if table doesn't exist)
|
|
try:
|
|
from code_review_graph.flows import get_flows
|
|
flows = get_flows(store, limit=100)
|
|
except (ImportError, sqlite3.OperationalError) as exc:
|
|
logger.debug("flows unavailable for export: %s", exc)
|
|
flows = []
|
|
|
|
# Include communities (graceful fallback if table doesn't exist)
|
|
try:
|
|
from code_review_graph.communities import get_communities
|
|
communities = get_communities(store)
|
|
except (ImportError, sqlite3.OperationalError) as exc:
|
|
logger.debug("communities unavailable for export: %s", exc)
|
|
communities = []
|
|
|
|
return {
|
|
"nodes": nodes,
|
|
"edges": edges,
|
|
"stats": asdict(stats),
|
|
"flows": flows,
|
|
"communities": communities,
|
|
}
|
|
|
|
|
|
def _aggregate_community(data: dict) -> dict:
|
|
"""Aggregate full graph data into community-level super-nodes.
|
|
|
|
Each community becomes a single node sized by member count.
|
|
Edges between super-nodes represent the count of cross-community edges.
|
|
Returns a new dict with the same schema as *data* but fewer nodes/edges.
|
|
Also returns per-community detail data for drill-down rendering.
|
|
"""
|
|
communities = data.get("communities") or []
|
|
nodes = data["nodes"]
|
|
edges = data["edges"]
|
|
|
|
# Build mapping: qualified_name -> community_id
|
|
qn_to_cid: dict[str, int] = {}
|
|
for c in communities:
|
|
for qn in c.get("members", []):
|
|
qn_to_cid[qn] = c["id"]
|
|
|
|
# Also use node-level community_id for nodes not in community member lists
|
|
for n in nodes:
|
|
if n.get("community_id") is not None and n["qualified_name"] not in qn_to_cid:
|
|
qn_to_cid[n["qualified_name"]] = n["community_id"]
|
|
|
|
# Assign uncategorized nodes to a synthetic community id = -1
|
|
uncategorized_members: list[str] = []
|
|
for n in nodes:
|
|
if n["qualified_name"] not in qn_to_cid:
|
|
qn_to_cid[n["qualified_name"]] = -1
|
|
uncategorized_members.append(n["qualified_name"])
|
|
|
|
# Build community info map (including the synthetic uncategorized one)
|
|
cid_info: dict[int, dict] = {}
|
|
for c in communities:
|
|
cid_info[c["id"]] = c
|
|
if uncategorized_members:
|
|
cid_info[-1] = {
|
|
"id": -1,
|
|
"name": "Uncategorized",
|
|
"size": len(uncategorized_members),
|
|
"members": uncategorized_members,
|
|
"dominant_language": "",
|
|
"description": "Nodes not assigned to any community",
|
|
"cohesion": 0,
|
|
"level": 0,
|
|
}
|
|
|
|
# Build super-nodes (one per community)
|
|
super_nodes = []
|
|
for cid, info in cid_info.items():
|
|
size = info.get("size", len(info.get("members", [])))
|
|
if size == 0:
|
|
continue
|
|
super_nodes.append({
|
|
"qualified_name": f"__community__{cid}",
|
|
"name": info.get("name", f"Community {cid}"),
|
|
"kind": "Community",
|
|
"file_path": "",
|
|
"line_start": None,
|
|
"line_end": None,
|
|
"language": info.get("dominant_language", ""),
|
|
"community_id": cid,
|
|
"member_count": size,
|
|
"description": info.get("description", ""),
|
|
"id": cid,
|
|
})
|
|
|
|
# Build super-edges: aggregate cross-community edges
|
|
cross_edge_counts: Counter[tuple[int, int]] = Counter()
|
|
for e in edges:
|
|
src_cid = qn_to_cid.get(e["source"])
|
|
tgt_cid = qn_to_cid.get(e["target"])
|
|
if src_cid is not None and tgt_cid is not None and src_cid != tgt_cid:
|
|
pair = (min(src_cid, tgt_cid), max(src_cid, tgt_cid))
|
|
cross_edge_counts[pair] += 1
|
|
|
|
super_edges = []
|
|
for (c1, c2), count in cross_edge_counts.items():
|
|
super_edges.append({
|
|
"source": f"__community__{c1}",
|
|
"target": f"__community__{c2}",
|
|
"kind": "CROSS_COMMUNITY",
|
|
"weight": count,
|
|
})
|
|
|
|
# Build per-community detail data for drill-down
|
|
community_details: dict[int, dict] = {}
|
|
cid_members_set: dict[int, set[str]] = defaultdict(set)
|
|
for qn, cid in qn_to_cid.items():
|
|
cid_members_set[cid].add(qn)
|
|
|
|
for cid, member_qns in cid_members_set.items():
|
|
detail_nodes = [n for n in nodes if n["qualified_name"] in member_qns]
|
|
detail_edges = [
|
|
e for e in edges
|
|
if e["source"] in member_qns and e["target"] in member_qns
|
|
]
|
|
community_details[cid] = {
|
|
"nodes": detail_nodes,
|
|
"edges": detail_edges,
|
|
}
|
|
|
|
return {
|
|
"nodes": super_nodes,
|
|
"edges": super_edges,
|
|
"stats": data["stats"],
|
|
"flows": data.get("flows", []),
|
|
"communities": communities,
|
|
"mode": "community",
|
|
"community_details": {
|
|
str(k): v for k, v in community_details.items()
|
|
},
|
|
}
|
|
|
|
|
|
def _aggregate_file(data: dict) -> dict:
|
|
"""Aggregate full graph data into file-level nodes.
|
|
|
|
Each file becomes a node sized by symbol count.
|
|
Edges between files represent aggregated cross-file dependencies.
|
|
"""
|
|
nodes = data["nodes"]
|
|
edges = data["edges"]
|
|
|
|
# Count symbols per file
|
|
file_symbol_count: Counter[str] = Counter()
|
|
qn_to_file: dict[str, str] = {}
|
|
file_languages: dict[str, str] = {}
|
|
|
|
for n in nodes:
|
|
fp = n.get("file_path", "")
|
|
if not fp:
|
|
continue
|
|
qn_to_file[n["qualified_name"]] = fp
|
|
if n["kind"] != "File":
|
|
file_symbol_count[fp] += 1
|
|
else:
|
|
file_symbol_count.setdefault(fp, 0)
|
|
if n.get("language"):
|
|
file_languages[fp] = n["language"]
|
|
|
|
# Build file nodes
|
|
file_nodes = []
|
|
for fp, count in file_symbol_count.items():
|
|
parts = fp.replace("\\", "/").split("/")
|
|
short = parts[-1] if parts else fp
|
|
parent = parts[-2] if len(parts) >= 2 else ""
|
|
label = f"{parent}/{short}" if parent else short
|
|
# Recover community_id from the majority of symbols in this file
|
|
cid = None
|
|
for n in nodes:
|
|
if n.get("file_path") == fp and n.get("community_id") is not None:
|
|
cid = n["community_id"]
|
|
break
|
|
file_nodes.append({
|
|
"qualified_name": fp,
|
|
"name": label,
|
|
"kind": "File",
|
|
"file_path": fp,
|
|
"line_start": None,
|
|
"line_end": None,
|
|
"language": file_languages.get(fp, ""),
|
|
"community_id": cid,
|
|
"symbol_count": count,
|
|
})
|
|
|
|
# Aggregate cross-file edges
|
|
cross_file_counts: Counter[tuple[str, str]] = Counter()
|
|
for e in edges:
|
|
src_fp = qn_to_file.get(e["source"])
|
|
tgt_fp = qn_to_file.get(e["target"])
|
|
if src_fp and tgt_fp and src_fp != tgt_fp:
|
|
pair = (src_fp, tgt_fp)
|
|
cross_file_counts[pair] += 1
|
|
|
|
file_edges = []
|
|
for (f1, f2), count in cross_file_counts.items():
|
|
file_edges.append({
|
|
"source": f1,
|
|
"target": f2,
|
|
"kind": "DEPENDS_ON",
|
|
"weight": count,
|
|
})
|
|
|
|
return {
|
|
"nodes": file_nodes,
|
|
"edges": file_edges,
|
|
"stats": data["stats"],
|
|
"flows": data.get("flows", []),
|
|
"communities": data.get("communities", []),
|
|
"mode": "file",
|
|
}
|
|
|
|
|
|
def generate_html(
|
|
store: GraphStore,
|
|
output_path: str | Path,
|
|
mode: str = "auto",
|
|
max_full_nodes: int = 3000,
|
|
) -> Path:
|
|
"""Generate a self-contained interactive HTML visualization.
|
|
|
|
Args:
|
|
store: The GraphStore to read graph data from.
|
|
output_path: Path for the output HTML file.
|
|
mode: Rendering mode — ``"auto"``, ``"full"``, ``"community"``,
|
|
or ``"file"``. ``"auto"`` switches to ``"community"`` when
|
|
the node count exceeds *max_full_nodes*.
|
|
max_full_nodes: Threshold for auto-switching to community mode.
|
|
|
|
Writes the HTML file to *output_path* and returns the resolved Path.
|
|
"""
|
|
output_path = Path(output_path)
|
|
stats = store.get_stats()
|
|
if stats.total_nodes > 50000:
|
|
logger.warning(
|
|
"Graph has %d nodes — visualization may be slow. "
|
|
"Consider filtering by file pattern.", stats.total_nodes,
|
|
)
|
|
data = export_graph_data(store)
|
|
|
|
# Determine effective mode
|
|
effective_mode = mode
|
|
if effective_mode == "auto":
|
|
effective_mode = (
|
|
"community" if stats.total_nodes > max_full_nodes else "full"
|
|
)
|
|
|
|
if effective_mode == "community":
|
|
# Keep full data available for drill-down; aggregate for top-level
|
|
agg = _aggregate_community(data)
|
|
# Escape </script> inside JSON to prevent premature tag closure
|
|
data_json = json.dumps(agg, default=str).replace("</", "<\\/")
|
|
html = _AGGREGATED_HTML_TEMPLATE.replace("__GRAPH_DATA__", data_json)
|
|
elif effective_mode == "file":
|
|
agg = _aggregate_file(data)
|
|
data_json = json.dumps(agg, default=str).replace("</", "<\\/")
|
|
html = _AGGREGATED_HTML_TEMPLATE.replace("__GRAPH_DATA__", data_json)
|
|
else:
|
|
# full mode — original behavior
|
|
data_json = json.dumps(data, default=str).replace("</", "<\\/")
|
|
html = _HTML_TEMPLATE.replace("__GRAPH_DATA__", data_json)
|
|
|
|
output_path.write_text(html, encoding="utf-8")
|
|
return output_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Full D3.js interactive HTML template
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Template lives in this file for zero-dependency packaging (no external files
|
|
# to locate at runtime). The E501 suppression for this module is configured via
|
|
# pyproject.toml per-file-ignores for this reason.
|
|
|
|
_HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Code Review Graph</title>
|
|
<script src="https://d3js.org/d3.v7.min.js" integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i" crossorigin="anonymous"></script>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
|
body {
|
|
background: #0d1117; color: #c9d1d9;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
font-size: 13px;
|
|
}
|
|
svg { display: block; width: 100%; height: 100%; }
|
|
#legend {
|
|
position: absolute; top: 16px; left: 16px;
|
|
background: rgba(22,27,34,0.95); border: 1px solid #30363d;
|
|
border-radius: 10px; padding: 16px 20px;
|
|
font-size: 12px; line-height: 1.8;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
backdrop-filter: blur(12px); z-index: 10;
|
|
}
|
|
#legend h3 {
|
|
font-size: 11px; font-weight: 700; margin-bottom: 6px;
|
|
color: #9eaab6; text-transform: uppercase; letter-spacing: 1px;
|
|
}
|
|
.legend-section { margin-bottom: 10px; }
|
|
.legend-section:last-child { margin-bottom: 0; }
|
|
.legend-item { display: flex; align-items: center; gap: 10px; padding: 2px 0; cursor: default; }
|
|
.legend-item[data-edge-kind] { cursor: pointer; user-select: none; }
|
|
.legend-item[data-edge-kind].dimmed { opacity: 0.3; }
|
|
.legend-item svg { flex-shrink: 0; }
|
|
.legend-line { width: 24px; height: 0; flex-shrink: 0; border-top-width: 2px; }
|
|
.l-calls { border-top: 2px solid #3fb950; }
|
|
.l-imports { border-top: 2px dashed #f0883e; }
|
|
.l-inherits { border-top: 2.5px dotted #d2a8ff; }
|
|
.l-contains { border-top: 1px solid rgba(139,148,158,0.3); }
|
|
#stats-bar {
|
|
position: absolute; bottom: 0; left: 0; right: 0;
|
|
background: rgba(13,17,23,0.95); border-top: 1px solid #21262d;
|
|
padding: 8px 24px; display: flex; gap: 32px; justify-content: center;
|
|
font-size: 12px; color: #9eaab6; backdrop-filter: blur(12px);
|
|
}
|
|
.stat-item { display: flex; gap: 6px; align-items: center; }
|
|
.stat-value { color: #e6edf3; font-weight: 600; }
|
|
#tooltip {
|
|
position: absolute; pointer-events: none;
|
|
background: rgba(22,27,34,0.97); color: #c9d1d9;
|
|
border: 1px solid #30363d; border-radius: 8px;
|
|
padding: 12px 16px; font-size: 12px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
|
max-width: 360px; line-height: 1.7;
|
|
opacity: 0; transition: opacity 0.15s ease;
|
|
z-index: 1000; backdrop-filter: blur(12px);
|
|
}
|
|
#tooltip.visible { opacity: 1; }
|
|
.tt-name { font-weight: 700; font-size: 14px; color: #e6edf3; }
|
|
.tt-kind {
|
|
display: inline-block; font-size: 9px; font-weight: 700;
|
|
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
}
|
|
.tt-row { margin-top: 4px; }
|
|
.tt-label { color: #9eaab6; }
|
|
.tt-file { color: #58a6ff; font-size: 11px; }
|
|
#controls {
|
|
position: absolute; top: 16px; right: 16px;
|
|
display: flex; gap: 8px; z-index: 10; flex-wrap: wrap;
|
|
max-width: 650px; justify-content: flex-end;
|
|
}
|
|
#controls button, #controls select {
|
|
background: rgba(22,27,34,0.95); color: #c9d1d9;
|
|
border: 1px solid #30363d; border-radius: 8px;
|
|
padding: 8px 14px; font-size: 12px; cursor: pointer;
|
|
backdrop-filter: blur(12px); transition: all 0.15s;
|
|
}
|
|
#controls button:hover, #controls select:hover { background: #30363d; border-color: #8b949e; }
|
|
#controls button.active { background: #1f6feb; border-color: #58a6ff; color: #fff; }
|
|
#controls select { outline: none; max-width: 200px; }
|
|
#controls select option { background: #161b22; color: #c9d1d9; }
|
|
#search {
|
|
background: rgba(22,27,34,0.95); color: #c9d1d9;
|
|
border: 1px solid #30363d; border-radius: 8px;
|
|
padding: 8px 14px; font-size: 12px; width: 220px;
|
|
outline: none; backdrop-filter: blur(12px);
|
|
}
|
|
#search:focus { border-color: #58a6ff; }
|
|
#search::placeholder { color: #6e7681; }
|
|
#search-results {
|
|
position: absolute; top: 52px; right: 16px;
|
|
background: rgba(22,27,34,0.97); border: 1px solid #30363d;
|
|
border-radius: 8px; max-height: 240px; overflow-y: auto;
|
|
z-index: 15; display: none; min-width: 220px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
|
}
|
|
.sr-item {
|
|
padding: 8px 14px; cursor: pointer; font-size: 12px;
|
|
border-bottom: 1px solid #21262d; display: flex; gap: 8px; align-items: center;
|
|
}
|
|
.sr-item:hover { background: #30363d; }
|
|
.sr-item:last-child { border-bottom: none; }
|
|
.sr-kind { font-size: 9px; padding: 2px 6px; border-radius: 8px; text-transform: uppercase; font-weight: 700; }
|
|
#detail-panel {
|
|
position: absolute; top: 16px; left: 16px;
|
|
width: 320px; max-height: calc(100vh - 80px);
|
|
background: rgba(22,27,34,0.97); border: 1px solid #30363d;
|
|
border-radius: 10px; padding: 20px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
|
backdrop-filter: blur(12px); z-index: 15;
|
|
overflow-y: auto; display: none; font-size: 12px;
|
|
}
|
|
#detail-panel.visible { display: block; }
|
|
#detail-panel h2 { font-size: 16px; color: #e6edf3; margin-bottom: 4px; word-break: break-all; }
|
|
#detail-panel .dp-close {
|
|
position: absolute; top: 12px; right: 14px;
|
|
cursor: pointer; color: #8b949e; font-size: 14px; line-height: 1;
|
|
border: 1px solid #30363d; background: rgba(22,27,34,0.95);
|
|
border-radius: 6px; width: 28px; height: 28px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
transition: all 0.15s;
|
|
}
|
|
#detail-panel .dp-close:hover { color: #e6edf3; border-color: #8b949e; background: #30363d; }
|
|
.dp-section { margin-top: 14px; }
|
|
.dp-section h4 { color: #9eaab6; font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px; }
|
|
.dp-list { list-style: none; }
|
|
.dp-list li { padding: 3px 0; color: #c9d1d9; cursor: pointer; }
|
|
.dp-list li:hover { color: #58a6ff; text-decoration: underline; }
|
|
.dp-meta { color: #9eaab6; }
|
|
.dp-meta span { color: #e6edf3; font-weight: 600; }
|
|
#filter-panel {
|
|
position: absolute; bottom: 50px; left: 16px;
|
|
background: rgba(22,27,34,0.95); border: 1px solid #30363d;
|
|
border-radius: 10px; padding: 14px 18px;
|
|
font-size: 12px; backdrop-filter: blur(12px); z-index: 10;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
}
|
|
#filter-panel h3 {
|
|
font-size: 11px; font-weight: 700; margin-bottom: 8px;
|
|
color: #9eaab6; text-transform: uppercase; letter-spacing: 1px;
|
|
}
|
|
.filter-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; }
|
|
.filter-item input { accent-color: #58a6ff; cursor: pointer; }
|
|
:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; }
|
|
.filter-item input:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; }
|
|
.dp-close:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; }
|
|
.filter-item:focus-within { outline: 2px solid #58a6ff; outline-offset: 2px; border-radius: 4px; }
|
|
#help-overlay {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
|
display: flex; align-items: center; justify-content: center; z-index: 100;
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
#help-overlay.hidden { display: none; }
|
|
.help-content {
|
|
position: relative; background: #161b22; border: 1px solid #30363d;
|
|
border-radius: 12px; padding: 28px 32px; max-width: 420px; width: 90%;
|
|
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
|
|
}
|
|
.help-content h2 { font-size: 16px; color: #e6edf3; margin-bottom: 16px; }
|
|
.help-content .help-close { position: absolute; top: 12px; right: 14px; }
|
|
.help-content table { width: 100%; border-collapse: collapse; }
|
|
.help-content td { padding: 6px 8px; color: #c9d1d9; font-size: 13px; border-bottom: 1px solid #21262d; }
|
|
.help-content td:first-child { white-space: nowrap; width: 1%; }
|
|
kbd {
|
|
display: inline-block; background: #21262d; border: 1px solid #30363d;
|
|
border-radius: 5px; padding: 2px 7px; font-size: 11px; font-family: inherit;
|
|
color: #e6edf3; box-shadow: inset 0 -1px 0 #0d1117; line-height: 1.6;
|
|
}
|
|
.help-dismiss {
|
|
margin-top: 16px; display: block; text-align: center;
|
|
color: #8b949e; font-size: 12px; cursor: pointer;
|
|
}
|
|
.help-dismiss:hover { color: #e6edf3; }
|
|
#loading-overlay {
|
|
position: fixed; inset: 0; display: flex; align-items: center;
|
|
justify-content: center; z-index: 50; background: rgba(13,17,23,0.85);
|
|
backdrop-filter: blur(6px); transition: opacity 0.4s ease;
|
|
}
|
|
#loading-overlay.hidden { opacity: 0; pointer-events: none; }
|
|
.loading-spinner {
|
|
width: 36px; height: 36px; border: 3px solid #30363d;
|
|
border-top-color: #58a6ff; border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.loading-text { color: #9eaab6; font-size: 13px; margin-top: 14px; text-align: center; }
|
|
#empty-state {
|
|
position: fixed; inset: 0; display: none; align-items: center;
|
|
justify-content: center; z-index: 50; flex-direction: column; gap: 12px;
|
|
}
|
|
#empty-state.visible { display: flex; }
|
|
.empty-icon { font-size: 48px; opacity: 0.4; }
|
|
.empty-title { color: #e6edf3; font-size: 18px; font-weight: 600; }
|
|
.empty-desc { color: #9eaab6; font-size: 13px; max-width: 320px; text-align: center; line-height: 1.6; }
|
|
#community-legend {
|
|
position: absolute; bottom: 50px; right: 16px;
|
|
background: rgba(22,27,34,0.95);
|
|
border: 1px solid #30363d;
|
|
border-radius: 10px;
|
|
padding: 14px 18px; font-size: 12px;
|
|
backdrop-filter: blur(12px); z-index: 10;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
max-height: 300px; overflow-y: auto;
|
|
display: none;
|
|
}
|
|
#community-legend.visible { display: block; }
|
|
#community-legend h3 {
|
|
font-size: 11px; font-weight: 700;
|
|
margin-bottom: 8px; color: #8b949e;
|
|
text-transform: uppercase; letter-spacing: 1px;
|
|
}
|
|
.cl-item {
|
|
display: flex; align-items: center;
|
|
gap: 8px; padding: 3px 0;
|
|
cursor: pointer; user-select: none;
|
|
}
|
|
.cl-item input {
|
|
accent-color: #58a6ff; cursor: pointer;
|
|
}
|
|
.cl-swatch {
|
|
width: 10px; height: 10px;
|
|
border-radius: 50%; flex-shrink: 0;
|
|
}
|
|
marker { overflow: visible; }
|
|
g.node-g:focus { outline: none; }
|
|
g.node-g:focus-visible .node-shape { stroke: #58a6ff !important; stroke-width: 3 !important; }
|
|
g.node-g:focus-visible .glow-ring { stroke: #58a6ff !important; opacity: 0.6 !important; }
|
|
button.legend-edge { background: none; border: none; color: #c9d1d9; font-size: 12px; font-family: inherit; }
|
|
button.legend-edge:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; border-radius: 4px; }
|
|
.sr-item.sr-active { background: #30363d; }
|
|
.skip-link {
|
|
position: absolute; top: -40px; left: 16px; z-index: 100;
|
|
background: #1f6feb; color: #fff; padding: 8px 16px;
|
|
border-radius: 0 0 8px 8px; text-decoration: none; font-weight: 600;
|
|
transition: top 0.2s;
|
|
}
|
|
.skip-link:focus { top: 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<a href="#graph-svg" class="skip-link">Skip to graph</a>
|
|
<nav id="legend" aria-label="Graph legend">
|
|
<h3>Nodes</h3>
|
|
<div class="legend-section">
|
|
<div class="legend-item"><svg width="16" height="16" viewBox="-8 -8 16 16" aria-hidden="true"><circle r="6" fill="#58a6ff"/></svg> File</div>
|
|
<div class="legend-item"><svg width="16" height="16" viewBox="-8 -8 16 16" aria-hidden="true"><rect x="-5" y="-5" width="10" height="10" fill="#f0883e"/></svg> Class</div>
|
|
<div class="legend-item"><svg width="16" height="16" viewBox="-8 -8 16 16" aria-hidden="true"><polygon points="0,-6 6,5 -6,5" fill="#3fb950"/></svg> Function</div>
|
|
<div class="legend-item"><svg width="16" height="16" viewBox="-8 -8 16 16" aria-hidden="true"><polygon points="0,-6 6,0 0,6 -6,0" fill="#d2a8ff"/></svg> Test</div>
|
|
<div class="legend-item"><svg width="16" height="16" viewBox="-8 -8 16 16" aria-hidden="true"><path d="M-2,-6v4h-4v4h4v4h4v-4h4v-4h-4v-4z" fill="#8b949e"/></svg> Type</div>
|
|
</div>
|
|
<h3>Edges</h3>
|
|
<div class="legend-section">
|
|
<button class="legend-item legend-edge" data-edge-kind="CALLS" aria-pressed="true"><span class="legend-line l-calls"></span> Calls</button>
|
|
<button class="legend-item legend-edge" data-edge-kind="IMPORTS_FROM" aria-pressed="true"><span class="legend-line l-imports"></span> Imports</button>
|
|
<button class="legend-item legend-edge" data-edge-kind="INHERITS" aria-pressed="true"><span class="legend-line l-inherits"></span> Inherits</button>
|
|
<button class="legend-item legend-edge" data-edge-kind="CONTAINS" aria-pressed="true"><span class="legend-line l-contains"></span> Contains</button>
|
|
<button class="legend-item legend-edge" data-edge-kind="IMPLEMENTS" aria-pressed="true"><span class="legend-line" style="border-top:2px dashed #f9e2af"></span> Implements</button>
|
|
<button class="legend-item legend-edge" data-edge-kind="TESTED_BY" aria-pressed="true"><span class="legend-line" style="border-top:2px dotted #f38ba8"></span> Tested By</button>
|
|
<button class="legend-item legend-edge" data-edge-kind="DEPENDS_ON" aria-pressed="true"><span class="legend-line" style="border-top:2px dashed #fab387"></span> Depends On</button>
|
|
</div>
|
|
</nav>
|
|
<div id="filter-panel">
|
|
<h3>Filter by Kind</h3>
|
|
<label class="filter-item"><input type="checkbox" data-kind="File" checked> File</label>
|
|
<label class="filter-item"><input type="checkbox" data-kind="Class" checked> Class</label>
|
|
<label class="filter-item"><input type="checkbox" data-kind="Function" checked> Function</label>
|
|
<label class="filter-item"><input type="checkbox" data-kind="Test" checked> Test</label>
|
|
<label class="filter-item"><input type="checkbox" data-kind="Type" checked> Type</label>
|
|
</div>
|
|
<div id="controls">
|
|
<input id="search" type="text" placeholder="Search nodes…" autocomplete="off" spellcheck="false" aria-label="Search graph nodes by name" aria-controls="search-results" aria-expanded="false">
|
|
<select id="flow-select" aria-label="Select execution flow to highlight"><option value="">Flows</option></select>
|
|
<button id="btn-community" title="Toggle community coloring" aria-label="Toggle community coloring" aria-pressed="false">Communities</button>
|
|
<button id="btn-fit" title="Fit to screen" aria-label="Fit graph to screen">Fit</button>
|
|
<button id="btn-labels" title="Toggle labels" class="active" aria-label="Toggle node labels" aria-pressed="true">Labels</button>
|
|
<button id="btn-help" title="Keyboard shortcuts" aria-label="Show keyboard shortcuts">?</button>
|
|
</div>
|
|
<div id="search-results" role="listbox" aria-label="Search results"></div>
|
|
<div id="detail-panel" role="dialog" aria-label="Node detail" aria-modal="false"><button class="dp-close" aria-label="Close detail panel">×</button><div id="dp-content" tabindex="-1"></div></div>
|
|
<div id="stats-bar" role="status" aria-label="Graph statistics"></div>
|
|
<div id="community-legend" aria-label="Community legend"></div>
|
|
<div id="tooltip" role="tooltip" aria-live="polite"></div>
|
|
<div id="help-overlay" class="hidden" role="dialog" aria-label="Help overlay" aria-modal="true">
|
|
<div class="help-content">
|
|
<h2>Graph Interactions</h2>
|
|
<button class="dp-close help-close" aria-label="Close help">×</button>
|
|
<table>
|
|
<tr><td>Click a file</td><td>Expand/collapse contained symbols</td></tr>
|
|
<tr><td>Click symbol</td><td>Show detail panel with callers/callees</td></tr>
|
|
<tr><td>Shift+click file</td><td>Show detail panel without toggling collapse</td></tr>
|
|
<tr><td>Hover</td><td>Highlight connected nodes and edges</td></tr>
|
|
<tr><td>Drag</td><td>Pin a node in place</td></tr>
|
|
<tr><td>Scroll</td><td>Zoom in/out</td></tr>
|
|
<tr><td>Click+drag background</td><td>Pan the view</td></tr>
|
|
<tr><td>Search</td><td>Type to filter — matching nodes stay bright</td></tr>
|
|
<tr><td>Legend edges</td><td>Click edge types in the legend to toggle visibility</td></tr>
|
|
</table>
|
|
<h2 style="margin-top:16px">Keyboard Shortcuts</h2>
|
|
<table>
|
|
<tr><td><kbd>/</kbd></td><td>Focus search</td></tr>
|
|
<tr><td><kbd>?</kbd></td><td>Toggle this help</td></tr>
|
|
<tr><td><kbd>Esc</kbd></td><td>Close panel / search / help</td></tr>
|
|
<tr><td><kbd>Enter</kbd> / <kbd>Space</kbd></td><td>Activate focused node</td></tr>
|
|
<tr><td><kbd>Arrow keys</kbd></td><td>Navigate between nodes</td></tr>
|
|
</table>
|
|
<span class="help-dismiss">Click anywhere outside to dismiss</span>
|
|
</div>
|
|
</div>
|
|
<div id="loading-overlay" aria-live="polite">
|
|
<div>
|
|
<div class="loading-spinner"></div>
|
|
<div class="loading-text">Laying out graph…</div>
|
|
</div>
|
|
</div>
|
|
<div id="empty-state" role="status">
|
|
<div class="empty-icon">🔍</div>
|
|
<div class="empty-title">No nodes to display</div>
|
|
<div class="empty-desc">The graph is empty. Run <strong>code-review-graph build</strong> to index your codebase, then regenerate the visualization.</div>
|
|
</div>
|
|
<svg id="graph-svg" tabindex="-1" role="img" aria-label="Interactive code knowledge graph visualization. Use search to find nodes, click files to expand."></svg>
|
|
<script>
|
|
"use strict";
|
|
var graphData = __GRAPH_DATA__;
|
|
var KIND_COLOR = { File:"#58a6ff", Class:"#f0883e", Function:"#3fb950", Test:"#d2a8ff", Type:"#8b949e" };
|
|
var KIND_RADIUS = { File:18, Class:12, Function:6, Test:6, Type:5 };
|
|
var KIND_AREA = { File:1018, Class:452, Function:113, Test:113, Type:79 };
|
|
var KIND_SHAPE = { File:d3.symbolCircle, Class:d3.symbolSquare, Function:d3.symbolTriangle, Test:d3.symbolDiamond, Type:d3.symbolCross };
|
|
var EDGE_COLOR = { CALLS:"#3fb950", IMPORTS_FROM:"#f0883e", INHERITS:"#d2a8ff", CONTAINS:"rgba(139,148,158,0.15)", IMPLEMENTS:"#f9e2af", TESTED_BY:"#f38ba8", DEPENDS_ON:"#fab387" };
|
|
var communityColorScale = d3.scaleOrdinal(d3.schemeTableau10);
|
|
var communityColoringOn = false;
|
|
function escH(s) { return !s ? "" : s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/`/g,"`"); }
|
|
function displayName(d) {
|
|
if (d.kind === "File") {
|
|
var fp = d.file_path || d.qualified_name || d.name;
|
|
var parts = fp.replace(/\\/g, "/").split("/");
|
|
var fname = parts.pop();
|
|
var parent = parts.pop() || "";
|
|
return parent ? parent + "/" + fname : fname;
|
|
}
|
|
return d.name;
|
|
}
|
|
var nodes = graphData.nodes.map(function(d) { var o = Object.assign({}, d); o._id = d.qualified_name; o.label = displayName(d); return o; });
|
|
var edges = graphData.edges.map(function(d) { var o = Object.assign({}, d); o._source = d.source; o._target = d.target; return o; });
|
|
var stats = graphData.stats;
|
|
var flows = graphData.flows || [];
|
|
var communities = graphData.communities || [];
|
|
var nodeById = new Map(nodes.map(function(n) { return [n.qualified_name, n]; }));
|
|
var hiddenEdgeKinds = new Set();
|
|
var hiddenNodeKinds = new Set();
|
|
var collapsedFiles = new Set();
|
|
var containsChildren = new Map();
|
|
var childToParent = new Map();
|
|
edges.forEach(function(e) {
|
|
if (e.kind === "CONTAINS") {
|
|
if (!containsChildren.has(e._source)) containsChildren.set(e._source, new Set());
|
|
containsChildren.get(e._source).add(e._target);
|
|
childToParent.set(e._target, e._source);
|
|
}
|
|
});
|
|
function allDescendants(qn) {
|
|
var result = new Set();
|
|
var stack = [qn];
|
|
while (stack.length) {
|
|
var cur = stack.pop();
|
|
var children = containsChildren.get(cur);
|
|
if (!children) continue;
|
|
children.forEach(function(c) { if (!result.has(c)) { result.add(c); stack.push(c); } });
|
|
}
|
|
return result;
|
|
}
|
|
var nodeToCommunity = new Map();
|
|
communities.forEach(function(c) {
|
|
(c.members || []).forEach(function(qn) {
|
|
nodeToCommunity.set(qn, c.id);
|
|
});
|
|
});
|
|
// Compute node degree (connection count) for size scaling
|
|
var nodeDegree = new Map();
|
|
edges.forEach(function(e) {
|
|
var s = e._source, t = e._target;
|
|
nodeDegree.set(s, (nodeDegree.get(s) || 0) + 1);
|
|
nodeDegree.set(t, (nodeDegree.get(t) || 0) + 1);
|
|
});
|
|
var maxDegree = 1;
|
|
nodeDegree.forEach(function(v) {
|
|
if (v > maxDegree) maxDegree = v;
|
|
});
|
|
function degreeRadius(d) {
|
|
var base = KIND_RADIUS[d.kind] || 6;
|
|
var deg = nodeDegree.get(d.qualified_name) || 0;
|
|
// Scale: base + up to 2x base proportional to degree
|
|
var scale = 1 + (deg / maxDegree);
|
|
return Math.round(base * scale);
|
|
}
|
|
var nodeIdToQn = new Map();
|
|
nodes.forEach(function(n) { nodeIdToQn.set(n.id, n.qualified_name); });
|
|
var flowSelect = document.getElementById("flow-select");
|
|
flows.forEach(function(f, i) {
|
|
var opt = document.createElement("option");
|
|
opt.value = i;
|
|
opt.textContent = f.name + " (" + f.node_count + " nodes)";
|
|
flowSelect.appendChild(opt);
|
|
});
|
|
var statsBar = document.getElementById("stats-bar");
|
|
var langList = (stats.languages || []).join(", ") || "n/a";
|
|
function si(l, v) { return '<div class="stat-item"><span class="tt-label">' + escH(l) + '</span> <span class="stat-value">' + escH(String(v)) + '</span></div>'; }
|
|
statsBar.textContent = "";
|
|
statsBar.insertAdjacentHTML("beforeend", si("Nodes", stats.total_nodes) + si("Edges", stats.total_edges) + si("Files", stats.files_count) + si("Languages", langList));
|
|
var tooltip = document.getElementById("tooltip");
|
|
function showTooltip(ev, d) {
|
|
var bg = communityColoringOn && d.community_id != null ? communityColorScale(d.community_id) : (KIND_COLOR[d.kind] || "#555");
|
|
var relFile = d.file_path ? d.file_path.split("/").slice(-3).join("/") : "";
|
|
var h = '<span class="tt-name">' + escH(d.label) + '</span>';
|
|
h += '<span class="tt-kind" style="background:' + bg + ';color:#0d1117">' + escH(d.kind) + '</span>';
|
|
if (relFile) h += '<div class="tt-row tt-file">' + escH(relFile) + '</div>';
|
|
if (d.line_start != null) h += '<div class="tt-row"><span class="tt-label">Lines: </span>' + d.line_start + ' \u2013 ' + (d.line_end || d.line_start) + '</div>';
|
|
if (d.params) h += '<div class="tt-row"><span class="tt-label">Params: </span>' + escH(d.params) + '</div>';
|
|
if (d.return_type) h += '<div class="tt-row"><span class="tt-label">Returns: </span>' + escH(d.return_type) + '</div>';
|
|
if (d.community_id != null) {
|
|
var comm = communities.find(function(c) { return c.id === d.community_id; });
|
|
if (comm) h += '<div class="tt-row"><span class="tt-label">Community: </span>' + escH(comm.name) + '</div>';
|
|
}
|
|
tooltip.textContent = "";
|
|
tooltip.insertAdjacentHTML("beforeend", h);
|
|
tooltip.classList.add("visible");
|
|
moveTooltip(ev);
|
|
}
|
|
function moveTooltip(ev) {
|
|
var p = 14;
|
|
var x = ev.pageX + p, y = ev.pageY + p;
|
|
var r = tooltip.getBoundingClientRect();
|
|
if (x + r.width > innerWidth - p) x = ev.pageX - r.width - p;
|
|
if (y + r.height > innerHeight - p) y = ev.pageY - r.height - p;
|
|
tooltip.style.left = x + "px"; tooltip.style.top = y + "px";
|
|
}
|
|
function hideTooltip() { tooltip.classList.remove("visible"); }
|
|
var W = innerWidth, H = innerHeight;
|
|
var svg = d3.select("svg").attr("viewBox", [0, 0, W, H]);
|
|
var gRoot = svg.append("g");
|
|
var currentTransform = d3.zoomIdentity;
|
|
var zoomBehavior = d3.zoom()
|
|
.scaleExtent([0.05, 8])
|
|
.on("zoom", function(ev) { currentTransform = ev.transform; gRoot.attr("transform", ev.transform); updateLabelVisibility(); });
|
|
svg.call(zoomBehavior);
|
|
var defs = svg.append("defs");
|
|
var glow = defs.append("filter").attr("id","glow").attr("x","-50%").attr("y","-50%").attr("width","200%").attr("height","200%");
|
|
glow.append("feGaussianBlur").attr("stdDeviation","3").attr("result","blur");
|
|
glow.append("feComposite").attr("in","SourceGraphic").attr("in2","blur").attr("operator","over");
|
|
[{id:"arrow-calls",color:"#3fb950"},{id:"arrow-imports",color:"#f0883e"},{id:"arrow-inherits",color:"#d2a8ff"},{id:"arrow-implements",color:"#f9e2af"},{id:"arrow-tested_by",color:"#f38ba8"},{id:"arrow-depends_on",color:"#fab387"}].forEach(function(mk) {
|
|
defs.append("marker").attr("id", mk.id)
|
|
.attr("viewBox","0 -5 10 10").attr("refX",28).attr("refY",0)
|
|
.attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto")
|
|
.append("path").attr("d","M0,-4L10,0L0,4Z").attr("fill",mk.color);
|
|
});
|
|
var N = nodes.length;
|
|
var isLarge = N > 300;
|
|
var simulation = d3.forceSimulation(nodes)
|
|
.force("link", d3.forceLink(edges).id(function(d) { return d.qualified_name; })
|
|
.distance(function(d) { return d.kind === "CONTAINS" ? 35 : (isLarge ? 80 : 120); })
|
|
.strength(function(d) { return d.kind === "CONTAINS" ? 1.5 : 0.15; }))
|
|
.force("charge", d3.forceManyBody().strength(function(d) { return d.kind === "File" ? (isLarge ? -200 : -400) : (isLarge ? -60 : -120); }).theta(0.85).distanceMax(600))
|
|
.force("collide", d3.forceCollide().radius(function(d) { return degreeRadius(d) + 4; }))
|
|
.force("center", d3.forceCenter(W / 2, H / 2))
|
|
.force("x", d3.forceX(W / 2).strength(0.03))
|
|
.force("y", d3.forceY(H / 2).strength(0.03))
|
|
.alphaDecay(isLarge ? 0.04 : 0.025)
|
|
.velocityDecay(0.4);
|
|
var EDGE_CFG = {
|
|
CONTAINS: { dash:null, width:1, opacity:0.14, marker:"" },
|
|
CALLS: { dash:null, width:2, opacity:0.7, marker:"url(#arrow-calls)" },
|
|
IMPORTS_FROM: { dash:"8,4", width:2, opacity:0.65, marker:"url(#arrow-imports)" },
|
|
INHERITS: { dash:"2,6", width:2.5, opacity:0.7, marker:"url(#arrow-inherits)" },
|
|
IMPLEMENTS: { dash:"4,3", width:1.5, opacity:0.65, marker:"url(#arrow-implements)" },
|
|
TESTED_BY: { dash:"2,4", width:1.5, opacity:0.6, marker:"url(#arrow-tested_by)" },
|
|
DEPENDS_ON: { dash:"8,4", width:1, opacity:0.6, marker:"url(#arrow-depends_on)" },
|
|
};
|
|
function eStyle(d) { return EDGE_CFG[d.kind] || {dash:null,width:1,opacity:0.3,marker:""}; }
|
|
function eColor(d) { return EDGE_COLOR[d.kind] || "#484f58"; }
|
|
function nodeColor(d) {
|
|
if (communityColoringOn && d.community_id != null) return communityColorScale(d.community_id);
|
|
return KIND_COLOR[d.kind] || "#8b949e";
|
|
}
|
|
var linkGroup = gRoot.append("g").attr("class","links");
|
|
var nodeGroup = gRoot.append("g").attr("class","nodes");
|
|
var labelGroup = gRoot.append("g").attr("class","labels");
|
|
var linkSel, labelSel;
|
|
var showLabels = true;
|
|
function updateLinks() {
|
|
var vis = new Set(nodes.filter(function(n) { return !n._hidden; }).map(function(n) { return n.qualified_name; }));
|
|
var visEdges = edges.filter(function(e) {
|
|
if (hiddenEdgeKinds.has(e.kind)) return false;
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
return vis.has(s) && vis.has(t);
|
|
});
|
|
linkSel = linkGroup.selectAll("line").data(visEdges, function(d) { return d._source+"->"+d._target+":"+d.kind; });
|
|
linkSel.exit().remove();
|
|
var enter = linkSel.enter().append("line");
|
|
linkSel = enter.merge(linkSel);
|
|
linkSel
|
|
.attr("stroke", function(d) { return eColor(d); })
|
|
.attr("stroke-width", function(d) { return eStyle(d).width; })
|
|
.attr("stroke-dasharray", function(d) { return eStyle(d).dash; })
|
|
.attr("opacity", function(d) { return eStyle(d).opacity; })
|
|
.attr("marker-end", function(d) { return eStyle(d).marker; });
|
|
}
|
|
function updateNodes() {
|
|
var hiddenSet = new Set();
|
|
collapsedFiles.forEach(function(fqn) { allDescendants(fqn).forEach(function(c) { hiddenSet.add(c); }); });
|
|
nodes.forEach(function(n) { n._hidden = hiddenSet.has(n.qualified_name) || hiddenNodeKinds.has(n.kind); });
|
|
var vis = nodes.filter(function(n) { return !n._hidden; });
|
|
var nodeSel = nodeGroup.selectAll("g.node-g").data(vis, function(d) { return d.qualified_name; });
|
|
nodeSel.exit().remove();
|
|
var enter = nodeSel.enter().append("g").attr("class","node-g");
|
|
enter.filter(function(d) { return d.kind === "File"; }).append("circle")
|
|
.attr("class","glow-ring")
|
|
.attr("r", function(d) { return degreeRadius(d) + 5; })
|
|
.attr("fill","none")
|
|
.attr("stroke", function(d) { return nodeColor(d); })
|
|
.attr("stroke-width", 1.5).attr("opacity", 0.3).attr("filter","url(#glow)");
|
|
enter.append("path").attr("class","node-shape")
|
|
.attr("d", function(d) { return d3.symbol().type(KIND_SHAPE[d.kind] || d3.symbolCircle).size(KIND_AREA[d.kind] || 113)(); })
|
|
.attr("fill", function(d) { return nodeColor(d); })
|
|
.attr("stroke", function(d) { return d.kind === "File" ? "rgba(88,166,255,0.3)" : "rgba(255,255,255,0.08)"; })
|
|
.attr("stroke-width", function(d) { return d.kind === "File" ? 2 : 1; })
|
|
.attr("cursor", "pointer");
|
|
enter
|
|
.on("mouseover", function(ev, d) { highlightConnected(d, true); showTooltip(ev, d); })
|
|
.on("mousemove", function(ev) { moveTooltip(ev); })
|
|
.on("mouseout", function(ev, d) { highlightConnected(d, false); hideTooltip(); })
|
|
.on("click", function(ev, d) {
|
|
ev.stopPropagation();
|
|
if (d.kind === "File" && !ev.shiftKey) toggleCollapse(d.qualified_name);
|
|
showDetailPanel(d);
|
|
})
|
|
.call(d3.drag().on("start", dragS).on("drag", dragD).on("end", dragE));
|
|
enter.attr("tabindex", 0).attr("role", "button")
|
|
.attr("aria-label", function(d) { return d.kind + ": " + d.label; })
|
|
.on("keydown", function(ev, d) {
|
|
if (ev.key === "Enter" || ev.key === " ") {
|
|
ev.preventDefault();
|
|
if (d.kind === "File" && !ev.shiftKey) toggleCollapse(d.qualified_name);
|
|
showDetailPanel(d);
|
|
} else if (ev.key === "Escape") {
|
|
ev.preventDefault();
|
|
detailPanel.classList.remove("visible");
|
|
hideTooltip();
|
|
} else if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].indexOf(ev.key) !== -1) {
|
|
ev.preventDefault();
|
|
var vis = nodes.filter(function(n) { return !n._hidden; });
|
|
var best = null, bestDist = Infinity;
|
|
vis.forEach(function(n) {
|
|
if (n.qualified_name === d.qualified_name) return;
|
|
var dx = n.x - d.x, dy = n.y - d.y;
|
|
var dist = Math.sqrt(dx*dx + dy*dy);
|
|
var ok = false;
|
|
if (ev.key === "ArrowRight" && dx > 0 && Math.abs(dy) < Math.abs(dx)) ok = true;
|
|
if (ev.key === "ArrowLeft" && dx < 0 && Math.abs(dy) < Math.abs(dx)) ok = true;
|
|
if (ev.key === "ArrowDown" && dy > 0 && Math.abs(dx) < Math.abs(dy)) ok = true;
|
|
if (ev.key === "ArrowUp" && dy < 0 && Math.abs(dx) < Math.abs(dy)) ok = true;
|
|
if (ok && dist < bestDist) { best = n; bestDist = dist; }
|
|
});
|
|
if (best) {
|
|
var target = nodeGroup.selectAll("g.node-g").filter(function(n) { return n.qualified_name === best.qualified_name; }).node();
|
|
if (target) target.focus();
|
|
}
|
|
}
|
|
})
|
|
.on("focus", function(ev, d) { highlightConnected(d, true); showTooltip(ev, d); })
|
|
.on("blur", function(ev, d) { highlightConnected(d, false); hideTooltip(); });
|
|
nodeSel = enter.merge(nodeSel);
|
|
labelSel = labelGroup.selectAll("text.node-label").data(vis, function(d) { return d.qualified_name; });
|
|
labelSel.exit().remove();
|
|
var lEnter = labelSel.enter().append("text").attr("class","node-label")
|
|
.attr("text-anchor","start").attr("dy","0.35em")
|
|
.text(function(d) { return d.label; })
|
|
.attr("fill", function(d) { return d.kind === "File" ? "#e6edf3" : d.kind === "Class" ? "#f0883e" : "#9eaab6"; })
|
|
.attr("font-size", function(d) { return d.kind === "File" ? "12px" : d.kind === "Class" ? "11px" : "10px"; })
|
|
.attr("font-weight", function(d) { return d.kind === "File" ? 700 : d.kind === "Class" ? 600 : 400; });
|
|
labelSel = lEnter.merge(labelSel);
|
|
updateLinks();
|
|
updateLabelVisibility();
|
|
}
|
|
function updateLabelVisibility() {
|
|
if (!labelSel) return;
|
|
var s = currentTransform.k;
|
|
labelSel.attr("display", function(d) {
|
|
if (!showLabels) return "none";
|
|
if (d.kind === "File") return null;
|
|
if (d.kind === "Class") return s > 0.5 ? null : "none";
|
|
return s > 1.0 ? null : "none";
|
|
});
|
|
}
|
|
function highlightConnected(d, on) {
|
|
if (on) {
|
|
var connected = new Set([d.qualified_name]);
|
|
edges.forEach(function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
if (s === d.qualified_name) connected.add(t);
|
|
if (t === d.qualified_name) connected.add(s);
|
|
});
|
|
nodeGroup.selectAll("g.node-g").select(".node-shape")
|
|
.transition().duration(150).attr("opacity", function(n) { return connected.has(n.qualified_name) ? 1 : 0.15; });
|
|
linkSel.transition().duration(150)
|
|
.attr("opacity", function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
return (s === d.qualified_name || t === d.qualified_name) ? 0.9 : 0.03;
|
|
})
|
|
.attr("stroke-width", function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
return (s === d.qualified_name || t === d.qualified_name) ? 2.5 : eStyle(e).width;
|
|
});
|
|
labelSel.transition().duration(150).attr("opacity", function(n) { return connected.has(n.qualified_name) ? 1 : 0.1; });
|
|
} else {
|
|
nodeGroup.selectAll("g.node-g").select(".node-shape").transition().duration(300).attr("opacity", 1);
|
|
linkSel.transition().duration(300)
|
|
.attr("opacity", function(e) { return eStyle(e).opacity; })
|
|
.attr("stroke-width", function(e) { return eStyle(e).width; });
|
|
labelSel.transition().duration(300).attr("opacity", 1);
|
|
updateLabelVisibility();
|
|
}
|
|
}
|
|
function toggleCollapse(qn) {
|
|
if (collapsedFiles.has(qn)) collapsedFiles.delete(qn); else collapsedFiles.add(qn);
|
|
nodeGroup.selectAll("g.node-g").select(".glow-ring")
|
|
.attr("stroke-dasharray", function(d) { return collapsedFiles.has(d.qualified_name) ? "4,3" : null; })
|
|
.attr("opacity", function(d) { return collapsedFiles.has(d.qualified_name) ? 0.6 : 0.3; });
|
|
updateNodes();
|
|
simulation.alpha(0.3).restart();
|
|
}
|
|
function dragS(ev, d) { if (!ev.active) simulation.alphaTarget(0.1).restart(); d.fx = d.x; d.fy = d.y; }
|
|
function dragD(ev, d) { d.fx = ev.x; d.fy = ev.y; }
|
|
function dragE(ev, d) { if (!ev.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }
|
|
simulation.on("tick", function() {
|
|
if (linkSel) linkSel
|
|
.attr("x1", function(d) { return d.source.x; }).attr("y1", function(d) { return d.source.y; })
|
|
.attr("x2", function(d) { return d.target.x; }).attr("y2", function(d) { return d.target.y; });
|
|
nodeGroup.selectAll("g.node-g").attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; });
|
|
if (labelSel) labelSel
|
|
.attr("x", function(d) { return d.x + degreeRadius(d) + 5; })
|
|
.attr("y", function(d) { return d.y; });
|
|
});
|
|
// Only auto-collapse File nodes on very large graphs, otherwise all edges
|
|
// become invisible because they connect to Functions/Classes that are now
|
|
// hidden beneath collapsed Files. See: #132
|
|
if (N > 2000) {
|
|
nodes.forEach(function(n) { if (n.kind === "File") collapsedFiles.add(n.qualified_name); });
|
|
}
|
|
updateNodes();
|
|
function fitGraph() {
|
|
var b = gRoot.node().getBBox();
|
|
if (b.width === 0 || b.height === 0) return;
|
|
var pad = 0.1;
|
|
var fw = b.width * (1 + 2*pad), fh = b.height * (1 + 2*pad);
|
|
var s = Math.min(W / fw, H / fh, 2.5);
|
|
var tx = W/2 - (b.x + b.width/2)*s, ty = H/2 - (b.y + b.height/2)*s;
|
|
svg.transition().duration(600).call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(s));
|
|
}
|
|
var loadingOverlay = document.getElementById("loading-overlay");
|
|
var emptyState = document.getElementById("empty-state");
|
|
if (nodes.length === 0) {
|
|
loadingOverlay.classList.add("hidden");
|
|
emptyState.classList.add("visible");
|
|
}
|
|
simulation.on("end", function() {
|
|
loadingOverlay.classList.add("hidden");
|
|
fitGraph();
|
|
});
|
|
function zoomToNode(qn) {
|
|
var nd = nodeById.get(qn);
|
|
if (!nd || nd.x == null) return;
|
|
var s = 2.0;
|
|
var tx = W/2 - nd.x*s, ty = H/2 - nd.y*s;
|
|
svg.transition().duration(600).call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(s));
|
|
}
|
|
document.getElementById("btn-fit").addEventListener("click", fitGraph);
|
|
document.getElementById("btn-labels").addEventListener("click", function() {
|
|
showLabels = !showLabels;
|
|
this.classList.toggle("active");
|
|
this.setAttribute("aria-pressed", showLabels);
|
|
updateLabelVisibility();
|
|
});
|
|
document.querySelectorAll(".legend-item[data-edge-kind]").forEach(function(el) {
|
|
el.addEventListener("click", function() {
|
|
var kind = this.dataset.edgeKind;
|
|
if (hiddenEdgeKinds.has(kind)) { hiddenEdgeKinds.delete(kind); this.classList.remove("dimmed"); }
|
|
else { hiddenEdgeKinds.add(kind); this.classList.add("dimmed"); }
|
|
this.setAttribute("aria-pressed", !hiddenEdgeKinds.has(kind));
|
|
updateLinks();
|
|
});
|
|
});
|
|
document.querySelectorAll("#filter-panel input[data-kind]").forEach(function(el) {
|
|
el.addEventListener("change", function() {
|
|
var kind = this.dataset.kind;
|
|
if (this.checked) hiddenNodeKinds.delete(kind); else hiddenNodeKinds.add(kind);
|
|
updateNodes();
|
|
simulation.alpha(0.15).restart();
|
|
});
|
|
});
|
|
document.getElementById("btn-community").addEventListener("click", function() {
|
|
communityColoringOn = !communityColoringOn;
|
|
this.classList.toggle("active");
|
|
this.setAttribute("aria-pressed", communityColoringOn);
|
|
nodeGroup.selectAll("g.node-g").select(".node-shape").transition().duration(300)
|
|
.attr("fill", function(d) { return nodeColor(d); });
|
|
nodeGroup.selectAll("g.node-g").select(".glow-ring")
|
|
.transition().duration(300)
|
|
.attr("stroke", function(d) { return nodeColor(d); });
|
|
// Toggle community legend visibility with coloring
|
|
var cl = document.getElementById("community-legend");
|
|
if (communityColoringOn) cl.classList.add("visible");
|
|
else cl.classList.remove("visible");
|
|
});
|
|
// Build community legend with toggle checkboxes
|
|
var hiddenCommunities = new Set();
|
|
(function buildCommunityLegend() {
|
|
var cl = document.getElementById("community-legend");
|
|
if (!communities.length) return;
|
|
var h = '<h3>Communities</h3>';
|
|
communities.slice(0, 30).forEach(function(c) {
|
|
var col = communityColorScale(c.id);
|
|
var nm = escH(c.name || ("Community " + c.id));
|
|
h += '<label class="cl-item">'
|
|
+ '<input type="checkbox" data-cid="'
|
|
+ c.id + '" checked>'
|
|
+ '<span class="cl-swatch" style="background:'
|
|
+ col + '"></span> ' + nm + '</label>';
|
|
});
|
|
cl.textContent = "";
|
|
cl.insertAdjacentHTML("beforeend", h);
|
|
cl.querySelectorAll("input[data-cid]").forEach(
|
|
function(el) {
|
|
el.addEventListener("change", function() {
|
|
var cid = parseInt(this.dataset.cid);
|
|
if (this.checked) hiddenCommunities.delete(cid);
|
|
else hiddenCommunities.add(cid);
|
|
applyCommunityFilter();
|
|
});
|
|
}
|
|
);
|
|
})();
|
|
function applyCommunityFilter() {
|
|
if (!communityColoringOn || !hiddenCommunities.size) {
|
|
nodeGroup.selectAll("g.node-g")
|
|
.attr("display", function(d) {
|
|
return d._hidden ? "none" : null;
|
|
});
|
|
updateLinks();
|
|
return;
|
|
}
|
|
nodeGroup.selectAll("g.node-g")
|
|
.attr("display", function(d) {
|
|
if (d._hidden) return "none";
|
|
var cid = d.community_id;
|
|
if (cid == null) {
|
|
cid = nodeToCommunity.get(d.qualified_name);
|
|
}
|
|
if (cid != null && hiddenCommunities.has(cid)) {
|
|
return "none";
|
|
}
|
|
return null;
|
|
});
|
|
if (labelSel) labelSel
|
|
.attr("display", function(d) {
|
|
var cid = d.community_id;
|
|
if (cid == null) {
|
|
cid = nodeToCommunity.get(d.qualified_name);
|
|
}
|
|
if (cid != null && hiddenCommunities.has(cid)) {
|
|
return "none";
|
|
}
|
|
return null;
|
|
});
|
|
updateLinks();
|
|
}
|
|
var activeFlowQns = null;
|
|
flowSelect.addEventListener("change", function() {
|
|
var idx = this.value;
|
|
if (idx === "") { activeFlowQns = null; clearFlowHighlight(); return; }
|
|
var flow = flows[parseInt(idx)];
|
|
if (!flow) return;
|
|
var pathQns = new Set();
|
|
(flow.path || []).forEach(function(nid) { var qn = nodeIdToQn.get(nid); if (qn) pathQns.add(qn); });
|
|
activeFlowQns = pathQns;
|
|
applyFlowHighlight();
|
|
});
|
|
function applyFlowHighlight() {
|
|
if (!activeFlowQns || activeFlowQns.size === 0) { clearFlowHighlight(); return; }
|
|
nodeGroup.selectAll("g.node-g").select(".node-shape").transition().duration(200)
|
|
.attr("opacity", function(d) { return activeFlowQns.has(d.qualified_name) ? 1 : 0.2; });
|
|
if (labelSel) labelSel.transition().duration(200)
|
|
.attr("opacity", function(d) { return activeFlowQns.has(d.qualified_name) ? 1 : 0.1; });
|
|
if (linkSel) linkSel.transition().duration(200)
|
|
.attr("opacity", function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
return (activeFlowQns.has(s) && activeFlowQns.has(t)) ? 0.9 : 0.03;
|
|
});
|
|
}
|
|
function clearFlowHighlight() {
|
|
nodeGroup.selectAll("g.node-g").select(".node-shape").transition().duration(300).attr("opacity", 1);
|
|
if (linkSel) linkSel.transition().duration(300).attr("opacity", function(e) { return eStyle(e).opacity; });
|
|
if (labelSel) labelSel.transition().duration(300).attr("opacity", 1);
|
|
updateLabelVisibility();
|
|
}
|
|
var detailPanel = document.getElementById("detail-panel");
|
|
var dpContent = document.getElementById("dp-content");
|
|
var detailTrigger = null;
|
|
document.querySelector("#detail-panel .dp-close").addEventListener("click", function() {
|
|
detailPanel.classList.remove("visible");
|
|
document.getElementById("legend").style.display = "";
|
|
if (detailTrigger) detailTrigger.focus();
|
|
});
|
|
svg.on("click", function() {
|
|
detailPanel.classList.remove("visible");
|
|
document.getElementById("legend").style.display = "";
|
|
if (detailTrigger) detailTrigger.focus();
|
|
});
|
|
function showDetailPanel(d) {
|
|
detailTrigger = document.activeElement;
|
|
var callers = [], callees = [];
|
|
edges.forEach(function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
if (t === d.qualified_name && e.kind === "CALLS") { var sN = nodeById.get(s); if (sN) callers.push(sN); }
|
|
if (s === d.qualified_name && e.kind === "CALLS") { var tN = nodeById.get(t); if (tN) callees.push(tN); }
|
|
});
|
|
var relFile = d.file_path ? d.file_path.split("/").slice(-3).join("/") : "";
|
|
var bg = communityColoringOn && d.community_id != null ? communityColorScale(d.community_id) : (KIND_COLOR[d.kind] || "#555");
|
|
var h = '<h2>' + escH(d.label) + '</h2>';
|
|
h += '<span class="tt-kind" style="background:' + bg + ';color:#0d1117">' + escH(d.kind) + '</span>';
|
|
if (relFile) h += '<div class="dp-meta" style="margin-top:8px">' + escH(relFile) + (d.line_start != null ? ':' + d.line_start : '') + '</div>';
|
|
if (d.params) h += '<div class="dp-meta"><span class="tt-label">Params:</span> ' + escH(d.params) + '</div>';
|
|
if (d.return_type) h += '<div class="dp-meta"><span class="tt-label">Returns:</span> ' + escH(d.return_type) + '</div>';
|
|
if (d.community_id != null) {
|
|
var comm = communities.find(function(c) { return c.id === d.community_id; });
|
|
if (comm) h += '<div class="dp-meta"><span class="tt-label">Community:</span> ' + escH(comm.name) + '</div>';
|
|
}
|
|
if (callers.length) {
|
|
h += '<div class="dp-section"><h4>Callers (' + callers.length + ')</h4><ul class="dp-list">';
|
|
callers.slice(0, 20).forEach(function(c) { h += '<li data-qn="' + escH(c.qualified_name) + '">' + escH(c.label) + '</li>'; });
|
|
h += '</ul></div>';
|
|
}
|
|
if (callees.length) {
|
|
h += '<div class="dp-section"><h4>Callees (' + callees.length + ')</h4><ul class="dp-list">';
|
|
callees.slice(0, 20).forEach(function(c) { h += '<li data-qn="' + escH(c.qualified_name) + '">' + escH(c.label) + '</li>'; });
|
|
h += '</ul></div>';
|
|
}
|
|
dpContent.textContent = "";
|
|
dpContent.insertAdjacentHTML("beforeend", h);
|
|
detailPanel.classList.add("visible");
|
|
document.getElementById("legend").style.display = "none";
|
|
detailPanel.querySelector(".dp-close").focus();
|
|
dpContent.querySelectorAll("li[data-qn]").forEach(function(li) {
|
|
li.addEventListener("click", function() {
|
|
var qn = li.dataset.qn;
|
|
zoomToNode(qn);
|
|
var nd = nodeById.get(qn);
|
|
if (nd) showDetailPanel(nd);
|
|
});
|
|
});
|
|
}
|
|
var searchInput = document.getElementById("search");
|
|
var searchResults = document.getElementById("search-results");
|
|
var searchTerm = "";
|
|
searchInput.addEventListener("input", function() {
|
|
searchTerm = this.value.trim().toLowerCase();
|
|
applySearchFilter();
|
|
showSearchResults();
|
|
});
|
|
searchInput.addEventListener("focus", showSearchResults);
|
|
searchInput.addEventListener("keydown", function(ev) {
|
|
var items = searchResults.querySelectorAll(".sr-item");
|
|
if (!items.length) return;
|
|
var active = searchResults.querySelector(".sr-item.sr-active");
|
|
var idx = active ? Array.from(items).indexOf(active) : -1;
|
|
if (ev.key === "ArrowDown") {
|
|
ev.preventDefault();
|
|
if (active) active.classList.remove("sr-active");
|
|
idx = (idx + 1) % items.length;
|
|
items[idx].classList.add("sr-active");
|
|
items[idx].scrollIntoView({ block: "nearest" });
|
|
searchInput.setAttribute("aria-activedescendant", items[idx].id);
|
|
} else if (ev.key === "ArrowUp") {
|
|
ev.preventDefault();
|
|
if (active) active.classList.remove("sr-active");
|
|
idx = idx <= 0 ? items.length - 1 : idx - 1;
|
|
items[idx].classList.add("sr-active");
|
|
items[idx].scrollIntoView({ block: "nearest" });
|
|
searchInput.setAttribute("aria-activedescendant", items[idx].id);
|
|
} else if (ev.key === "Enter" && active) {
|
|
ev.preventDefault();
|
|
active.click();
|
|
}
|
|
});
|
|
document.addEventListener("click", function(ev) {
|
|
if (!searchResults.contains(ev.target) && ev.target !== searchInput) searchResults.style.display = "none";
|
|
});
|
|
function showSearchResults() {
|
|
if (!searchTerm) { searchResults.style.display = "none"; searchInput.setAttribute("aria-expanded", "false"); return; }
|
|
var matched = [];
|
|
nodes.forEach(function(n) {
|
|
if (n._hidden) return;
|
|
var hay = (n.label + " " + n.qualified_name).toLowerCase();
|
|
if (hay.indexOf(searchTerm) !== -1) matched.push(n);
|
|
});
|
|
if (!matched.length) { searchResults.style.display = "none"; searchInput.setAttribute("aria-expanded", "false"); return; }
|
|
searchResults.textContent = "";
|
|
matched.slice(0, 15).forEach(function(n, i) {
|
|
var bg = KIND_COLOR[n.kind] || "#555";
|
|
var div = document.createElement("div");
|
|
div.className = "sr-item";
|
|
div.setAttribute("role", "option");
|
|
div.setAttribute("tabindex", "-1");
|
|
div.id = "sr-" + i;
|
|
var kindSpan = document.createElement("span");
|
|
kindSpan.className = "sr-kind";
|
|
kindSpan.style.background = bg;
|
|
kindSpan.style.color = "#0d1117";
|
|
kindSpan.textContent = n.kind;
|
|
div.appendChild(kindSpan);
|
|
div.appendChild(document.createTextNode(" " + n.label));
|
|
div.addEventListener("click", function() {
|
|
zoomToNode(n.qualified_name);
|
|
showDetailPanel(n);
|
|
searchResults.style.display = "none";
|
|
});
|
|
searchResults.appendChild(div);
|
|
});
|
|
searchResults.style.display = "block";
|
|
searchInput.setAttribute("aria-expanded", "true");
|
|
}
|
|
function applySearchFilter() {
|
|
if (!searchTerm) {
|
|
nodeGroup.selectAll("g.node-g").select(".node-shape").attr("opacity", 1);
|
|
if (labelSel) labelSel.attr("opacity", 1);
|
|
if (linkSel) linkSel.attr("opacity", function(e) { return eStyle(e).opacity; });
|
|
updateLabelVisibility();
|
|
return;
|
|
}
|
|
var matched = new Set();
|
|
nodes.forEach(function(n) {
|
|
if (n._hidden) return;
|
|
var hay = (n.label + " " + n.qualified_name).toLowerCase();
|
|
if (hay.indexOf(searchTerm) !== -1) matched.add(n.qualified_name);
|
|
});
|
|
nodeGroup.selectAll("g.node-g").select(".node-shape")
|
|
.attr("opacity", function(d) { return matched.has(d.qualified_name) ? 1 : 0.08; });
|
|
if (labelSel) labelSel
|
|
.attr("opacity", function(d) { return matched.has(d.qualified_name) ? 1 : 0.05; })
|
|
.attr("display", function(d) { return matched.has(d.qualified_name) ? null : "none"; });
|
|
if (linkSel) linkSel.attr("opacity", function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
return (matched.has(s) || matched.has(t)) ? eStyle(e).opacity : 0.02;
|
|
});
|
|
}
|
|
var helpOverlay = document.getElementById("help-overlay");
|
|
function toggleHelp() {
|
|
helpOverlay.classList.toggle("hidden");
|
|
if (!helpOverlay.classList.contains("hidden")) {
|
|
helpOverlay.querySelector(".help-close").focus();
|
|
}
|
|
}
|
|
document.getElementById("btn-help").addEventListener("click", toggleHelp);
|
|
helpOverlay.querySelector(".help-close").addEventListener("click", toggleHelp);
|
|
helpOverlay.addEventListener("click", function(ev) {
|
|
if (ev.target === helpOverlay) toggleHelp();
|
|
});
|
|
document.addEventListener("keydown", function(ev) {
|
|
var tag = (ev.target.tagName || "").toLowerCase();
|
|
var inInput = tag === "input" || tag === "textarea" || tag === "select" || ev.target.isContentEditable;
|
|
if (ev.key === "Escape") {
|
|
if (detailPanel.classList.contains("visible")) {
|
|
detailPanel.classList.remove("visible");
|
|
if (detailTrigger) detailTrigger.focus();
|
|
} else if (searchResults.style.display === "block") {
|
|
searchResults.style.display = "none";
|
|
searchInput.focus();
|
|
} else if (!helpOverlay.classList.contains("hidden")) {
|
|
toggleHelp();
|
|
}
|
|
return;
|
|
}
|
|
if (inInput) return;
|
|
if (ev.key === "/") {
|
|
ev.preventDefault();
|
|
searchInput.focus();
|
|
} else if (ev.key === "?") {
|
|
ev.preventDefault();
|
|
toggleHelp();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Aggregated-mode HTML template (community / file)
|
|
# ---------------------------------------------------------------------------
|
|
# Supports community super-nodes with drill-down (double-click) and a Back
|
|
# button to return to the overview.
|
|
# NOTE: innerHTML / insertAdjacentHTML usage below mirrors the original
|
|
# _HTML_TEMPLATE and is safe because all interpolated values pass through
|
|
# escH() which escapes &, <, >, ", ', and backtick characters.
|
|
|
|
_AGGREGATED_HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Code Review Graph (Aggregated)</title>
|
|
<script src="https://d3js.org/d3.v7.min.js" integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i" crossorigin="anonymous"></script>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
|
body {
|
|
background: #0d1117; color: #c9d1d9;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
font-size: 13px;
|
|
}
|
|
svg { display: block; width: 100%; height: 100%; }
|
|
#legend {
|
|
position: absolute; top: 16px; left: 16px;
|
|
background: rgba(22,27,34,0.95); border: 1px solid #30363d;
|
|
border-radius: 10px; padding: 16px 20px;
|
|
font-size: 12px; line-height: 1.8;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
backdrop-filter: blur(12px); z-index: 10;
|
|
}
|
|
#legend h3 {
|
|
font-size: 11px; font-weight: 700; margin-bottom: 6px;
|
|
color: #8b949e; text-transform: uppercase; letter-spacing: 1px;
|
|
}
|
|
.legend-section { margin-bottom: 10px; }
|
|
.legend-section:last-child { margin-bottom: 0; }
|
|
.legend-item { display: flex; align-items: center; gap: 10px; padding: 2px 0; cursor: default; }
|
|
.legend-circle { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
.legend-line { width: 24px; height: 0; flex-shrink: 0; border-top-width: 2px; }
|
|
.l-cross { border-top: 2px solid #58a6ff; }
|
|
.l-dep { border-top: 2px dashed #f0883e; }
|
|
.l-calls { border-top: 2px solid #3fb950; }
|
|
.l-imports { border-top: 2px dashed #f0883e; }
|
|
.l-inherits { border-top: 2.5px dotted #d2a8ff; }
|
|
.l-contains { border-top: 1.5px solid rgba(139,148,158,0.3); }
|
|
#stats-bar {
|
|
position: absolute; bottom: 0; left: 0; right: 0;
|
|
background: rgba(13,17,23,0.95); border-top: 1px solid #21262d;
|
|
padding: 8px 24px; display: flex; gap: 32px; justify-content: center;
|
|
font-size: 12px; color: #8b949e; backdrop-filter: blur(12px);
|
|
}
|
|
.stat-item { display: flex; gap: 6px; align-items: center; }
|
|
.stat-value { color: #e6edf3; font-weight: 600; }
|
|
#tooltip {
|
|
position: absolute; pointer-events: none;
|
|
background: rgba(22,27,34,0.97); color: #c9d1d9;
|
|
border: 1px solid #30363d; border-radius: 8px;
|
|
padding: 12px 16px; font-size: 12px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
|
max-width: 360px; line-height: 1.7;
|
|
opacity: 0; transition: opacity 0.15s ease;
|
|
z-index: 1000; backdrop-filter: blur(12px);
|
|
}
|
|
#tooltip.visible { opacity: 1; }
|
|
.tt-name { font-weight: 700; font-size: 14px; color: #e6edf3; }
|
|
.tt-kind {
|
|
display: inline-block; font-size: 9px; font-weight: 700;
|
|
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
|
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
}
|
|
.tt-row { margin-top: 4px; }
|
|
.tt-label { color: #8b949e; }
|
|
.tt-file { color: #58a6ff; font-size: 11px; }
|
|
#controls {
|
|
position: absolute; top: 16px; right: 16px;
|
|
display: flex; gap: 8px; z-index: 10; flex-wrap: wrap;
|
|
max-width: 650px; justify-content: flex-end;
|
|
}
|
|
#controls button, #controls select {
|
|
background: rgba(22,27,34,0.95); color: #c9d1d9;
|
|
border: 1px solid #30363d; border-radius: 8px;
|
|
padding: 8px 14px; font-size: 12px; cursor: pointer;
|
|
backdrop-filter: blur(12px); transition: all 0.15s;
|
|
}
|
|
#controls button:hover, #controls select:hover { background: #30363d; border-color: #8b949e; }
|
|
#controls button.active { background: #1f6feb; border-color: #58a6ff; color: #fff; }
|
|
#controls select { outline: none; max-width: 200px; }
|
|
#controls select option { background: #161b22; color: #c9d1d9; }
|
|
#search {
|
|
background: rgba(22,27,34,0.95); color: #c9d1d9;
|
|
border: 1px solid #30363d; border-radius: 8px;
|
|
padding: 8px 14px; font-size: 12px; width: 220px;
|
|
outline: none; backdrop-filter: blur(12px);
|
|
}
|
|
#search:focus { border-color: #58a6ff; }
|
|
#search::placeholder { color: #484f58; }
|
|
#search-results {
|
|
position: absolute; top: 52px; right: 16px;
|
|
background: rgba(22,27,34,0.97); border: 1px solid #30363d;
|
|
border-radius: 8px; max-height: 240px; overflow-y: auto;
|
|
z-index: 15; display: none; min-width: 220px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
|
}
|
|
.sr-item {
|
|
padding: 8px 14px; cursor: pointer; font-size: 12px;
|
|
border-bottom: 1px solid #21262d; display: flex; gap: 8px; align-items: center;
|
|
}
|
|
.sr-item:hover { background: #30363d; }
|
|
.sr-item:last-child { border-bottom: none; }
|
|
.sr-kind { font-size: 9px; padding: 2px 6px; border-radius: 8px; text-transform: uppercase; font-weight: 700; }
|
|
#detail-panel {
|
|
position: absolute; top: 16px; right: 16px;
|
|
width: 320px; max-height: calc(100vh - 80px);
|
|
background: rgba(22,27,34,0.97); border: 1px solid #30363d;
|
|
border-radius: 10px; padding: 20px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
|
backdrop-filter: blur(12px); z-index: 20;
|
|
overflow-y: auto; display: none; font-size: 12px;
|
|
}
|
|
#detail-panel.visible { display: block; }
|
|
#detail-panel h2 { font-size: 16px; color: #e6edf3; margin-bottom: 4px; word-break: break-all; }
|
|
#detail-panel .dp-close {
|
|
position: absolute; top: 12px; right: 14px;
|
|
cursor: pointer; color: #8b949e; font-size: 18px; line-height: 1;
|
|
border: none; background: none;
|
|
}
|
|
#detail-panel .dp-close:hover { color: #e6edf3; }
|
|
.dp-section { margin-top: 14px; }
|
|
.dp-section h4 { color: #8b949e; font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px; }
|
|
.dp-list { list-style: none; }
|
|
.dp-list li { padding: 3px 0; color: #c9d1d9; cursor: pointer; }
|
|
.dp-list li:hover { color: #58a6ff; text-decoration: underline; }
|
|
.dp-meta { color: #8b949e; }
|
|
.dp-meta span { color: #e6edf3; font-weight: 600; }
|
|
#btn-back {
|
|
display: none;
|
|
position: absolute; bottom: 50px; right: 16px;
|
|
background: #1f6feb; color: #fff;
|
|
border: 1px solid #58a6ff; border-radius: 8px;
|
|
padding: 10px 18px; font-size: 13px; cursor: pointer;
|
|
z-index: 10; font-weight: 600;
|
|
box-shadow: 0 4px 16px rgba(31,111,235,0.4);
|
|
transition: all 0.15s;
|
|
}
|
|
#btn-back:hover { background: #388bfd; }
|
|
#filter-panel {
|
|
position: absolute; bottom: 50px; left: 16px;
|
|
background: rgba(22,27,34,0.95); border: 1px solid #30363d;
|
|
border-radius: 10px; padding: 14px 18px;
|
|
font-size: 12px; backdrop-filter: blur(12px); z-index: 10;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
}
|
|
#filter-panel h3 {
|
|
font-size: 11px; font-weight: 700; margin-bottom: 8px;
|
|
color: #8b949e; text-transform: uppercase; letter-spacing: 1px;
|
|
}
|
|
.filter-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; }
|
|
.filter-item input { accent-color: #58a6ff; cursor: pointer; }
|
|
marker { overflow: visible; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="legend" role="complementary" aria-label="Graph legend">
|
|
<h3>Nodes</h3>
|
|
<div class="legend-section" id="legend-nodes"></div>
|
|
<h3>Edges</h3>
|
|
<div class="legend-section" id="legend-edges"></div>
|
|
</div>
|
|
<div id="filter-panel">
|
|
<h3>View Mode</h3>
|
|
<div id="filter-info" style="color:#8b949e;font-size:11px;"></div>
|
|
</div>
|
|
<div id="controls">
|
|
<input id="search" type="text" placeholder="Search nodes…" autocomplete="off" spellcheck="false" aria-label="Search graph nodes by name">
|
|
<button id="btn-fit" title="Fit to screen" aria-label="Fit graph to screen">Fit</button>
|
|
<button id="btn-labels" title="Toggle labels" class="active" aria-label="Toggle node labels" aria-pressed="true">Labels</button>
|
|
</div>
|
|
<div id="search-results"></div>
|
|
<div id="detail-panel"><button class="dp-close" aria-label="Close detail panel">×</button><div id="dp-content"></div></div>
|
|
<div id="stats-bar" role="status" aria-label="Graph statistics"></div>
|
|
<div id="tooltip"></div>
|
|
<button id="btn-back" aria-label="Back to overview">← Back to Overview</button>
|
|
<svg role="img" aria-label="Interactive code knowledge graph visualization (aggregated view)."></svg>
|
|
<script>
|
|
"use strict";
|
|
var graphData = __GRAPH_DATA__;
|
|
var dataMode = graphData.mode || "full";
|
|
var communityDetails = graphData.community_details || {};
|
|
|
|
var communityColorScale = d3.scaleOrdinal(d3.schemeTableau10);
|
|
function escH(s) { return !s ? "" : s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/`/g,"`"); }
|
|
|
|
var KIND_COLOR = {
|
|
Community: "#1f6feb", File: "#58a6ff", Class: "#f0883e",
|
|
Function: "#3fb950", Test: "#d2a8ff", Type: "#8b949e"
|
|
};
|
|
var EDGE_COLOR = {
|
|
CROSS_COMMUNITY: "#58a6ff", DEPENDS_ON: "#f0883e",
|
|
CALLS: "#3fb950", IMPORTS_FROM: "#f0883e",
|
|
INHERITS: "#d2a8ff", CONTAINS: "rgba(139,148,158,0.15)"
|
|
};
|
|
var EDGE_CFG = {
|
|
CROSS_COMMUNITY: { dash: null, width: 2, opacity: 0.6, marker: "" },
|
|
DEPENDS_ON: { dash: "6,3", width: 1.5, opacity: 0.5, marker: "" },
|
|
CONTAINS: { dash: null, width: 1, opacity: 0.08, marker: "" },
|
|
CALLS: { dash: null, width: 1.5, opacity: 0.7, marker: "url(#arrow-calls)" },
|
|
IMPORTS_FROM: { dash: "6,3", width: 1.5, opacity: 0.65, marker: "url(#arrow-imports)" },
|
|
INHERITS: { dash: "3,4", width: 2, opacity: 0.7, marker: "url(#arrow-inherits)" },
|
|
};
|
|
function eStyle(d) { return EDGE_CFG[d.kind] || { dash: null, width: 1, opacity: 0.3, marker: "" }; }
|
|
function eColor(d) { return EDGE_COLOR[d.kind] || "#484f58"; }
|
|
|
|
/* --- Legend setup --- */
|
|
var legendNodes = document.getElementById("legend-nodes");
|
|
var legendEdges = document.getElementById("legend-edges");
|
|
function buildLegend(nodeKinds, edgeKinds) {
|
|
legendNodes.textContent = "";
|
|
legendEdges.textContent = "";
|
|
nodeKinds.forEach(function(k) {
|
|
var div = document.createElement("div");
|
|
div.className = "legend-item";
|
|
var circle = document.createElement("span");
|
|
circle.className = "legend-circle";
|
|
circle.style.background = KIND_COLOR[k] || "#8b949e";
|
|
div.appendChild(circle);
|
|
div.appendChild(document.createTextNode(" " + k));
|
|
legendNodes.appendChild(div);
|
|
});
|
|
edgeKinds.forEach(function(k) {
|
|
var div = document.createElement("div");
|
|
div.className = "legend-item";
|
|
var cls = k === "CROSS_COMMUNITY" ? "l-cross" : k === "DEPENDS_ON" ? "l-dep" : k === "CALLS" ? "l-calls" : k === "IMPORTS_FROM" ? "l-imports" : k === "INHERITS" ? "l-inherits" : "l-contains";
|
|
var line = document.createElement("span");
|
|
line.className = "legend-line " + cls;
|
|
div.appendChild(line);
|
|
var label = k.replace(/_/g, " ").replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
|
div.appendChild(document.createTextNode(" " + label));
|
|
legendEdges.appendChild(div);
|
|
});
|
|
}
|
|
|
|
/* --- Stats bar --- */
|
|
var statsBar = document.getElementById("stats-bar");
|
|
var stats = graphData.stats;
|
|
function addStat(label, value) {
|
|
var div = document.createElement("div");
|
|
div.className = "stat-item";
|
|
var lbl = document.createElement("span");
|
|
lbl.className = "tt-label";
|
|
lbl.textContent = label;
|
|
var val = document.createElement("span");
|
|
val.className = "stat-value";
|
|
val.textContent = String(value);
|
|
div.appendChild(lbl);
|
|
div.appendChild(document.createTextNode(" "));
|
|
div.appendChild(val);
|
|
statsBar.appendChild(div);
|
|
}
|
|
var langList = (stats.languages || []).join(", ") || "n/a";
|
|
statsBar.textContent = "";
|
|
addStat("Nodes", stats.total_nodes);
|
|
addStat("Edges", stats.total_edges);
|
|
addStat("Files", stats.files_count);
|
|
addStat("Languages", langList);
|
|
addStat("Mode", dataMode);
|
|
|
|
/* --- Filter info --- */
|
|
var filterInfo = document.getElementById("filter-info");
|
|
if (dataMode === "community") {
|
|
filterInfo.textContent = "Showing communities. Double-click to drill down.";
|
|
} else if (dataMode === "file") {
|
|
filterInfo.textContent = "Showing file-level aggregation.";
|
|
} else {
|
|
filterInfo.textContent = "Showing all nodes.";
|
|
}
|
|
|
|
/* --- Tooltip --- */
|
|
var tooltip = document.getElementById("tooltip");
|
|
function showTooltip(ev, d) {
|
|
var bg = KIND_COLOR[d.kind] || "#555";
|
|
tooltip.textContent = "";
|
|
var nameSpan = document.createElement("span");
|
|
nameSpan.className = "tt-name";
|
|
nameSpan.textContent = d.name || d.label;
|
|
tooltip.appendChild(nameSpan);
|
|
var kindSpan = document.createElement("span");
|
|
kindSpan.className = "tt-kind";
|
|
kindSpan.style.background = bg;
|
|
kindSpan.style.color = "#0d1117";
|
|
kindSpan.textContent = d.kind;
|
|
tooltip.appendChild(kindSpan);
|
|
function addRow(label, value) {
|
|
var row = document.createElement("div");
|
|
row.className = "tt-row";
|
|
var lbl = document.createElement("span");
|
|
lbl.className = "tt-label";
|
|
lbl.textContent = label + ": ";
|
|
row.appendChild(lbl);
|
|
row.appendChild(document.createTextNode(String(value)));
|
|
tooltip.appendChild(row);
|
|
}
|
|
if (d.member_count != null) addRow("Members", d.member_count);
|
|
if (d.symbol_count != null) addRow("Symbols", d.symbol_count);
|
|
if (d.description) addRow("Description", d.description);
|
|
if (d.language) addRow("Language", d.language);
|
|
if (d.file_path) {
|
|
var relFile = d.file_path.split("/").slice(-3).join("/");
|
|
if (relFile) {
|
|
var fileRow = document.createElement("div");
|
|
fileRow.className = "tt-row tt-file";
|
|
fileRow.textContent = relFile;
|
|
tooltip.appendChild(fileRow);
|
|
}
|
|
}
|
|
if (d.line_start != null) addRow("Lines", d.line_start + " \u2013 " + (d.line_end || d.line_start));
|
|
if (d.params) addRow("Params", d.params);
|
|
if (d.return_type) addRow("Returns", d.return_type);
|
|
if (d.weight != null) addRow("Weight", d.weight);
|
|
tooltip.classList.add("visible");
|
|
moveTooltip(ev);
|
|
}
|
|
function moveTooltip(ev) {
|
|
var p = 14;
|
|
var x = ev.pageX + p, y = ev.pageY + p;
|
|
var r = tooltip.getBoundingClientRect();
|
|
if (x + r.width > innerWidth - p) x = ev.pageX - r.width - p;
|
|
if (y + r.height > innerHeight - p) y = ev.pageY - r.height - p;
|
|
tooltip.style.left = x + "px"; tooltip.style.top = y + "px";
|
|
}
|
|
function hideTooltip() { tooltip.classList.remove("visible"); }
|
|
|
|
/* --- SVG setup --- */
|
|
var W = innerWidth, H = innerHeight;
|
|
var svg = d3.select("svg").attr("viewBox", [0, 0, W, H]);
|
|
var gRoot = svg.append("g");
|
|
var currentTransform = d3.zoomIdentity;
|
|
var zoomBehavior = d3.zoom()
|
|
.scaleExtent([0.05, 8])
|
|
.on("zoom", function(ev) { currentTransform = ev.transform; gRoot.attr("transform", ev.transform); updateLabelVisibility(); });
|
|
svg.call(zoomBehavior);
|
|
var defs = svg.append("defs");
|
|
var glow = defs.append("filter").attr("id","glow").attr("x","-50%").attr("y","-50%").attr("width","200%").attr("height","200%");
|
|
glow.append("feGaussianBlur").attr("stdDeviation","3").attr("result","blur");
|
|
glow.append("feComposite").attr("in","SourceGraphic").attr("in2","blur").attr("operator","over");
|
|
[{id:"arrow-calls",color:"#3fb950"},{id:"arrow-imports",color:"#f0883e"},{id:"arrow-inherits",color:"#d2a8ff"}].forEach(function(mk) {
|
|
defs.append("marker").attr("id", mk.id)
|
|
.attr("viewBox","0 -5 10 10").attr("refX",28).attr("refY",0)
|
|
.attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto")
|
|
.append("path").attr("d","M0,-4L10,0L0,4Z").attr("fill",mk.color);
|
|
});
|
|
|
|
var linkGroup = gRoot.append("g").attr("class","links");
|
|
var nodeGroup = gRoot.append("g").attr("class","nodes");
|
|
var labelGroup = gRoot.append("g").attr("class","labels");
|
|
var linkSel = null, labelSel = null;
|
|
var showLabels = true;
|
|
var simulation = null;
|
|
var currentNodes = [];
|
|
var currentEdges = [];
|
|
var isDrilledDown = false;
|
|
|
|
function nodeRadius(d) {
|
|
if (d.kind === "Community") return Math.max(12, Math.min(40, 8 + Math.sqrt(d.member_count || 1) * 3));
|
|
if (d.kind === "File") {
|
|
if (d.symbol_count != null) return Math.max(8, Math.min(30, 6 + Math.sqrt(d.symbol_count || 1) * 2));
|
|
return 18;
|
|
}
|
|
return { Class: 12, Function: 6, Test: 6, Type: 5 }[d.kind] || 6;
|
|
}
|
|
function nodeColor(d) {
|
|
if (d.kind === "Community" && d.community_id != null) return communityColorScale(d.community_id);
|
|
return KIND_COLOR[d.kind] || "#8b949e";
|
|
}
|
|
|
|
function renderGraph(nodesData, edgesData, drillDown) {
|
|
isDrilledDown = !!drillDown;
|
|
currentNodes = nodesData.map(function(d) {
|
|
var o = Object.assign({}, d);
|
|
o._id = d.qualified_name;
|
|
o.label = d.name;
|
|
return o;
|
|
});
|
|
currentEdges = edgesData.map(function(d) {
|
|
var o = Object.assign({}, d);
|
|
o._source = d.source;
|
|
o._target = d.target;
|
|
return o;
|
|
});
|
|
|
|
/* Update legend based on current data */
|
|
var nodeKindSet = new Set(); var edgeKindSet = new Set();
|
|
currentNodes.forEach(function(n) { nodeKindSet.add(n.kind); });
|
|
currentEdges.forEach(function(e) { edgeKindSet.add(e.kind); });
|
|
buildLegend(Array.from(nodeKindSet), Array.from(edgeKindSet));
|
|
|
|
/* Back button */
|
|
document.getElementById("btn-back").style.display = isDrilledDown ? "block" : "none";
|
|
|
|
/* Clear existing */
|
|
linkGroup.selectAll("*").remove();
|
|
nodeGroup.selectAll("*").remove();
|
|
labelGroup.selectAll("*").remove();
|
|
if (simulation) simulation.stop();
|
|
|
|
var N = currentNodes.length;
|
|
var isLarge = N > 300;
|
|
var nodeById = new Map(currentNodes.map(function(n) { return [n.qualified_name, n]; }));
|
|
|
|
simulation = d3.forceSimulation(currentNodes)
|
|
.force("link", d3.forceLink(currentEdges).id(function(d) { return d.qualified_name; })
|
|
.distance(function(d) {
|
|
if (d.kind === "CONTAINS") return 35;
|
|
if (d.kind === "CROSS_COMMUNITY" || d.kind === "DEPENDS_ON") return Math.max(100, 200 - (d.weight || 1) * 5);
|
|
return isLarge ? 80 : 120;
|
|
})
|
|
.strength(function(d) {
|
|
if (d.kind === "CONTAINS") return 1.5;
|
|
if (d.kind === "CROSS_COMMUNITY" || d.kind === "DEPENDS_ON") return 0.1 + Math.min(0.5, (d.weight || 1) * 0.02);
|
|
return 0.15;
|
|
}))
|
|
.force("charge", d3.forceManyBody().strength(function(d) {
|
|
if (d.kind === "Community") return -400 - (d.member_count || 0) * 2;
|
|
return d.kind === "File" ? (isLarge ? -200 : -400) : (isLarge ? -60 : -120);
|
|
}).theta(0.85).distanceMax(600))
|
|
.force("collide", d3.forceCollide().radius(function(d) { return nodeRadius(d) + 6; }))
|
|
.force("center", d3.forceCenter(W / 2, H / 2))
|
|
.force("x", d3.forceX(W / 2).strength(0.03))
|
|
.force("y", d3.forceY(H / 2).strength(0.03))
|
|
.alphaDecay(isLarge ? 0.04 : 0.025)
|
|
.velocityDecay(0.4);
|
|
|
|
/* Draw edges */
|
|
linkSel = linkGroup.selectAll("line").data(currentEdges, function(d) { return d._source + "->" + d._target + ":" + d.kind; });
|
|
linkSel.exit().remove();
|
|
var linkEnter = linkSel.enter().append("line");
|
|
linkSel = linkEnter.merge(linkSel);
|
|
linkSel
|
|
.attr("stroke", function(d) { return eColor(d); })
|
|
.attr("stroke-width", function(d) {
|
|
if (d.weight && (d.kind === "CROSS_COMMUNITY" || d.kind === "DEPENDS_ON")) return Math.max(1, Math.min(6, Math.sqrt(d.weight)));
|
|
return eStyle(d).width;
|
|
})
|
|
.attr("stroke-dasharray", function(d) { return eStyle(d).dash; })
|
|
.attr("opacity", function(d) { return eStyle(d).opacity; })
|
|
.attr("marker-end", function(d) { return eStyle(d).marker; });
|
|
|
|
/* Draw nodes */
|
|
var nodeSel = nodeGroup.selectAll("g.node-g").data(currentNodes, function(d) { return d.qualified_name; });
|
|
nodeSel.exit().remove();
|
|
var enter = nodeSel.enter().append("g").attr("class", "node-g");
|
|
enter.append("circle")
|
|
.attr("class", "glow-ring")
|
|
.attr("r", function(d) { return nodeRadius(d) + 5; })
|
|
.attr("fill", "none")
|
|
.attr("stroke", function(d) { return nodeColor(d); })
|
|
.attr("stroke-width", 1.5).attr("opacity", 0.3).attr("filter", "url(#glow)");
|
|
enter.append("circle").attr("class", "node-circle")
|
|
.attr("r", function(d) { return nodeRadius(d); })
|
|
.attr("fill", function(d) { return nodeColor(d); })
|
|
.attr("stroke", "rgba(255,255,255,0.15)")
|
|
.attr("stroke-width", 2)
|
|
.attr("cursor", "pointer");
|
|
enter
|
|
.on("mouseover", function(ev, d) { highlightConnected(d, true); showTooltip(ev, d); })
|
|
.on("mousemove", function(ev) { moveTooltip(ev); })
|
|
.on("mouseout", function(ev, d) { highlightConnected(d, false); hideTooltip(); })
|
|
.on("click", function(ev, d) { ev.stopPropagation(); showDetailPanel(d, nodeById); })
|
|
.on("dblclick", function(ev, d) {
|
|
ev.stopPropagation();
|
|
if (d.kind === "Community" && dataMode === "community") drillIntoCommunity(d);
|
|
})
|
|
.call(d3.drag()
|
|
.on("start", function(ev, d) { if (!ev.active) simulation.alphaTarget(0.1).restart(); d.fx = d.x; d.fy = d.y; })
|
|
.on("drag", function(ev, d) { d.fx = ev.x; d.fy = ev.y; })
|
|
.on("end", function(ev, d) { if (!ev.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; })
|
|
);
|
|
nodeSel = enter.merge(nodeSel);
|
|
|
|
/* Draw labels */
|
|
labelSel = labelGroup.selectAll("text.node-label").data(currentNodes, function(d) { return d.qualified_name; });
|
|
labelSel.exit().remove();
|
|
var lEnter = labelSel.enter().append("text").attr("class", "node-label")
|
|
.attr("text-anchor", "start").attr("dy", "0.35em")
|
|
.text(function(d) { return d.label; })
|
|
.attr("fill", function(d) { return d.kind === "Community" ? "#e6edf3" : d.kind === "File" ? "#e6edf3" : "#8b949e"; })
|
|
.attr("font-size", function(d) { return d.kind === "Community" ? "13px" : d.kind === "File" ? "12px" : "10px"; })
|
|
.attr("font-weight", function(d) { return (d.kind === "Community" || d.kind === "File") ? 700 : 400; });
|
|
labelSel = lEnter.merge(labelSel);
|
|
|
|
simulation.on("tick", function() {
|
|
linkSel
|
|
.attr("x1", function(d) { return d.source.x; }).attr("y1", function(d) { return d.source.y; })
|
|
.attr("x2", function(d) { return d.target.x; }).attr("y2", function(d) { return d.target.y; });
|
|
nodeGroup.selectAll("g.node-g").attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; });
|
|
labelSel
|
|
.attr("x", function(d) { return d.x + nodeRadius(d) + 5; })
|
|
.attr("y", function(d) { return d.y; });
|
|
});
|
|
|
|
simulation.on("end", fitGraph);
|
|
updateLabelVisibility();
|
|
}
|
|
|
|
function updateLabelVisibility() {
|
|
if (!labelSel) return;
|
|
var s = currentTransform.k;
|
|
labelSel.attr("display", function(d) {
|
|
if (!showLabels) return "none";
|
|
if (d.kind === "Community" || d.kind === "File") return null;
|
|
if (d.kind === "Class") return s > 0.5 ? null : "none";
|
|
return s > 1.0 ? null : "none";
|
|
});
|
|
}
|
|
|
|
function highlightConnected(d, on) {
|
|
if (on) {
|
|
var connected = new Set([d.qualified_name]);
|
|
currentEdges.forEach(function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
if (s === d.qualified_name) connected.add(t);
|
|
if (t === d.qualified_name) connected.add(s);
|
|
});
|
|
nodeGroup.selectAll("g.node-g").select(".node-circle")
|
|
.transition().duration(150).attr("opacity", function(n) { return connected.has(n.qualified_name) ? 1 : 0.15; });
|
|
if (linkSel) linkSel.transition().duration(150)
|
|
.attr("opacity", function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
return (s === d.qualified_name || t === d.qualified_name) ? 0.9 : 0.03;
|
|
})
|
|
.attr("stroke-width", function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
var base = e.weight ? Math.max(1, Math.min(6, Math.sqrt(e.weight))) : eStyle(e).width;
|
|
return (s === d.qualified_name || t === d.qualified_name) ? base + 1.5 : base;
|
|
});
|
|
if (labelSel) labelSel.transition().duration(150).attr("opacity", function(n) { return connected.has(n.qualified_name) ? 1 : 0.1; });
|
|
} else {
|
|
nodeGroup.selectAll("g.node-g").select(".node-circle").transition().duration(300).attr("opacity", 1);
|
|
if (linkSel) linkSel.transition().duration(300)
|
|
.attr("opacity", function(e) { return eStyle(e).opacity; })
|
|
.attr("stroke-width", function(e) {
|
|
if (e.weight && (e.kind === "CROSS_COMMUNITY" || e.kind === "DEPENDS_ON")) return Math.max(1, Math.min(6, Math.sqrt(e.weight)));
|
|
return eStyle(e).width;
|
|
});
|
|
if (labelSel) labelSel.transition().duration(300).attr("opacity", 1);
|
|
updateLabelVisibility();
|
|
}
|
|
}
|
|
|
|
function fitGraph() {
|
|
var b = gRoot.node().getBBox();
|
|
if (b.width === 0 || b.height === 0) return;
|
|
var pad = 0.1;
|
|
var fw = b.width * (1 + 2 * pad), fh = b.height * (1 + 2 * pad);
|
|
var s = Math.min(W / fw, H / fh, 2.5);
|
|
var tx = W / 2 - (b.x + b.width / 2) * s, ty = H / 2 - (b.y + b.height / 2) * s;
|
|
svg.transition().duration(600).call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(s));
|
|
}
|
|
|
|
function zoomToNode(qn) {
|
|
var nd = currentNodes.find(function(n) { return n.qualified_name === qn; });
|
|
if (!nd || nd.x == null) return;
|
|
var s = 2.0;
|
|
var tx = W / 2 - nd.x * s, ty = H / 2 - nd.y * s;
|
|
svg.transition().duration(600).call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(s));
|
|
}
|
|
|
|
/* --- Detail panel --- */
|
|
var detailPanel = document.getElementById("detail-panel");
|
|
var dpContent = document.getElementById("dp-content");
|
|
document.querySelector("#detail-panel .dp-close").addEventListener("click", function() {
|
|
detailPanel.classList.remove("visible");
|
|
});
|
|
svg.on("click", function() { detailPanel.classList.remove("visible"); });
|
|
function showDetailPanel(d, nodeById) {
|
|
dpContent.textContent = "";
|
|
var h2 = document.createElement("h2");
|
|
h2.textContent = d.label || d.name;
|
|
dpContent.appendChild(h2);
|
|
var kindSpan = document.createElement("span");
|
|
kindSpan.className = "tt-kind";
|
|
kindSpan.style.background = KIND_COLOR[d.kind] || "#555";
|
|
kindSpan.style.color = "#0d1117";
|
|
kindSpan.textContent = d.kind;
|
|
dpContent.appendChild(kindSpan);
|
|
function addMeta(label, value) {
|
|
var div = document.createElement("div");
|
|
div.className = "dp-meta";
|
|
var lbl = document.createElement("span");
|
|
lbl.className = "tt-label";
|
|
lbl.textContent = label + ": ";
|
|
div.appendChild(lbl);
|
|
var val = document.createElement("span");
|
|
val.textContent = String(value);
|
|
div.appendChild(val);
|
|
dpContent.appendChild(div);
|
|
}
|
|
if (d.member_count != null) addMeta("Members", d.member_count);
|
|
if (d.symbol_count != null) addMeta("Symbols", d.symbol_count);
|
|
if (d.description) {
|
|
var desc = document.createElement("div");
|
|
desc.className = "dp-meta";
|
|
desc.style.marginTop = "6px";
|
|
desc.textContent = d.description;
|
|
dpContent.appendChild(desc);
|
|
}
|
|
if (d.language) addMeta("Language", d.language);
|
|
if (d.file_path) {
|
|
var relFile = d.file_path.split("/").slice(-3).join("/");
|
|
if (relFile) {
|
|
var fDiv = document.createElement("div");
|
|
fDiv.className = "dp-meta";
|
|
fDiv.style.marginTop = "8px";
|
|
fDiv.textContent = relFile + (d.line_start != null ? ":" + d.line_start : "");
|
|
dpContent.appendChild(fDiv);
|
|
}
|
|
}
|
|
if (d.params) addMeta("Params", d.params);
|
|
if (d.return_type) addMeta("Returns", d.return_type);
|
|
/* Show connected super-nodes */
|
|
var neighbors = [];
|
|
currentEdges.forEach(function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
if (s === d.qualified_name && nodeById.has(t)) neighbors.push({ node: nodeById.get(t), weight: e.weight || 1 });
|
|
if (t === d.qualified_name && nodeById.has(s)) neighbors.push({ node: nodeById.get(s), weight: e.weight || 1 });
|
|
});
|
|
if (neighbors.length) {
|
|
var section = document.createElement("div");
|
|
section.className = "dp-section";
|
|
var h4 = document.createElement("h4");
|
|
h4.textContent = "Connected (" + neighbors.length + ")";
|
|
section.appendChild(h4);
|
|
var ul = document.createElement("ul");
|
|
ul.className = "dp-list";
|
|
neighbors.sort(function(a, b) { return b.weight - a.weight; });
|
|
neighbors.slice(0, 20).forEach(function(nb) {
|
|
var li = document.createElement("li");
|
|
li.dataset.qn = nb.node.qualified_name;
|
|
li.textContent = (nb.node.label || nb.node.name) + " (" + nb.weight + ")";
|
|
li.addEventListener("click", function() { zoomToNode(nb.node.qualified_name); });
|
|
ul.appendChild(li);
|
|
});
|
|
section.appendChild(ul);
|
|
dpContent.appendChild(section);
|
|
}
|
|
if (d.kind === "Community" && dataMode === "community") {
|
|
var drillSection = document.createElement("div");
|
|
drillSection.className = "dp-section";
|
|
drillSection.style.marginTop = "12px";
|
|
var drillHint = document.createElement("em");
|
|
drillHint.style.color = "#58a6ff";
|
|
drillHint.textContent = "Double-click node to drill down";
|
|
drillSection.appendChild(drillHint);
|
|
dpContent.appendChild(drillSection);
|
|
}
|
|
detailPanel.classList.add("visible");
|
|
}
|
|
|
|
/* --- Drill-down (community mode) --- */
|
|
function drillIntoCommunity(d) {
|
|
var cid = String(d.community_id != null ? d.community_id : d.id);
|
|
var detail = communityDetails[cid];
|
|
if (!detail || !detail.nodes || detail.nodes.length === 0) return;
|
|
filterInfo.textContent = "Viewing community: " + (d.name || d.label) + ". Click Back to return.";
|
|
renderGraph(detail.nodes, detail.edges, true);
|
|
}
|
|
|
|
/* --- Back button --- */
|
|
document.getElementById("btn-back").addEventListener("click", function() {
|
|
filterInfo.textContent = dataMode === "community"
|
|
? "Showing communities. Double-click to drill down."
|
|
: "Showing file-level aggregation.";
|
|
renderGraph(graphData.nodes, graphData.edges, false);
|
|
});
|
|
|
|
/* --- Controls --- */
|
|
document.getElementById("btn-fit").addEventListener("click", fitGraph);
|
|
document.getElementById("btn-labels").addEventListener("click", function() {
|
|
showLabels = !showLabels;
|
|
this.classList.toggle("active");
|
|
this.setAttribute("aria-pressed", showLabels);
|
|
updateLabelVisibility();
|
|
});
|
|
|
|
/* --- Search --- */
|
|
var searchInput = document.getElementById("search");
|
|
var searchResults = document.getElementById("search-results");
|
|
var searchTerm = "";
|
|
searchInput.addEventListener("input", function() {
|
|
searchTerm = this.value.trim().toLowerCase();
|
|
applySearchFilter();
|
|
showSearchResults();
|
|
});
|
|
searchInput.addEventListener("focus", showSearchResults);
|
|
document.addEventListener("click", function(ev) {
|
|
if (!searchResults.contains(ev.target) && ev.target !== searchInput) searchResults.style.display = "none";
|
|
});
|
|
function showSearchResults() {
|
|
if (!searchTerm) { searchResults.style.display = "none"; return; }
|
|
var matched = [];
|
|
currentNodes.forEach(function(n) {
|
|
var hay = ((n.label || "") + " " + (n.qualified_name || "") + " " + (n.name || "")).toLowerCase();
|
|
if (hay.indexOf(searchTerm) !== -1) matched.push(n);
|
|
});
|
|
if (!matched.length) { searchResults.style.display = "none"; return; }
|
|
searchResults.textContent = "";
|
|
matched.slice(0, 15).forEach(function(n) {
|
|
var bg = KIND_COLOR[n.kind] || "#555";
|
|
var div = document.createElement("div");
|
|
div.className = "sr-item";
|
|
var kindSpan = document.createElement("span");
|
|
kindSpan.className = "sr-kind";
|
|
kindSpan.style.background = bg;
|
|
kindSpan.style.color = "#0d1117";
|
|
kindSpan.textContent = n.kind;
|
|
div.appendChild(kindSpan);
|
|
div.appendChild(document.createTextNode(" " + (n.label || n.name)));
|
|
div.addEventListener("click", function() {
|
|
zoomToNode(n.qualified_name);
|
|
searchResults.style.display = "none";
|
|
});
|
|
searchResults.appendChild(div);
|
|
});
|
|
searchResults.style.display = "block";
|
|
}
|
|
function applySearchFilter() {
|
|
if (!searchTerm) {
|
|
nodeGroup.selectAll("g.node-g").select(".node-circle").attr("opacity", 1);
|
|
if (labelSel) labelSel.attr("opacity", 1);
|
|
if (linkSel) linkSel.attr("opacity", function(e) { return eStyle(e).opacity; });
|
|
updateLabelVisibility();
|
|
return;
|
|
}
|
|
var matched = new Set();
|
|
currentNodes.forEach(function(n) {
|
|
var hay = ((n.label || "") + " " + (n.qualified_name || "") + " " + (n.name || "")).toLowerCase();
|
|
if (hay.indexOf(searchTerm) !== -1) matched.add(n.qualified_name);
|
|
});
|
|
nodeGroup.selectAll("g.node-g").select(".node-circle")
|
|
.attr("opacity", function(d) { return matched.has(d.qualified_name) ? 1 : 0.08; });
|
|
if (labelSel) labelSel
|
|
.attr("opacity", function(d) { return matched.has(d.qualified_name) ? 1 : 0.05; })
|
|
.attr("display", function(d) { return matched.has(d.qualified_name) ? null : "none"; });
|
|
if (linkSel) linkSel.attr("opacity", function(e) {
|
|
var s = typeof e.source === "object" ? e.source.qualified_name : e._source;
|
|
var t = typeof e.target === "object" ? e.target.qualified_name : e._target;
|
|
return (matched.has(s) || matched.has(t)) ? eStyle(e).opacity : 0.02;
|
|
});
|
|
}
|
|
|
|
/* --- Initial render --- */
|
|
renderGraph(graphData.nodes, graphData.edges, false);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|