<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>GitCortex — Knowledge Graph</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
<style>
*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #1e1e2e; --bg1: #181825; --bg2: #11111b;
--surface0: #313244; --surface1:#45475a; --overlay0:#6c7086;
--text: #cdd6f4; --subtext: #a6adc8; --subtext0:#bac2de;
--blue: #89b4fa; --sky: #74c7ec; --sapphire:#89dceb;
--teal: #94e2d5; --green: #a6e3a1; --yellow: #f9e2af;
--peach: #fab387; --flamingo:#eba0ac; --pink: #f5c2e7;
--mauve: #cba6f7; --red: #f38ba8; --lavender:#b4befe;
}
html, body { width:100%; height:100%; background:var(--bg); color:var(--text);
font-family:'JetBrains Mono',Menlo,Consolas,monospace; overflow:hidden; }
#cy { position:fixed; inset:0; }
#stats {
position:fixed; top:14px; left:14px; z-index:10;
background:rgba(24,24,37,.93); border:1px solid var(--surface0);
border-radius:8px; padding:10px 14px; font-size:11px; color:var(--overlay0);
line-height:1.8; pointer-events:none;
}
#stats b { color:var(--text); }
#search-wrap {
position:fixed; top:14px; left:50%; transform:translateX(-50%); z-index:10;
background:rgba(24,24,37,.93); border:1px solid var(--surface0); border-radius:8px;
display:flex; align-items:center; gap:8px; padding:8px 14px; min-width:280px;
}
#search-input {
background:transparent; border:none; outline:none; color:var(--text);
font-family:inherit; font-size:12px; width:220px;
}
#search-input::placeholder { color:var(--surface1); }
#search-clear { background:none; border:none; color:var(--overlay0); cursor:pointer;
font-size:14px; display:none; font-family:inherit; }
#search-count { color:var(--overlay0); font-size:10px; white-space:nowrap; }
#search-nav { display:none; gap:4px; }
#search-nav button {
background:var(--surface0); border:1px solid var(--surface1); color:var(--text);
border-radius:4px; padding:1px 7px; font-size:11px; cursor:pointer; font-family:inherit;
}
#layout-switcher {
position:fixed; top:14px; right:14px; z-index:10;
display:flex; gap:6px; align-items:center;
}
.layout-btn {
background:rgba(24,24,37,.93); border:1px solid var(--surface0); color:var(--subtext);
border-radius:6px; padding:5px 12px; font-size:11px; cursor:pointer; font-family:inherit;
transition:background .15s, color .15s;
}
.layout-btn.active { background:var(--mauve); color:var(--bg); border-color:var(--mauve); }
.layout-btn:hover:not(.active) { background:var(--surface0); color:var(--text); }
#filter-rail {
position:fixed; top:60px; left:14px; z-index:10; width:196px;
background:rgba(24,24,37,.97); border:1px solid var(--surface0); border-radius:8px;
padding:12px; display:flex; flex-direction:column; gap:10px; max-height:calc(100vh - 80px);
overflow-y:auto;
}
#filter-rail::-webkit-scrollbar { width:4px; }
#filter-rail::-webkit-scrollbar-thumb { background:var(--surface1); border-radius:2px; }
.filter-section-title {
font-size:9px; text-transform:uppercase; letter-spacing:.08em; color:var(--overlay0);
margin-bottom:4px;
}
.filter-toggle {
display:flex; align-items:center; gap:6px; margin:2px 0; cursor:pointer;
font-size:10px; color:var(--subtext); user-select:none;
}
.filter-toggle:hover { color:var(--text); }
.dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.dot-sq { width:8px; height:8px; border-radius:1px; flex-shrink:0; }
.filter-toggle.off { opacity:.35; }
.filter-sep { height:1px; background:var(--surface0); }
#vis-filter { display:flex; gap:4px; flex-wrap:wrap; margin-top:2px; }
.vis-btn {
padding:2px 8px; border-radius:4px; font-size:9px; cursor:pointer; font-family:inherit;
background:var(--surface0); color:var(--subtext); border:1px solid var(--surface1);
transition:background .12s, color .12s;
}
.vis-btn.active { background:var(--mauve); color:var(--bg); border-color:var(--mauve); }
.vis-btn:hover:not(.active) { background:var(--surface1); color:var(--text); }
#file-search-input {
width:100%; background:var(--surface0); border:1px solid var(--surface1); color:var(--text);
border-radius:4px; padding:4px 8px; font-size:10px; font-family:inherit; outline:none;
margin-bottom:4px;
}
#file-list { max-height:100px; overflow-y:auto; display:flex; flex-direction:column; gap:1px; }
.file-toggle {
font-size:9px; color:var(--subtext); padding:2px 4px; border-radius:3px; cursor:pointer;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; user-select:none;
}
.file-toggle:hover { background:var(--surface0); color:var(--text); }
.file-toggle.off { opacity:.3; }
#cluster-btns { display:flex; gap:3px; flex-wrap:wrap; margin-top:3px; }
.cluster-btn {
padding:2px 7px; border-radius:4px; font-size:9px; cursor:pointer; font-family:inherit;
border:1px solid var(--surface1); color:var(--subtext); transition:all .12s;
background:var(--surface0);
}
.cluster-btn.active { color:var(--bg); border-color:transparent; }
#inspector {
position:fixed; top:60px; right:14px; z-index:10; width:270px;
background:rgba(24,24,37,.97); border:1px solid var(--surface0); border-radius:8px;
padding:14px; display:none; max-height:calc(100vh - 160px); overflow-y:auto;
}
#inspector::-webkit-scrollbar { width:4px; }
#inspector::-webkit-scrollbar-thumb { background:var(--surface1); border-radius:2px; }
.insp-badge {
display:inline-block; padding:2px 9px; border-radius:99px;
font-size:9px; font-weight:700; margin-bottom:10px; color:var(--bg2);
}
.insp-field { margin:6px 0; }
.insp-label { color:var(--overlay0); font-size:9px; text-transform:uppercase; letter-spacing:.06em; }
.insp-val { color:var(--text); font-size:11px; word-break:break-all; }
.insp-tags { display:flex; gap:4px; flex-wrap:wrap; margin-top:2px; }
.tag {
padding:1px 7px; border-radius:99px; font-size:9px;
background:var(--surface0); color:var(--subtext); border:1px solid var(--surface1);
}
.tag.async { background:#89b4fa22; color:var(--blue); border-color:#89b4fa44; }
.tag.unsafe { background:#f38ba822; color:var(--red); border-color:#f38ba844; }
.tag.pub { background:#a6e3a122; color:var(--green); border-color:#a6e3a144; }
.tag.pubcrate { background:#f9e2af22; color:var(--yellow); border-color:#f9e2af44; }
.insp-section { margin-top:10px; border-top:1px solid var(--surface0); padding-top:8px; }
.insp-section-title { font-size:9px; text-transform:uppercase; color:var(--overlay0);
letter-spacing:.06em; margin-bottom:5px; }
.insp-list { display:flex; flex-direction:column; gap:2px; }
.insp-link {
font-size:10px; color:var(--blue); cursor:pointer; padding:1px 4px; border-radius:3px;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.insp-link:hover { background:var(--surface0); }
.insp-empty { font-size:10px; color:var(--overlay0); font-style:italic; }
.editor-link {
font-size:9px; padding:2px 7px; border-radius:4px; text-decoration:none;
background:var(--surface0); color:var(--subtext); border:1px solid var(--surface1);
}
.editor-link:hover { background:var(--surface1); color:var(--text); }
#trace-section { margin-top:10px; border-top:1px solid var(--surface0); padding-top:8px; }
.trace-row { display:flex; gap:4px; margin:3px 0; }
.trace-input {
flex:1; background:var(--surface0); border:1px solid var(--surface1); color:var(--text);
border-radius:4px; padding:3px 6px; font-size:10px; font-family:inherit; outline:none;
}
.trace-btn {
padding:3px 10px; background:var(--mauve); border:none; color:var(--bg);
border-radius:4px; font-size:10px; cursor:pointer; font-family:inherit; font-weight:700;
}
.trace-btn:hover { opacity:.85; }
#trace-result { font-size:10px; color:var(--subtext); margin-top:4px; }
#trace-clear {
font-size:9px; color:var(--red); background:none; border:none; cursor:pointer;
font-family:inherit; padding:0; display:none; margin-top:2px;
}
#edge-legend {
position:fixed; bottom:14px; right:14px; z-index:10;
background:rgba(24,24,37,.93); border:1px solid var(--surface0); border-radius:8px;
padding:10px 14px; font-size:10px;
}
.legend-title { color:var(--overlay0); font-size:9px; text-transform:uppercase;
letter-spacing:.06em; margin-bottom:5px; }
.legend-row { display:flex; align-items:center; gap:6px; margin:2px 0; }
.edge-line { width:18px; height:2px; flex-shrink:0; border-radius:1px; }
</style>
</head>
<body>
<div id="stats"><b id="s-nodes">—</b> nodes · <b id="s-edges">—</b> edges · <b id="s-clusters">—</b> clusters</div>
<div id="search-wrap">
<span style="color:var(--overlay0);font-size:12px">⌕</span>
<input id="search-input" type="text" placeholder="Search symbols…" autocomplete="off" spellcheck="false">
<span id="search-count"></span>
<span id="search-nav">
<button id="search-prev">↑</button>
<button id="search-next">↓</button>
</span>
<button id="search-clear">✕</button>
</div>
<div id="layout-switcher">
<button class="layout-btn active" data-layout="force">Force</button>
<button class="layout-btn" data-layout="concentric">Concentric</button>
<button class="layout-btn" data-layout="breadthfirst">Tree</button>
</div>
<div id="filter-rail">
<div>
<div class="filter-section-title">Node Kind</div>
<div id="kind-filters"></div>
</div>
<div class="filter-sep"></div>
<div>
<div class="filter-section-title">Visibility</div>
<div id="vis-filter">
<button class="vis-btn active" data-vis="pub">pub</button>
<button class="vis-btn active" data-vis="pub(crate)">crate</button>
<button class="vis-btn active" data-vis="private">priv</button>
</div>
</div>
<div class="filter-sep"></div>
<div>
<div class="filter-section-title">Flags</div>
<div id="flag-filters">
<label class="filter-toggle" id="flag-async"><span class="dot" style="background:var(--blue)"></span>async only</label>
<label class="filter-toggle" id="flag-unsafe"><span class="dot" style="background:var(--red)"></span>unsafe only</label>
</div>
</div>
<div class="filter-sep"></div>
<div>
<div class="filter-section-title">Cluster</div>
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:2px;">
<button class="cluster-btn active" id="cluster-all">All</button>
</div>
<div id="cluster-btns"></div>
</div>
<div class="filter-sep"></div>
<div>
<div class="filter-section-title">Files</div>
<input id="file-search-input" type="text" placeholder="Filter files…" spellcheck="false">
<div id="file-list"></div>
</div>
</div>
<div id="inspector">
<span class="insp-badge" id="insp-badge"></span>
<div class="insp-field">
<div class="insp-label">Name</div>
<div class="insp-val" id="insp-name"></div>
</div>
<div class="insp-field">
<div class="insp-label">Qualified</div>
<div class="insp-val" id="insp-qualified"></div>
</div>
<div class="insp-field">
<div class="insp-label">File : Line</div>
<div class="insp-val" id="insp-file"></div>
<div style="display:flex;gap:6px;margin-top:3px;" id="editor-links"></div>
</div>
<div class="insp-field">
<div class="insp-label">LOC</div>
<div class="insp-val" id="insp-loc"></div>
</div>
<div class="insp-field">
<div class="insp-label">Flags</div>
<div class="insp-tags" id="insp-tags"></div>
</div>
<div class="insp-section">
<div class="insp-section-title">Callers (<span id="insp-callers-count">0</span>)</div>
<div class="insp-list" id="insp-callers"></div>
</div>
<div class="insp-section">
<div class="insp-section-title">Callees (<span id="insp-callees-count">0</span>)</div>
<div class="insp-list" id="insp-callees"></div>
</div>
<div class="insp-section">
<div class="insp-section-title">Uses / Implements (<span id="insp-deps-count">0</span>)</div>
<div class="insp-list" id="insp-deps"></div>
</div>
<div id="trace-section">
<div class="insp-section-title" style="margin-bottom:6px">Trace Path to…</div>
<div class="trace-row">
<input id="trace-target" class="trace-input" type="text" placeholder="target symbol name…" autocomplete="off">
<button class="trace-btn" id="trace-run">→</button>
</div>
<div id="trace-result"></div>
<button id="trace-clear">Clear path</button>
</div>
</div>
<div id="edge-legend">
<div class="legend-title">Edge Kind</div>
<div id="legend-rows"></div>
</div>
<div id="cy"></div>
<script>
const KIND_COLOR = {
'function': '#89b4fa', 'method': '#74c7ec', 'struct': '#a6e3a1', 'enum': '#94e2d5', 'trait': '#fab387', 'interface': '#89dceb', 'module': '#cba6f7', 'type_alias': '#f38ba8', 'constant': '#f9e2af', 'macro': '#f5c2e7', 'file': '#6c7086', 'folder': '#45475a', 'property': '#b4befe', 'annotation': '#eba0ac', 'enum_member': '#a8d8a8', };
const EDGE_COLOR = {
'calls': '#89b4fa', 'implements': '#eba0ac', 'inherits': '#fab387', 'uses': '#cba6f7', 'imports': '#6c7086', 'contains': '#45475a', 'throws': '#f38ba8', 'annotated': '#89dceb', };
const CLUSTER_ACCENT = [
'#89b4fa','#a6e3a1','#fab387','#cba6f7','#94e2d5',
'#f9e2af','#eba0ac','#89dceb','#f38ba8','#74c7ec',
'#f5c2e7','#b4befe','#a8d8a8','#cdd6f4','#45475a','#6c7086',
];
function detectCommunities(nodes, edges) {
const label = {};
nodes.forEach(n => { label[n.id] = n.id; });
const adj = {};
nodes.forEach(n => { adj[n.id] = []; });
edges.forEach(e => {
if (adj[e.src]) adj[e.src].push(e.dst);
if (adj[e.dst]) adj[e.dst].push(e.src);
});
for (let iter = 0; iter < 30; iter++) {
let changed = false;
const shuffled = nodes.slice().sort(() => Math.random() - 0.5);
for (const n of shuffled) {
const nbrs = adj[n.id];
if (!nbrs.length) continue;
const counts = {};
nbrs.forEach(nb => { const l = label[nb]; counts[l] = (counts[l] || 0) + 1; });
const best = Object.entries(counts).sort((a,b) => b[1]-a[1])[0][0];
if (best !== label[n.id]) { label[n.id] = best; changed = true; }
}
if (!changed) break;
}
const ids = [...new Set(Object.values(label))];
const idx = {};
ids.forEach((id, i) => { idx[id] = i; });
const out = {};
nodes.forEach(n => { out[n.id] = idx[label[n.id]]; });
return out;
}
function computeClusterNames(nodes, communityMap) {
const SKIP = new Set(['src','lib','main','mod','index','test','tests','__init__','util','utils','common']);
const EXT = /\.(rs|py|ts|js|tsx|jsx|go|java|kt|cpp|cc|h)$/;
const segCounts = {};
nodes.forEach(n => {
const c = communityMap[n.id];
if (!(c in segCounts)) segCounts[c] = {};
const parts = (n.file || '').replace(/\\/g, '/').split('/').filter(Boolean);
parts.forEach((seg, i) => {
const s = seg.replace(EXT, '');
if (SKIP.has(s) || s.length < 2) return;
const w = (i === parts.length - 2) ? 3 : (i === parts.length - 1) ? 1 : 2;
segCounts[c][s] = (segCounts[c][s] || 0) + w;
});
});
const names = {};
Object.keys(segCounts).forEach(c => {
const sorted = Object.entries(segCounts[c]).sort((a,b) => b[1]-a[1]);
names[c] = sorted.length ? sorted[0][0] : `C${parseInt(c)+1}`;
});
return names;
}
let cy, allData, communityMap, clusterNames;
let searchMatches = [], searchIdx = 0;
let tracedEdges = null;
const activeKinds = new Set();
const activeEdgeKinds = new Set();
const activeVis = new Set(['pub','pub(crate)','private']);
const hiddenFiles = new Set();
let flagFilterAsync = false;
let flagFilterUnsafe = false;
let activeCluster = null;
fetch('/data')
.then(r => r.json())
.then(data => {
allData = data;
const { nodes, edges } = data;
communityMap = detectCommunities(nodes, edges);
clusterNames = computeClusterNames(nodes, communityMap);
const numClusters = new Set(Object.values(communityMap)).size;
nodes.forEach(n => activeKinds.add(n.kind));
edges.forEach(e => activeEdgeKinds.add(e.kind));
buildFilterRail(nodes, edges, numClusters);
buildEdgeLegend();
initCytoscape(nodes, edges);
document.getElementById('s-nodes').textContent = nodes.length;
document.getElementById('s-edges').textContent = edges.length;
document.getElementById('s-clusters').textContent = numClusters;
});
function buildElements(nodes, edges) {
const els = [];
const nodeSet = new Set(nodes.map(n => n.id));
nodes.forEach(n => {
const loc = n.loc || 0;
const size = Math.max(10, Math.min(40, 10 + Math.sqrt(loc) * 2.4));
const cluster = communityMap[n.id] ?? 0;
const color = KIND_COLOR[n.kind] || '#a6adc8';
els.push({ group:'nodes', data:{
id: n.id, label: n.name, kind: n.kind, file: n.file,
qualified_name: n.qualified_name || n.name,
start_line: n.start_line || 1, end_line: n.end_line || 1,
loc, vis: n.visibility || 'private',
is_async: n.is_async || false, is_unsafe: n.is_unsafe || false,
cluster, color, size,
}});
});
edges.forEach(e => {
if (!nodeSet.has(e.src) || !nodeSet.has(e.dst)) return;
els.push({ group:'edges', data:{
id: `${e.src}→${e.dst}:${e.kind}`,
source: e.src, target: e.dst, kind: e.kind,
color: EDGE_COLOR[e.kind] || '#6c7086',
}});
});
return els;
}
function initCytoscape(nodes, edges) {
cy = cytoscape({
container: document.getElementById('cy'),
elements: buildElements(nodes, edges),
style: cyStyle(),
layout: forceLayout(),
wheelSensitivity: 0.25,
minZoom: 0.02, maxZoom: 10,
pixelRatio: 'auto',
});
cy.on('tap', 'node', e => openInspector(e.target));
cy.on('tap', e => { if (e.target === cy) closeInspector(); });
}
function cyStyle() {
return [
{
selector: 'node',
style: {
'width': 'data(size)',
'height': 'data(size)',
'background-color': 'data(color)',
'background-opacity': 0.88,
'border-width': 1.5,
'border-color': 'data(color)',
'border-opacity': 1,
'label': 'data(label)',
'color': '#cdd6f4',
'font-size': 8,
'font-family': 'JetBrains Mono, Menlo, monospace',
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': 3,
'text-background-color': '#1e1e2e',
'text-background-opacity': 0.75,
'text-background-padding': '2px',
'text-max-width': '90px',
'text-overflow-wrap': 'ellipsis',
'min-zoomed-font-size': 8,
}
},
{
selector: 'node:selected',
style: {
'border-width': 3,
'border-color': '#cdd6f4',
'background-opacity': 1,
}
},
{
selector: 'node.hover',
style: {
'background-opacity': 1,
'border-width': 2,
'border-color': '#cdd6f4',
}
},
{
selector: 'edge',
style: {
'width': 0.8,
'line-color': 'data(color)',
'opacity': 0.35,
'curve-style': 'bezier',
'target-arrow-color': 'data(color)',
'target-arrow-shape': 'triangle',
'arrow-scale': 0.5,
}
},
{ selector: 'edge:selected', style: { 'opacity': 1, 'width': 2 } },
{ selector: '.dimmed', style: { 'opacity': 0.06 } },
{ selector: '.highlighted', style: { 'opacity': 1, 'z-index': 9999 } },
{ selector: '.path-node', style: { 'border-width': 3, 'border-color': '#cdd6f4', 'background-opacity': 1, 'z-index': 9999 } },
{ selector: '.path-edge', style: { 'opacity': 1, 'width': 2.5, 'z-index': 9998 } },
];
}
function forceLayout() {
return {
name: 'cose',
animate: true,
animationDuration: 1200,
animationEasing: 'ease-in-out-cubic',
fit: true,
padding: 80,
randomize: true,
nodeRepulsion: () => 450000,
nodeOverlap: 30,
idealEdgeLength: () => 160,
edgeElasticity: () => 40,
nestingFactor: 1.2,
gravity: 5,
numIter: 2500,
initialTemp: 300,
coolingFactor: 0.97,
minTemp: 1.0,
componentSpacing: 80,
};
}
function runLayout(name) {
const opts = {
force: forceLayout(),
concentric: {
name: 'concentric',
animate: true, fit: true, padding: 60,
animationDuration: 800,
concentric: n => n.degree(),
levelWidth: () => 4,
minNodeSpacing: 30,
},
breadthfirst: {
name: 'breadthfirst',
animate: true, fit: true, padding: 60,
animationDuration: 800,
directed: true,
spacingFactor: 1.4,
grid: false,
},
};
(cy.layout(opts[name] || opts.force)).run();
}
function buildFilterRail(nodes, edges, numClusters) {
const kindDiv = document.getElementById('kind-filters');
[...activeKinds].sort().forEach(k => {
const el = document.createElement('label');
el.className = 'filter-toggle';
el.dataset.kind = k;
const color = KIND_COLOR[k] || '#a6adc8';
el.innerHTML = `<span class="dot" style="background:${color}"></span>${k}`;
el.addEventListener('click', () => toggleKind(k, el));
kindDiv.appendChild(el);
});
document.querySelectorAll('.vis-btn').forEach(btn => {
btn.addEventListener('click', () => {
const v = btn.dataset.vis;
if (activeVis.has(v)) activeVis.delete(v); else activeVis.add(v);
btn.classList.toggle('active', activeVis.has(v));
applyFilters();
});
});
document.getElementById('flag-async').addEventListener('click', () => {
flagFilterAsync = !flagFilterAsync;
document.getElementById('flag-async').classList.toggle('off', flagFilterAsync);
applyFilters();
});
document.getElementById('flag-unsafe').addEventListener('click', () => {
flagFilterUnsafe = !flagFilterUnsafe;
document.getElementById('flag-unsafe').classList.toggle('off', flagFilterUnsafe);
applyFilters();
});
const clusterBtns = document.getElementById('cluster-btns');
for (let i = 0; i < Math.min(numClusters, 16); i++) {
const btn = document.createElement('button');
btn.className = 'cluster-btn';
const name = clusterNames[i] || `C${i + 1}`;
btn.textContent = name;
btn.title = `Cluster ${i + 1}`;
btn.dataset.cluster = i;
const accent = CLUSTER_ACCENT[i % CLUSTER_ACCENT.length];
btn.dataset.accent = accent;
btn.addEventListener('click', () => setClusterFilter(i, btn, accent));
clusterBtns.appendChild(btn);
}
document.getElementById('cluster-all').addEventListener('click', () => {
activeCluster = null;
document.getElementById('cluster-all').classList.add('active');
document.getElementById('cluster-all').style.background = 'var(--mauve)';
document.getElementById('cluster-all').style.color = 'var(--bg)';
document.querySelectorAll('#cluster-btns .cluster-btn').forEach(b => {
b.classList.remove('active');
b.style.background = '';
b.style.color = '';
});
applyFilters();
});
const allBtn = document.getElementById('cluster-all');
allBtn.style.background = 'var(--mauve)';
allBtn.style.color = 'var(--bg2)';
const files = [...new Set(nodes.map(n => n.file))].sort();
const fileList = document.getElementById('file-list');
files.forEach(f => {
const el = document.createElement('div');
el.className = 'file-toggle';
el.textContent = f.replace(/^.*\/([^/]+\/[^/]+)$/, '…/$1');
el.title = f;
el.dataset.file = f;
el.addEventListener('click', () => toggleFile(f, el));
fileList.appendChild(el);
});
document.getElementById('file-search-input').addEventListener('input', e => {
const q = e.target.value.toLowerCase();
document.querySelectorAll('.file-toggle').forEach(el => {
el.style.display = el.dataset.file.toLowerCase().includes(q) ? '' : 'none';
});
});
}
function setClusterFilter(clusterIdx, btn, accent) {
activeCluster = clusterIdx;
document.getElementById('cluster-all').classList.remove('active');
document.getElementById('cluster-all').style.background = '';
document.getElementById('cluster-all').style.color = '';
document.querySelectorAll('#cluster-btns .cluster-btn').forEach(b => {
b.classList.remove('active');
b.style.background = '';
b.style.color = '';
});
btn.classList.add('active');
btn.style.background = accent;
btn.style.color = '#1e1e2e';
applyFilters();
}
function toggleKind(k, el) {
if (activeKinds.has(k)) activeKinds.delete(k); else activeKinds.add(k);
el.classList.toggle('off', !activeKinds.has(k));
applyFilters();
}
function toggleFile(f, el) {
if (hiddenFiles.has(f)) hiddenFiles.delete(f); else hiddenFiles.add(f);
el.classList.toggle('off', hiddenFiles.has(f));
applyFilters();
}
function applyFilters() {
if (!cy) return;
cy.batch(() => {
cy.nodes().forEach(n => {
const d = n.data();
const visible =
activeKinds.has(d.kind) &&
activeVis.has(d.vis) &&
!hiddenFiles.has(d.file) &&
(!flagFilterAsync || d.is_async) &&
(!flagFilterUnsafe || d.is_unsafe) &&
(activeCluster === null || d.cluster === activeCluster);
n.style('display', visible ? 'element' : 'none');
});
cy.edges().forEach(e => {
const d = e.data();
const ok = e.source().style('display') !== 'none' &&
e.target().style('display') !== 'none';
e.style('display', ok ? 'element' : 'none');
});
});
}
function buildEdgeLegend() {
const container = document.getElementById('legend-rows');
Object.entries(EDGE_COLOR).forEach(([kind, color]) => {
const row = document.createElement('div');
row.className = 'legend-row';
row.innerHTML = `<div class="edge-line" style="background:${color}"></div>`
+ `<span style="color:${color};font-size:10px">${kind}</span>`;
container.appendChild(row);
});
}
function openInspector(node) {
const d = node.data();
const badge = document.getElementById('insp-badge');
badge.textContent = d.kind;
badge.style.background = d.color || '#a6adc8';
document.getElementById('insp-name').textContent = d.label;
document.getElementById('insp-qualified').textContent = d.qualified_name;
document.getElementById('insp-file').textContent = `${d.file}:${d.start_line}`;
const editorLinks = document.getElementById('editor-links');
editorLinks.innerHTML = '';
const line = d.start_line || 1;
const fileEnc = encodeURIComponent(d.file);
[
{ label: 'VS Code', href: `vscode://file/${d.file}:${line}` },
{ label: 'Cursor', href: `cursor://file/${d.file}:${line}` },
{ label: 'IDEA', href: `idea://open?file=${fileEnc}&line=${line}` },
].forEach(({ label, href }) => {
const a = document.createElement('a');
a.className = 'editor-link'; a.textContent = label; a.href = href; a.target = '_blank';
editorLinks.appendChild(a);
});
document.getElementById('insp-loc').textContent = d.loc ? `${d.loc} lines` : '—';
const tags = document.getElementById('insp-tags');
tags.innerHTML = '';
const vis = d.vis || 'private';
const visTag = document.createElement('span');
visTag.className = `tag ${vis === 'pub' ? 'pub' : vis === 'pub(crate)' ? 'pubcrate' : ''}`;
visTag.textContent = vis;
tags.appendChild(visTag);
if (d.is_async) { const t = document.createElement('span'); t.className='tag async'; t.textContent='async'; tags.appendChild(t); }
if (d.is_unsafe) { const t = document.createElement('span'); t.className='tag unsafe'; t.textContent='unsafe'; tags.appendChild(t); }
const callers = cy.edges(`[target = "${d.id}"][kind = "calls"]`).map(e => e.source().data());
renderInspList('insp-callers', 'insp-callers-count', callers);
const callees = cy.edges(`[source = "${d.id}"][kind = "calls"]`).map(e => e.target().data());
renderInspList('insp-callees', 'insp-callees-count', callees);
const deps = cy.edges(`[source = "${d.id}"]`)
.filter(e => ['uses','implements','inherits'].includes(e.data('kind')))
.map(e => e.target().data());
renderInspList('insp-deps', 'insp-deps-count', deps);
document.getElementById('inspector').style.display = 'block';
document.getElementById('trace-target').value = '';
document.getElementById('trace-result').textContent = '';
document.getElementById('trace-clear').style.display = 'none';
clearTrace();
}
function renderInspList(listId, countId, items) {
const list = document.getElementById(listId);
const count = document.getElementById(countId);
count.textContent = items.length;
list.innerHTML = '';
items.slice(0, 12).forEach(item => {
const el = document.createElement('div');
el.className = 'insp-link';
el.textContent = item.label;
el.title = item.qualified_name;
el.addEventListener('click', () => {
const n = cy.getElementById(item.id);
if (n.length) { cy.animate({ fit:{ eles:n, padding:80 }, duration:400 }); openInspector(n); }
});
list.appendChild(el);
});
if (!items.length) {
const el = document.createElement('div'); el.className='insp-empty'; el.textContent='none';
list.appendChild(el);
} else if (items.length > 12) {
const el = document.createElement('div'); el.className='insp-empty';
el.textContent = `+${items.length - 12} more`;
list.appendChild(el);
}
}
function closeInspector() {
document.getElementById('inspector').style.display = 'none';
clearTrace();
}
document.getElementById('trace-run').addEventListener('click', runTrace);
document.getElementById('trace-target').addEventListener('keydown', e => {
if (e.key === 'Enter') runTrace();
});
document.getElementById('trace-clear').addEventListener('click', () => {
clearTrace();
document.getElementById('trace-result').textContent = '';
document.getElementById('trace-clear').style.display = 'none';
});
function runTrace() {
if (!cy) return;
clearTrace();
const insp = document.getElementById('insp-name').textContent;
const target = document.getElementById('trace-target').value.trim();
if (!insp || !target) return;
const srcNodes = cy.nodes().filter(n => n.data('label') === insp || n.data('qualified_name').includes(insp));
const dstNodes = cy.nodes().filter(n => n.data('label') === target || n.data('qualified_name').includes(target));
if (!srcNodes.length || !dstNodes.length) {
document.getElementById('trace-result').textContent = 'Symbol not found.';
return;
}
const path = bfsPath(srcNodes[0].id(), dstNodes[0].id());
const result = document.getElementById('trace-result');
if (!path) { result.textContent = 'No path found.'; return; }
result.textContent = `Path: ${path.length - 1} hop${path.length - 2 === 1 ? '' : 's'}`;
document.getElementById('trace-clear').style.display = '';
cy.elements().addClass('dimmed');
tracedEdges = [];
for (let i = 0; i < path.length; i++) {
const n = cy.getElementById(path[i]);
n.removeClass('dimmed').addClass('path-node');
if (i > 0) {
const e = cy.edges(`[source = "${path[i-1]}"][target = "${path[i]}"]`);
e.removeClass('dimmed').addClass('path-edge');
tracedEdges.push(e);
}
}
cy.animate({ fit:{ eles: cy.collection(path.map(id => cy.getElementById(id))), padding:80 }, duration:600 });
}
function clearTrace() {
if (cy) cy.elements().removeClass('dimmed path-node path-edge highlighted');
tracedEdges = null;
}
function bfsPath(srcId, dstId) {
if (srcId === dstId) return [srcId];
const prev = {}, visited = new Set([srcId]), queue = [srcId];
while (queue.length) {
const cur = queue.shift();
for (const nb of cy.getElementById(cur).outgoers('node')) {
const nbId = nb.id();
if (visited.has(nbId)) continue;
prev[nbId] = cur;
if (nbId === dstId) {
const path = [dstId]; let c = dstId;
while (prev[c]) { c = prev[c]; path.unshift(c); }
return path;
}
visited.add(nbId); queue.push(nbId);
}
if (visited.size > 4000) break;
}
return null;
}
const searchInput = document.getElementById('search-input');
const searchCount = document.getElementById('search-count');
const searchNav = document.getElementById('search-nav');
const searchClear = document.getElementById('search-clear');
searchInput.addEventListener('input', () => {
const q = searchInput.value.trim().toLowerCase();
searchClear.style.display = q ? '' : 'none';
if (!q || !cy) { clearSearch(); return; }
searchMatches = cy.nodes().filter(n =>
n.data('label').toLowerCase().includes(q) ||
n.data('qualified_name').toLowerCase().includes(q)
).toArray();
searchIdx = 0;
updateSearchUI();
if (searchMatches.length) zoomToMatch(0);
});
searchInput.addEventListener('keydown', e => {
if (!searchMatches.length) return;
if (e.key === 'ArrowDown' || e.key === 'Enter') {
e.preventDefault(); searchIdx = (searchIdx + 1) % searchMatches.length; zoomToMatch(searchIdx);
}
if (e.key === 'ArrowUp') {
e.preventDefault(); searchIdx = (searchIdx - 1 + searchMatches.length) % searchMatches.length; zoomToMatch(searchIdx);
}
if (e.key === 'Escape') { clearSearch(); searchInput.value = ''; }
});
document.getElementById('search-prev').addEventListener('click', () => {
if (!searchMatches.length) return;
searchIdx = (searchIdx - 1 + searchMatches.length) % searchMatches.length;
zoomToMatch(searchIdx);
});
document.getElementById('search-next').addEventListener('click', () => {
if (!searchMatches.length) return;
searchIdx = (searchIdx + 1) % searchMatches.length;
zoomToMatch(searchIdx);
});
searchClear.addEventListener('click', () => { clearSearch(); searchInput.value = ''; });
function zoomToMatch(i) {
const n = searchMatches[i];
cy.animate({ center:{ eles: n }, zoom: 3, duration: 300 });
openInspector(n);
updateSearchUI();
}
function updateSearchUI() {
if (!searchMatches.length) { clearSearch(); return; }
searchCount.textContent = `${searchIdx + 1}/${searchMatches.length}`;
searchNav.style.display = 'flex';
cy.elements().removeClass('highlighted dimmed');
if (searchMatches.length < cy.nodes().length) {
cy.elements().addClass('dimmed');
searchMatches.forEach(n => n.removeClass('dimmed').addClass('highlighted'));
}
}
function clearSearch() {
searchMatches = []; searchIdx = 0;
searchCount.textContent = '';
searchNav.style.display = 'none';
if (cy) cy.elements().removeClass('highlighted dimmed path-node path-edge');
}
document.querySelectorAll('.layout-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.layout-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
runLayout(btn.dataset.layout);
});
});
</script>
</body>
</html>