Files
Tirth Kanani 9faeb54955 Merge remote-tracking branch 'origin/main' into fix/issue-475-visualize-serve-cors
# Conflicts:
#	code_review_graph/visualization.py
2026-07-30 21:53:53 +01:00

2356 lines
102 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 an aggregated mode when the rendered node count or
edge count exceeds its threshold (community when community data exists,
file otherwise)
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import sqlite3
from collections import Counter, defaultdict
from dataclasses import asdict
from importlib import resources
from pathlib import Path
from .graph import GraphStore, edge_to_dict, node_to_dict
logger = logging.getLogger(__name__)
# Pinned D3 build vendored with the package (issue #475). The generated HTML
# loads this same-origin copy first so `visualize --serve` works on offline or
# filtered networks, and only falls back to the CDN — still SRI-pinned — when
# the local copy is unavailable. Both references carry the same integrity hash.
D3_LOCAL_FILENAME = "d3.v7.min.js"
D3_CDN_URL = "https://d3js.org/d3.v7.min.js"
D3_SRI_HASH = "sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i"
def _d3_script_tags() -> str:
"""Build the D3 loader tags: same-origin script plus SRI-pinned CDN fallback.
The fallback uses ``document.write`` so it loads *synchronously* during
parsing — the rest of the page's inline scripts use ``d3`` immediately.
"""
local_tag = f'<script src="{D3_LOCAL_FILENAME}" integrity="{D3_SRI_HASH}"></script>'
cdn_tag = (
f'<script src="{D3_CDN_URL}" integrity="{D3_SRI_HASH}" crossorigin="anonymous">'
"<\\/script>"
)
fallback_tag = f"<script>window.d3 || document.write('{cdn_tag}');</script>"
return local_tag + "\n" + fallback_tag
def _write_d3_asset(directory: Path) -> Path | None:
"""Copy the vendored D3 build next to the generated HTML.
The copy is verified against the pinned SRI hash before writing. Returns
the written path, or ``None`` if the bundled asset is missing, corrupt, or
unwritable — the generated page then loads D3 via the SRI-pinned CDN
fallback tag instead.
"""
dest = directory / D3_LOCAL_FILENAME
try:
asset = resources.files("code_review_graph") / "assets" / D3_LOCAL_FILENAME
data = asset.read_bytes()
except OSError as exc:
logger.warning("Bundled D3 asset unavailable (%s); page will use the CDN fallback.", exc)
return None
digest = base64.b64encode(hashlib.sha384(data).digest()).decode()
if f"sha384-{digest}" != D3_SRI_HASH:
logger.error(
"Bundled D3 asset does not match the pinned SRI hash; refusing to write %s. "
"The page will use the CDN fallback.",
dest,
)
return None
try:
dest.write_bytes(data)
except OSError as exc:
logger.warning("Could not write %s (%s); page will use the CDN fallback.", dest, exc)
return None
return dest
# Auto-mode thresholds for the full D3 force layout. Rendering cost scales
# with both counts: every simulation tick runs an O(E) link force on top of
# the O(N log N) many-body force, and the SVG DOM holds one element per node
# *and* one per edge. The long-standing 3000-node cap implicitly tolerated
# the ~3 edges per node typical of graphs at that size (~9000 rendered SVG
# edge elements), so the edge cap is derived from the same rendering budget:
# 3x the node cap. Issue #609 (2792 nodes / 17488 edges) stalled because
# only nodes were checked.
DEFAULT_MAX_FULL_NODES = 3000
DEFAULT_MAX_FULL_EDGES = 3 * DEFAULT_MAX_FULL_NODES
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 _has_community_data(data: dict) -> bool:
"""Return True if the exported graph carries any community assignment."""
if data.get("communities"):
return True
return any(n.get("community_id") is not None for n in data["nodes"])
def _resolve_auto_mode(
node_count: int,
edge_count: int,
max_full_nodes: int,
max_full_edges: int,
has_communities: bool,
) -> str:
"""Pick the effective rendering mode for ``mode="auto"``.
The full force layout is only viable while *both* rendered counts stay
within budget (see DEFAULT_MAX_FULL_NODES / DEFAULT_MAX_FULL_EDGES).
When aggregation is needed, prefer community mode; fall back to file
aggregation when no community data is available (otherwise every node
would collapse into a single "Uncategorized" super-node whose drill-down
re-renders the entire graph).
"""
if node_count <= max_full_nodes and edge_count <= max_full_edges:
return "full"
return "community" if has_communities else "file"
def generate_html(
store: GraphStore,
output_path: str | Path,
mode: str = "auto",
max_full_nodes: int = DEFAULT_MAX_FULL_NODES,
max_full_edges: int = DEFAULT_MAX_FULL_EDGES,
) -> 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 an aggregated mode
(community, or file when no community data exists) when the
rendered node count exceeds *max_full_nodes* or the rendered
edge count exceeds *max_full_edges*.
max_full_nodes: Rendered-node threshold for auto-switching.
max_full_edges: Rendered-edge threshold for auto-switching.
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 = _resolve_auto_mode(
node_count=len(data["nodes"]),
edge_count=len(data["edges"]),
max_full_nodes=max_full_nodes,
max_full_edges=max_full_edges,
has_communities=_has_community_data(data),
)
if effective_mode != "full":
logger.info(
"auto mode: %d nodes / %d edges exceeds full-render budget "
"(%d nodes / %d edges) — using %s aggregation",
len(data["nodes"]), len(data["edges"]),
max_full_nodes, max_full_edges, effective_mode,
)
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("</", "<\\/")
template = _AGGREGATED_HTML_TEMPLATE
elif effective_mode == "file":
agg = _aggregate_file(data)
data_json = json.dumps(agg, default=str).replace("</", "<\\/")
template = _AGGREGATED_HTML_TEMPLATE
else:
# full mode — original behavior
data_json = json.dumps(data, default=str).replace("</", "<\\/")
template = _HTML_TEMPLATE
# Substitute the script tags into the trusted template BEFORE the graph
# data: graph content is repo-derived, and running a replace over it would
# let a node literally named __D3_SCRIPTS__ inject markup into the page.
html = template.replace("__D3_SCRIPTS__", _d3_script_tags())
html = html.replace("__GRAPH_DATA__", data_json)
output_path.write_text(html, encoding="utf-8")
# Ship the vendored D3 build alongside the HTML so the same-origin script
# reference resolves both under `visualize --serve` and file:// opens.
_write_d3_asset(output_path.parent)
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>
__D3_SCRIPTS__
<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&#8230;" 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">&times;</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">&times;</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 &mdash; 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&hellip;</div>
</div>
</div>
<div id="empty-state" role="status">
<div class="empty-icon">&#x1F50D;</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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;").replace(/`/g,"&#96;"); }
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 svgEl = document.getElementById("graph-svg");
function getW() { return svgEl.clientWidth || window.innerWidth || 800; }
function getH() { return svgEl.clientHeight || window.innerHeight || 600; }
var W = getW(), H = getH();
var svg = d3.select("#graph-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 syncViewport() {
W = getW(); H = getH();
svg.attr("viewBox", [0, 0, W, H]);
simulation.force("center", d3.forceCenter(W / 2, H / 2));
simulation.force("x", d3.forceX(W / 2).strength(0.03));
simulation.force("y", d3.forceY(H / 2).strength(0.03));
}
function fitGraph(retries) {
if (retries === undefined) retries = 10;
var b = gRoot.node().getBBox();
if (b.width === 0 || b.height === 0) {
if (retries > 0) requestAnimationFrame(function() { fitGraph(retries - 1); });
return;
}
var pad = 0.1;
var fw = b.width * (1 + 2*pad), fh = b.height * (1 + 2*pad);
var cw = getW(), ch = getH();
W = cw; H = ch;
svg.attr("viewBox", [0, 0, W, H]);
var s = Math.min(cw / fw, ch / fh, 2.5);
var tx = cw/2 - (b.x + b.width/2)*s, ty = ch/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");
syncViewport();
requestAnimationFrame(function() { fitGraph(); });
});
window.addEventListener("resize", function() {
syncViewport();
requestAnimationFrame(function() { 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", function() { 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>
__D3_SCRIPTS__
<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&#8230;" 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">&times;</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">&larr; Back to Overview</button>
<svg id="graph-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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;").replace(/`/g,"&#96;"); }
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 svgEl = document.getElementById("graph-svg");
function getW() { return svgEl.clientWidth || window.innerWidth || 800; }
function getH() { return svgEl.clientHeight || window.innerHeight || 600; }
var W = getW(), H = getH();
var svg = d3.select("#graph-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", function() {
syncViewport();
requestAnimationFrame(function() { 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 syncViewport() {
W = getW(); H = getH();
svg.attr("viewBox", [0, 0, W, H]);
if (simulation) {
simulation.force("center", d3.forceCenter(W / 2, H / 2));
simulation.force("x", d3.forceX(W / 2).strength(0.03));
simulation.force("y", d3.forceY(H / 2).strength(0.03));
}
}
function fitGraph(retries) {
if (retries === undefined) retries = 10;
var b = gRoot.node().getBBox();
if (b.width === 0 || b.height === 0) {
if (retries > 0) requestAnimationFrame(function() { fitGraph(retries - 1); });
return;
}
var pad = 0.1;
var fw = b.width * (1 + 2 * pad), fh = b.height * (1 + 2 * pad);
var cw = getW(), ch = getH();
W = cw; H = ch;
svg.attr("viewBox", [0, 0, W, H]);
var s = Math.min(cw / fw, ch / fh, 2.5);
var tx = cw / 2 - (b.x + b.width / 2) * s, ty = ch / 2 - (b.y + b.height / 2) * s;
svg.transition().duration(600).call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(s));
}
window.addEventListener("resize", function() {
syncViewport();
requestAnimationFrame(function() { fitGraph(); });
});
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", function() { 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>
"""