polycodegraph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +10 -0
- codegraph/analysis/__init__.py +30 -0
- codegraph/analysis/_common.py +125 -0
- codegraph/analysis/blast_radius.py +63 -0
- codegraph/analysis/cycles.py +79 -0
- codegraph/analysis/dataflow.py +861 -0
- codegraph/analysis/dead_code.py +165 -0
- codegraph/analysis/hotspots.py +68 -0
- codegraph/analysis/infrastructure.py +439 -0
- codegraph/analysis/metrics.py +52 -0
- codegraph/analysis/report.py +222 -0
- codegraph/analysis/roles.py +323 -0
- codegraph/analysis/untested.py +79 -0
- codegraph/cli.py +1506 -0
- codegraph/config.py +64 -0
- codegraph/embed/__init__.py +35 -0
- codegraph/embed/chunker.py +120 -0
- codegraph/embed/embedder.py +113 -0
- codegraph/embed/query.py +181 -0
- codegraph/embed/store.py +360 -0
- codegraph/graph/__init__.py +0 -0
- codegraph/graph/builder.py +212 -0
- codegraph/graph/schema.py +69 -0
- codegraph/graph/store_networkx.py +55 -0
- codegraph/graph/store_sqlite.py +249 -0
- codegraph/mcp_server/__init__.py +6 -0
- codegraph/mcp_server/server.py +933 -0
- codegraph/parsers/__init__.py +0 -0
- codegraph/parsers/base.py +70 -0
- codegraph/parsers/go.py +570 -0
- codegraph/parsers/python.py +1707 -0
- codegraph/parsers/typescript.py +1397 -0
- codegraph/py.typed +0 -0
- codegraph/resolve/__init__.py +4 -0
- codegraph/resolve/calls.py +480 -0
- codegraph/review/__init__.py +31 -0
- codegraph/review/baseline.py +32 -0
- codegraph/review/differ.py +211 -0
- codegraph/review/hook.py +70 -0
- codegraph/review/risk.py +219 -0
- codegraph/review/rules.py +342 -0
- codegraph/viz/__init__.py +17 -0
- codegraph/viz/_style.py +45 -0
- codegraph/viz/dashboard.py +740 -0
- codegraph/viz/diagrams.py +370 -0
- codegraph/viz/explore.py +453 -0
- codegraph/viz/hld.py +683 -0
- codegraph/viz/html.py +115 -0
- codegraph/viz/mermaid.py +111 -0
- codegraph/viz/svg.py +77 -0
- codegraph/web/__init__.py +4 -0
- codegraph/web/server.py +165 -0
- codegraph/web/static/app.css +664 -0
- codegraph/web/static/app.js +919 -0
- codegraph/web/static/index.html +112 -0
- codegraph/web/static/views/architecture.js +1671 -0
- codegraph/web/static/views/graph3d.css +564 -0
- codegraph/web/static/views/graph3d.js +999 -0
- codegraph/web/static/views/graph3d_transform.js +984 -0
- codegraph/workspace/__init__.py +34 -0
- codegraph/workspace/config.py +110 -0
- codegraph/workspace/operations.py +294 -0
- polycodegraph-0.1.0.dist-info/METADATA +687 -0
- polycodegraph-0.1.0.dist-info/RECORD +67 -0
- polycodegraph-0.1.0.dist-info/WHEEL +4 -0
- polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
- polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
/* codegraph dashboard - vanilla JS app */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ----- Theme + sidebar persistence (apply early) -----
|
|
5
|
+
(function applyEarlyPrefs() {
|
|
6
|
+
try {
|
|
7
|
+
const t = localStorage.getItem('cg-theme');
|
|
8
|
+
if (t === 'light') document.documentElement.classList.add('theme-light');
|
|
9
|
+
if (localStorage.getItem('cg-sb') === 'collapsed')
|
|
10
|
+
document.documentElement.classList.add('sb-collapsed');
|
|
11
|
+
} catch (e) { /* ignore */ }
|
|
12
|
+
})();
|
|
13
|
+
|
|
14
|
+
const state = {
|
|
15
|
+
data: null,
|
|
16
|
+
view: 'overview',
|
|
17
|
+
flowSel: 0,
|
|
18
|
+
};
|
|
19
|
+
// Expose to other view scripts (graph3d.js etc.) that load in separate
|
|
20
|
+
// <script> tags. Top-level `const` is not implicitly a window property.
|
|
21
|
+
window.state = state;
|
|
22
|
+
|
|
23
|
+
const VIEWS = [
|
|
24
|
+
{ section: 'Insights' },
|
|
25
|
+
{ id: 'overview', label: 'Overview', icon: 'layout-dashboard' },
|
|
26
|
+
{ id: 'hld', label: 'HLD', icon: 'layers' },
|
|
27
|
+
{ id: 'architecture', label: 'Architecture', icon: 'cloud' },
|
|
28
|
+
{ id: 'flows', label: 'Call flows', icon: 'git-fork' },
|
|
29
|
+
{ section: 'Diagrams' },
|
|
30
|
+
{ id: 'matrix', label: 'Matrix', icon: 'grid-3x3' },
|
|
31
|
+
{ id: 'sankey', label: 'Sankey', icon: 'waves' },
|
|
32
|
+
{ id: 'treemap', label: 'Treemap', icon: 'square-stack' },
|
|
33
|
+
{ id: 'graph3d', label: '3D Graph', icon: 'atom' },
|
|
34
|
+
{ section: 'Browse' },
|
|
35
|
+
{ id: 'explorers', label: 'Explorers', icon: 'compass' },
|
|
36
|
+
{ id: 'files', label: 'Files', icon: 'folder-tree' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// ---- Tooltip ----
|
|
40
|
+
const tt = document.getElementById('tooltip');
|
|
41
|
+
function showTip(html, x, y) {
|
|
42
|
+
tt.innerHTML = html;
|
|
43
|
+
const r = tt.getBoundingClientRect();
|
|
44
|
+
let lx = x + 14, ly = y + 14;
|
|
45
|
+
if (lx + r.width > innerWidth - 8) lx = x - r.width - 14;
|
|
46
|
+
if (ly + r.height > innerHeight - 8) ly = y - r.height - 14;
|
|
47
|
+
tt.style.left = lx + 'px';
|
|
48
|
+
tt.style.top = ly + 'px';
|
|
49
|
+
tt.style.opacity = '1';
|
|
50
|
+
}
|
|
51
|
+
function hideTip() { tt.style.opacity = '0'; }
|
|
52
|
+
|
|
53
|
+
// ---- Toast ----
|
|
54
|
+
function toast(msg, kind) {
|
|
55
|
+
const host = document.getElementById('toast-host');
|
|
56
|
+
const el = document.createElement('div');
|
|
57
|
+
el.className = 'toast ' + (kind || '');
|
|
58
|
+
el.textContent = msg;
|
|
59
|
+
host.appendChild(el);
|
|
60
|
+
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity 0.3s';
|
|
61
|
+
setTimeout(() => el.remove(), 350); }, 2400);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- Mermaid ----
|
|
65
|
+
function mermaidThemeVars() {
|
|
66
|
+
const light = document.documentElement.classList.contains('theme-light');
|
|
67
|
+
return light
|
|
68
|
+
? { fontFamily: 'Inter, system-ui, sans-serif', fontSize: '14px',
|
|
69
|
+
background: 'transparent',
|
|
70
|
+
primaryColor: '#eef2ff', primaryTextColor: '#0f172a',
|
|
71
|
+
primaryBorderColor: '#a5b4fc', lineColor: '#6366f1',
|
|
72
|
+
secondaryColor: '#f5f3ff', tertiaryColor: '#ffffff',
|
|
73
|
+
clusterBkg: 'rgba(238,242,255,0.7)', clusterBorder: '#a5b4fc',
|
|
74
|
+
nodeBorder: '#a5b4fc', mainBkg: '#eef2ff',
|
|
75
|
+
edgeLabelBackground: '#ffffff', titleColor: '#1e293b' }
|
|
76
|
+
: { fontFamily: 'Inter, system-ui, sans-serif', fontSize: '14px',
|
|
77
|
+
background: 'transparent',
|
|
78
|
+
primaryColor: '#1d2942', primaryTextColor: '#e6ecf5',
|
|
79
|
+
primaryBorderColor: '#3b4a6a', lineColor: '#5b6b8c',
|
|
80
|
+
secondaryColor: '#161f33', tertiaryColor: '#0f1626',
|
|
81
|
+
clusterBkg: 'rgba(15,22,38,0.6)', clusterBorder: '#3b4a6a',
|
|
82
|
+
nodeBorder: '#3b4a6a', mainBkg: '#1d2942',
|
|
83
|
+
edgeLabelBackground: '#0a0f1c', titleColor: '#c4cfe2' };
|
|
84
|
+
}
|
|
85
|
+
function initMermaid() {
|
|
86
|
+
mermaid.initialize({
|
|
87
|
+
startOnLoad: false, theme: 'base',
|
|
88
|
+
themeVariables: mermaidThemeVars(),
|
|
89
|
+
flowchart: { padding: 18, nodeSpacing: 38, rankSpacing: 54,
|
|
90
|
+
curve: 'basis', htmlLabels: true, useMaxWidth: true },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
initMermaid();
|
|
94
|
+
|
|
95
|
+
// ---- Sidebar ----
|
|
96
|
+
function buildNav() {
|
|
97
|
+
const nav = document.getElementById('nav');
|
|
98
|
+
VIEWS.forEach(v => {
|
|
99
|
+
if (v.section) {
|
|
100
|
+
const h = document.createElement('div');
|
|
101
|
+
h.className = 'nav-section'; h.textContent = v.section;
|
|
102
|
+
nav.appendChild(h);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const item = document.createElement('div');
|
|
106
|
+
item.className = 'nav-item';
|
|
107
|
+
item.dataset.id = v.id;
|
|
108
|
+
item.innerHTML = `<i data-lucide="${v.icon}"></i><span>${v.label}</span>`;
|
|
109
|
+
item.onclick = () => activate(v.id);
|
|
110
|
+
nav.appendChild(item);
|
|
111
|
+
});
|
|
112
|
+
lucide.createIcons();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function activate(id) {
|
|
116
|
+
state.view = id;
|
|
117
|
+
document.querySelectorAll('.nav-item').forEach(el =>
|
|
118
|
+
el.classList.toggle('active', el.dataset.id === id));
|
|
119
|
+
const view = VIEWS.find(v => v.id === id);
|
|
120
|
+
document.getElementById('page-title').textContent = view?.label || 'View';
|
|
121
|
+
document.getElementById('crumb').textContent =
|
|
122
|
+
VIEWS.find(v => v.section && VIEWS.indexOf(v) <
|
|
123
|
+
VIEWS.findIndex(x => x.id === id))?.section || 'codegraph';
|
|
124
|
+
render(id);
|
|
125
|
+
history.replaceState({}, '', '#' + id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- Header stats ----
|
|
129
|
+
function setHeaderStats() {
|
|
130
|
+
const m = state.data.metrics, iss = state.data.issues;
|
|
131
|
+
document.getElementById('header-stats').innerHTML = `
|
|
132
|
+
<span class="pill">${m.nodes} nodes</span>
|
|
133
|
+
<span class="pill">${m.edges} edges</span>
|
|
134
|
+
${iss.cycles ? `<span class="pill pill-hot">${iss.cycles} cycles</span>` : ''}
|
|
135
|
+
${iss.dead ? `<span class="pill pill-warm">${iss.dead} dead</span>` : ''}
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- Views ----
|
|
140
|
+
function render(id) {
|
|
141
|
+
const host = document.getElementById('view-host');
|
|
142
|
+
host.innerHTML = '';
|
|
143
|
+
const fn = VIEW_RENDERERS[id];
|
|
144
|
+
if (!fn) { host.innerHTML = '<div class="p-8 text-ink-200">Unknown view.</div>'; return; }
|
|
145
|
+
fn(host);
|
|
146
|
+
lucide.createIcons();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const VIEW_RENDERERS = {
|
|
150
|
+
overview: renderOverview,
|
|
151
|
+
hld: renderHld,
|
|
152
|
+
flows: renderFlows,
|
|
153
|
+
matrix: renderMatrix,
|
|
154
|
+
sankey: renderSankey,
|
|
155
|
+
treemap: renderTreemap,
|
|
156
|
+
graph3d: renderGraph3dShim,
|
|
157
|
+
architecture: (host) => {
|
|
158
|
+
if (typeof window.renderArchitectureView === 'function') {
|
|
159
|
+
window.renderArchitectureView(host);
|
|
160
|
+
} else {
|
|
161
|
+
host.innerHTML = '<div class="p-8 text-ink-200">Architecture view not loaded.</div>';
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
explorers: renderExplorers,
|
|
165
|
+
files: renderFiles,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// ---------- Overview ----------
|
|
169
|
+
function renderOverview(host) {
|
|
170
|
+
const m = state.data.metrics, iss = state.data.issues;
|
|
171
|
+
const card = (n, l, accent) => `
|
|
172
|
+
<div class="stat-card">
|
|
173
|
+
<div class="stat-num ${accent || ''}">${n}</div>
|
|
174
|
+
<div class="stat-lbl">${l}</div>
|
|
175
|
+
</div>`;
|
|
176
|
+
const rows = obj => Object.entries(obj).sort((a,b)=>b[1]-a[1]).map(([k,v]) =>
|
|
177
|
+
`<tr><td>${esc(k)}</td><td class="num">${v}</td></tr>`).join('');
|
|
178
|
+
const hot = state.data.hotspots.map(h => `
|
|
179
|
+
<tr>
|
|
180
|
+
<td><span class="qn-mono text-[12.5px]">${formatQn(h.qualname, {maxParts: 4})}</span></td>
|
|
181
|
+
<td class="text-ink-200"><code>${esc(h.file)}</code></td>
|
|
182
|
+
<td class="num">${h.fan_in}</td>
|
|
183
|
+
<td class="num">${h.fan_out}</td>
|
|
184
|
+
<td class="num">${h.loc}</td>
|
|
185
|
+
<td class="num"><span class="pill ${h.score>200?'pill-hot':h.score>80?'pill-warm':''}">${h.score}</span></td>
|
|
186
|
+
</tr>`).join('');
|
|
187
|
+
|
|
188
|
+
host.innerHTML = `
|
|
189
|
+
<div class="p-8 space-y-6 max-w-7xl mx-auto">
|
|
190
|
+
<div class="help-card">
|
|
191
|
+
<i data-lucide="sparkles" class="icon w-4 h-4"></i>
|
|
192
|
+
<div><b>Where to start.</b> Open <b>HLD</b> for a clean layered diagram of how the codebase is wired.
|
|
193
|
+
Use <b>Call flows</b> to step through specific functions, or <b>Matrix</b> to see who calls whom in one glance.</div>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
|
196
|
+
${card(m.nodes, 'Nodes')}
|
|
197
|
+
${card(m.edges, 'Edges')}
|
|
198
|
+
${card(m.unresolved, 'Unresolved', m.unresolved ? 'text-accent-amber' : '')}
|
|
199
|
+
${card(iss.cycles, 'Cycles', iss.cycles ? 'text-accent-rose' : '')}
|
|
200
|
+
${card(iss.dead, 'Dead-code candidates', iss.dead ? 'text-accent-amber' : '')}
|
|
201
|
+
${card(iss.untested, 'Untested fns', iss.untested ? 'text-accent-amber' : '')}
|
|
202
|
+
</div>
|
|
203
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
204
|
+
<div class="panel p-5"><div class="section-h"><h2>Nodes by kind</h2></div>
|
|
205
|
+
<table class="data">${rows(m.by_kind)}</table></div>
|
|
206
|
+
<div class="panel p-5"><div class="section-h"><h2>Edges by kind</h2></div>
|
|
207
|
+
<table class="data">${rows(m.by_edge)}</table></div>
|
|
208
|
+
<div class="panel p-5"><div class="section-h"><h2>Languages</h2></div>
|
|
209
|
+
<table class="data">${rows(m.languages)}</table></div>
|
|
210
|
+
</div>
|
|
211
|
+
<div class="panel p-5">
|
|
212
|
+
<div class="section-h"><h2>Top hotspots</h2>
|
|
213
|
+
<span class="text-[11px] text-ink-200">score = fan_in*2 + fan_out + LOC/50</span></div>
|
|
214
|
+
<table class="data">
|
|
215
|
+
<thead><tr><th>Symbol</th><th>File</th><th class="num">Fan-in</th>
|
|
216
|
+
<th class="num">Fan-out</th><th class="num">LOC</th><th class="num">Score</th></tr></thead>
|
|
217
|
+
<tbody>${hot}</tbody>
|
|
218
|
+
</table>
|
|
219
|
+
</div>
|
|
220
|
+
</div>`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------- HLD ----------
|
|
224
|
+
const hldNav = { layer: null, module: null, symbol: null };
|
|
225
|
+
|
|
226
|
+
function renderHld(host) {
|
|
227
|
+
const hld = state.data.hld;
|
|
228
|
+
if (!hld) { host.innerHTML = '<div class="text-app-2 p-8">No HLD payload.</div>'; return; }
|
|
229
|
+
const m = hld.metrics;
|
|
230
|
+
const card = (n, l) => `
|
|
231
|
+
<div class="stat-card"><div class="stat-num">${n}</div>
|
|
232
|
+
<div class="stat-lbl">${l}</div></div>`;
|
|
233
|
+
|
|
234
|
+
host.innerHTML = `
|
|
235
|
+
<div class="p-8 space-y-6 max-w-7xl mx-auto">
|
|
236
|
+
<div class="help-card">
|
|
237
|
+
<i data-lucide="map" class="icon w-4 h-4"></i>
|
|
238
|
+
<div><b>How to read this.</b> Top = system context. Below = the layered architecture (heaviest modules per layer; <code>+N more</code> means more exist — see Navigator). The <b>Navigator</b> drills <i>Layer → Module → Symbol</i>; selecting a symbol draws a live focus graph of who calls it and what it calls.</div>
|
|
239
|
+
</div>
|
|
240
|
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
241
|
+
${card(m.layers, 'Layers')}
|
|
242
|
+
${card(m.components, 'Modules')}
|
|
243
|
+
${card(m.cross_layer_edges, 'Cross-layer edges')}
|
|
244
|
+
${card(m.total_cross_layer_calls, 'Cross-layer calls')}
|
|
245
|
+
</div>
|
|
246
|
+
<div class="panel p-5">
|
|
247
|
+
<div class="section-h"><h2>System context</h2>
|
|
248
|
+
<span class="text-[11px] text-app-2">C4-style</span></div>
|
|
249
|
+
<div class="mermaid-host" style="min-height:240px"><pre class="mermaid">${esc(hld.mermaid_context)}</pre></div>
|
|
250
|
+
</div>
|
|
251
|
+
<div class="panel p-5">
|
|
252
|
+
<div class="section-h"><h2>Layered architecture</h2>
|
|
253
|
+
<span class="text-[11px] text-app-2">top modules per layer · <code>+N more</code> = drill in Navigator</span></div>
|
|
254
|
+
<div class="mermaid-host" style="min-height:520px"><pre class="mermaid">${esc(hld.mermaid_layered)}</pre></div>
|
|
255
|
+
</div>
|
|
256
|
+
<div class="panel p-5">
|
|
257
|
+
<div class="section-h">
|
|
258
|
+
<h2>Navigator</h2>
|
|
259
|
+
<div id="hld-crumb" class="hld-crumb"></div>
|
|
260
|
+
</div>
|
|
261
|
+
<div class="hld-cols">
|
|
262
|
+
<div class="hld-col" id="hld-col-layers"></div>
|
|
263
|
+
<div class="hld-col" id="hld-col-modules"></div>
|
|
264
|
+
<div class="hld-col" id="hld-col-symbols"></div>
|
|
265
|
+
</div>
|
|
266
|
+
<div id="hld-detail" class="hld-detail"></div>
|
|
267
|
+
<svg id="hld-focus" class="hld-focus" style="display:none"></svg>
|
|
268
|
+
</div>
|
|
269
|
+
</div>`;
|
|
270
|
+
|
|
271
|
+
if (hldNav.layer && !(hld.components[hldNav.layer] || []).length) hldNav.layer = null;
|
|
272
|
+
hldRenderNav();
|
|
273
|
+
mermaid.run({ nodes: host.querySelectorAll('.mermaid') });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function hldRenderNav() {
|
|
277
|
+
const hld = state.data.hld;
|
|
278
|
+
const layers = hld.layers.filter(L => (hld.components[L.id] || []).length);
|
|
279
|
+
|
|
280
|
+
const colLayers = document.getElementById('hld-col-layers');
|
|
281
|
+
const colMods = document.getElementById('hld-col-modules');
|
|
282
|
+
const colSyms = document.getElementById('hld-col-symbols');
|
|
283
|
+
const crumb = document.getElementById('hld-crumb');
|
|
284
|
+
const detail = document.getElementById('hld-detail');
|
|
285
|
+
|
|
286
|
+
// ---- Layers column
|
|
287
|
+
colLayers.innerHTML = `<div class="hld-col-h">Layers</div>` +
|
|
288
|
+
layers.map(L => {
|
|
289
|
+
const n = (hld.components[L.id] || []).length;
|
|
290
|
+
const active = hldNav.layer === L.id ? ' active' : '';
|
|
291
|
+
return `<div class="hld-row${active}" data-layer="${L.id}">
|
|
292
|
+
<span class="swatch" style="background:${L.color}"></span>
|
|
293
|
+
<div class="flex-1 min-w-0">
|
|
294
|
+
<div class="hld-row-t">${esc(L.title)}</div>
|
|
295
|
+
<div class="hld-row-s">${esc(L.subtitle)}</div>
|
|
296
|
+
</div>
|
|
297
|
+
<span class="pill">${n}</span>
|
|
298
|
+
<i data-lucide="chevron-right" class="hld-chev"></i>
|
|
299
|
+
</div>`;
|
|
300
|
+
}).join('');
|
|
301
|
+
colLayers.querySelectorAll('[data-layer]').forEach(el => {
|
|
302
|
+
el.onclick = () => { hldNav.layer = el.dataset.layer;
|
|
303
|
+
hldNav.module = null; hldNav.symbol = null; hldRenderNav(); };
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ---- Modules column
|
|
307
|
+
if (!hldNav.layer) {
|
|
308
|
+
colMods.innerHTML = `<div class="hld-col-h">Modules</div>
|
|
309
|
+
<div class="hld-empty">Pick a layer →</div>`;
|
|
310
|
+
} else {
|
|
311
|
+
const modules = (hld.components[hldNav.layer] || [])
|
|
312
|
+
.slice().sort((a, b) => b.symbols - a.symbols);
|
|
313
|
+
colMods.innerHTML = `<div class="hld-col-h">Modules · ${esc(layerTitle(hldNav.layer))}</div>` +
|
|
314
|
+
modules.map(c => {
|
|
315
|
+
const active = hldNav.module === c.qualname ? ' active' : '';
|
|
316
|
+
return `<div class="hld-row${active}" data-module="${esc(c.qualname)}">
|
|
317
|
+
<i data-lucide="package" class="hld-ico"></i>
|
|
318
|
+
<div class="flex-1 min-w-0">
|
|
319
|
+
<div class="hld-row-t qn-mono">${formatQn(c.qualname, {maxParts: 2})}</div>
|
|
320
|
+
<div class="hld-row-s">${esc(c.file || '')}</div>
|
|
321
|
+
</div>
|
|
322
|
+
<span class="pill">${c.symbols}</span>
|
|
323
|
+
<i data-lucide="chevron-right" class="hld-chev"></i>
|
|
324
|
+
</div>`;
|
|
325
|
+
}).join('') || '<div class="hld-empty">No modules.</div>';
|
|
326
|
+
colMods.querySelectorAll('[data-module]').forEach(el => {
|
|
327
|
+
el.onclick = () => { hldNav.module = el.dataset.module;
|
|
328
|
+
hldNav.symbol = null; hldRenderNav(); };
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- Symbols column
|
|
333
|
+
if (!hldNav.module) {
|
|
334
|
+
colSyms.innerHTML = `<div class="hld-col-h">Symbols</div>
|
|
335
|
+
<div class="hld-empty">Pick a module →</div>`;
|
|
336
|
+
} else {
|
|
337
|
+
const mod = (hld.modules || {})[hldNav.module];
|
|
338
|
+
const symbols = mod ? (mod.symbols || []) : [];
|
|
339
|
+
colSyms.innerHTML = `<div class="hld-col-h">Symbols · ${esc(shortQn(hldNav.module))}</div>` +
|
|
340
|
+
(symbols.length
|
|
341
|
+
? symbols.map(s => {
|
|
342
|
+
const active = hldNav.symbol === s.qualname ? ' active' : '';
|
|
343
|
+
return `<div class="hld-row${active}" data-symbol="${esc(s.qualname)}">
|
|
344
|
+
<i data-lucide="${kindIcon(s.kind)}" class="hld-ico" style="color:${kindColor(s.kind)}"></i>
|
|
345
|
+
<div class="flex-1 min-w-0">
|
|
346
|
+
<div class="hld-row-t qn-mono">${esc(s.name)}</div>
|
|
347
|
+
<div class="hld-row-s">${s.kind} · L${s.line || '?'}</div>
|
|
348
|
+
</div>
|
|
349
|
+
<span class="pill" title="fan-in / fan-out">${s.fan_in}/${s.fan_out}</span>
|
|
350
|
+
</div>`;
|
|
351
|
+
}).join('')
|
|
352
|
+
: '<div class="hld-empty">No symbols recorded.</div>');
|
|
353
|
+
colSyms.querySelectorAll('[data-symbol]').forEach(el => {
|
|
354
|
+
el.onclick = () => { hldNav.symbol = el.dataset.symbol; hldRenderNav(); };
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- Crumb
|
|
359
|
+
const parts = [];
|
|
360
|
+
parts.push(`<a class="crumb-link" data-jump="root">All layers</a>`);
|
|
361
|
+
if (hldNav.layer) parts.push(`<span class="crumb-sep">/</span>
|
|
362
|
+
<a class="crumb-link" data-jump="layer">${esc(layerTitle(hldNav.layer))}</a>`);
|
|
363
|
+
if (hldNav.module) parts.push(`<span class="crumb-sep">/</span>
|
|
364
|
+
<a class="crumb-link qn-mono" data-jump="module">${esc(shortQn(hldNav.module))}</a>`);
|
|
365
|
+
if (hldNav.symbol) parts.push(`<span class="crumb-sep">/</span>
|
|
366
|
+
<span class="qn-mono">${esc(shortQn(hldNav.symbol))}</span>`);
|
|
367
|
+
crumb.innerHTML = parts.join(' ');
|
|
368
|
+
crumb.querySelectorAll('[data-jump]').forEach(el => {
|
|
369
|
+
el.onclick = () => {
|
|
370
|
+
if (el.dataset.jump === 'root') { hldNav.layer = hldNav.module = hldNav.symbol = null; }
|
|
371
|
+
else if (el.dataset.jump === 'layer') { hldNav.module = hldNav.symbol = null; }
|
|
372
|
+
else if (el.dataset.jump === 'module') { hldNav.symbol = null; }
|
|
373
|
+
hldRenderNav();
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ---- Detail panel + focus graph (only when a symbol is selected)
|
|
378
|
+
const focus = document.getElementById('hld-focus');
|
|
379
|
+
if (hldNav.symbol) {
|
|
380
|
+
const mod = (hld.modules || {})[hldNav.module];
|
|
381
|
+
const sym = mod && (mod.symbols || []).find(s => s.qualname === hldNav.symbol);
|
|
382
|
+
if (sym) detail.innerHTML = symbolDetailHtml(sym, mod);
|
|
383
|
+
detail.querySelectorAll('[data-jumpqn]').forEach(el => {
|
|
384
|
+
el.onclick = () => jumpToQualname(el.dataset.jumpqn);
|
|
385
|
+
});
|
|
386
|
+
if (sym) drawFocusGraph(focus, sym);
|
|
387
|
+
} else {
|
|
388
|
+
detail.innerHTML = '';
|
|
389
|
+
if (focus) { focus.style.display = 'none'; focus.innerHTML = ''; }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
lucide.createIcons();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function symbolDetailHtml(sym, mod) {
|
|
396
|
+
const callRow = qn => `<div class="call-row" data-jumpqn="${esc(qn)}">
|
|
397
|
+
<i data-lucide="arrow-right" class="hld-ico"></i>
|
|
398
|
+
<span class="qn-mono">${formatQn(qn, {maxParts: 3})}</span></div>`;
|
|
399
|
+
const callerRow = qn => `<div class="call-row" data-jumpqn="${esc(qn)}">
|
|
400
|
+
<i data-lucide="arrow-left" class="hld-ico"></i>
|
|
401
|
+
<span class="qn-mono">${formatQn(qn, {maxParts: 3})}</span></div>`;
|
|
402
|
+
|
|
403
|
+
return `
|
|
404
|
+
<div class="hld-detail-head">
|
|
405
|
+
<div class="flex items-start gap-3 min-w-0 flex-1">
|
|
406
|
+
<i data-lucide="${kindIcon(sym.kind)}" class="hld-ico" style="color:${kindColor(sym.kind)};margin-top:6px"></i>
|
|
407
|
+
<div class="min-w-0">
|
|
408
|
+
<div class="hld-detail-title qn-mono">${formatQn(sym.qualname, {maxParts: 5})}</div>
|
|
409
|
+
<div class="hld-detail-meta">
|
|
410
|
+
<span class="pill">${sym.kind}</span>
|
|
411
|
+
<span class="pill">L${sym.line || '?'}</span>
|
|
412
|
+
<span class="pill pill-cool" title="fan-in">in: ${sym.fan_in}</span>
|
|
413
|
+
<span class="pill pill-warm" title="fan-out">out: ${sym.fan_out}</span>
|
|
414
|
+
<span class="text-[11px] text-app-2">${esc(mod ? mod.file : '')}</span>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
|
|
420
|
+
<div>
|
|
421
|
+
<div class="hld-col-h flex items-center gap-1.5"><i data-lucide="arrow-left" class="w-3.5 h-3.5"></i>Called by (${sym.fan_in})</div>
|
|
422
|
+
${(sym.callers && sym.callers.length)
|
|
423
|
+
? sym.callers.map(callerRow).join('')
|
|
424
|
+
: '<div class="hld-empty">No callers in graph.</div>'}
|
|
425
|
+
</div>
|
|
426
|
+
<div>
|
|
427
|
+
<div class="hld-col-h flex items-center gap-1.5"><i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>Calls (${sym.fan_out})</div>
|
|
428
|
+
${(sym.callees && sym.callees.length)
|
|
429
|
+
? sym.callees.map(callRow).join('')
|
|
430
|
+
: '<div class="hld-empty">Calls nothing tracked.</div>'}
|
|
431
|
+
</div>
|
|
432
|
+
</div>`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function jumpToQualname(qn) {
|
|
436
|
+
// Find the module that owns this qualname (longest prefix match) and select it.
|
|
437
|
+
const mods = (state.data.hld.modules || {});
|
|
438
|
+
const candidates = Object.keys(mods).filter(mq => qn === mq || qn.startsWith(mq + '.'));
|
|
439
|
+
if (!candidates.length) return;
|
|
440
|
+
const mqn = candidates.sort((a, b) => b.length - a.length)[0];
|
|
441
|
+
const mod = mods[mqn];
|
|
442
|
+
hldNav.layer = mod.layer;
|
|
443
|
+
hldNav.module = mqn;
|
|
444
|
+
hldNav.symbol = (mod.symbols || []).some(s => s.qualname === qn) ? qn : null;
|
|
445
|
+
hldRenderNav();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function layerTitle(id) {
|
|
449
|
+
const L = (state.data.hld.layers || []).find(x => x.id === id);
|
|
450
|
+
return L ? L.title : id;
|
|
451
|
+
}
|
|
452
|
+
function kindIcon(k) {
|
|
453
|
+
return k === 'CLASS' ? 'box' : k === 'METHOD' ? 'corner-down-right' : 'function-square';
|
|
454
|
+
}
|
|
455
|
+
function kindColor(k) {
|
|
456
|
+
return k === 'CLASS' ? 'var(--accent-violet)'
|
|
457
|
+
: k === 'METHOD' ? 'var(--accent-cyan)'
|
|
458
|
+
: 'var(--accent-emerald)';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* Radial focus graph for the selected symbol. Center = symbol; left arc =
|
|
462
|
+
callers; right arc = callees. Edges are dashed and animated (CSS) to give
|
|
463
|
+
a sense of data flowing inward / outward. Click any node to jump. */
|
|
464
|
+
function drawFocusGraph(svg, sym) {
|
|
465
|
+
if (!svg) return;
|
|
466
|
+
const callers = (sym.callers || []).slice(0, 8);
|
|
467
|
+
const callees = (sym.callees || []).slice(0, 8);
|
|
468
|
+
if (!callers.length && !callees.length) {
|
|
469
|
+
svg.style.display = 'none'; svg.innerHTML = ''; return;
|
|
470
|
+
}
|
|
471
|
+
svg.style.display = 'block';
|
|
472
|
+
d3.select(svg).selectAll('*').remove();
|
|
473
|
+
|
|
474
|
+
const W = svg.parentElement.clientWidth - 4;
|
|
475
|
+
const H = 320;
|
|
476
|
+
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
477
|
+
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
478
|
+
svg.setAttribute('width', W); svg.setAttribute('height', H);
|
|
479
|
+
|
|
480
|
+
const cx = W / 2, cy = H / 2;
|
|
481
|
+
const R = Math.min(H * 0.42, W * 0.32);
|
|
482
|
+
|
|
483
|
+
const arcPositions = (n, side) => {
|
|
484
|
+
if (n === 0) return [];
|
|
485
|
+
const span = Math.min(Math.PI * 0.85, 0.5 + n * 0.18);
|
|
486
|
+
const start = side === 'left' ? Math.PI - span / 2 : -span / 2;
|
|
487
|
+
return d3.range(n).map(i => {
|
|
488
|
+
const t = n === 1 ? 0.5 : i / (n - 1);
|
|
489
|
+
const a = start + t * span;
|
|
490
|
+
return [cx + R * Math.cos(a), cy + R * Math.sin(a)];
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const left = arcPositions(callers.length, 'left');
|
|
495
|
+
const right = arcPositions(callees.length, 'right');
|
|
496
|
+
|
|
497
|
+
const root = d3.select(svg);
|
|
498
|
+
const g = root.append('g');
|
|
499
|
+
|
|
500
|
+
// Edges (callers → center, center → callees). dashoffset CSS animation.
|
|
501
|
+
callers.forEach((qn, i) => {
|
|
502
|
+
const [x, y] = left[i];
|
|
503
|
+
g.append('path')
|
|
504
|
+
.attr('class', 'focus-edge focus-in')
|
|
505
|
+
.attr('d', `M${x},${y} Q${(x+cx)/2},${(y+cy)/2 - 18} ${cx},${cy}`);
|
|
506
|
+
});
|
|
507
|
+
callees.forEach((qn, i) => {
|
|
508
|
+
const [x, y] = right[i];
|
|
509
|
+
g.append('path')
|
|
510
|
+
.attr('class', 'focus-edge focus-out')
|
|
511
|
+
.attr('d', `M${cx},${cy} Q${(cx+x)/2},${(cy+y)/2 - 18} ${x},${y}`);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Caller / callee nodes.
|
|
515
|
+
const node = (qn, x, y, side) => {
|
|
516
|
+
const grp = g.append('g')
|
|
517
|
+
.attr('class', 'focus-node')
|
|
518
|
+
.attr('transform', `translate(${x},${y})`)
|
|
519
|
+
.style('cursor', 'pointer')
|
|
520
|
+
.on('click', () => jumpToQualname(qn));
|
|
521
|
+
grp.append('circle').attr('r', 8)
|
|
522
|
+
.attr('class', side === 'in' ? 'focus-dot focus-dot-in' : 'focus-dot focus-dot-out');
|
|
523
|
+
const label = shortQn(qn);
|
|
524
|
+
grp.append('text')
|
|
525
|
+
.attr('y', 22).attr('text-anchor', 'middle')
|
|
526
|
+
.attr('class', 'focus-label')
|
|
527
|
+
.text(label.length > 26 ? label.slice(0, 25) + '…' : label);
|
|
528
|
+
};
|
|
529
|
+
callers.forEach((qn, i) => node(qn, left[i][0], left[i][1], 'in'));
|
|
530
|
+
callees.forEach((qn, i) => node(qn, right[i][0], right[i][1], 'out'));
|
|
531
|
+
|
|
532
|
+
// Center node.
|
|
533
|
+
const center = g.append('g').attr('transform', `translate(${cx},${cy})`);
|
|
534
|
+
center.append('circle').attr('r', 22).attr('class', 'focus-core');
|
|
535
|
+
center.append('circle').attr('r', 28).attr('class', 'focus-core-ring');
|
|
536
|
+
center.append('text').attr('y', 5).attr('text-anchor', 'middle')
|
|
537
|
+
.attr('class', 'focus-core-label')
|
|
538
|
+
.text(shortQn(sym.qualname).slice(0, 18));
|
|
539
|
+
|
|
540
|
+
// Side captions.
|
|
541
|
+
if (callers.length) {
|
|
542
|
+
g.append('text').attr('x', 16).attr('y', 22)
|
|
543
|
+
.attr('class', 'focus-caption')
|
|
544
|
+
.text(`called by · ${sym.fan_in}`);
|
|
545
|
+
}
|
|
546
|
+
if (callees.length) {
|
|
547
|
+
g.append('text').attr('x', W - 16).attr('y', 22)
|
|
548
|
+
.attr('text-anchor', 'end')
|
|
549
|
+
.attr('class', 'focus-caption')
|
|
550
|
+
.text(`calls · ${sym.fan_out}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---------- Flows ----------
|
|
555
|
+
function renderFlows(host) {
|
|
556
|
+
const flows = state.data.flows;
|
|
557
|
+
if (!flows.length) {
|
|
558
|
+
host.innerHTML = `<div class="p-8 text-ink-200">No call chains found.</div>`;
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (state.flowSel >= flows.length) state.flowSel = 0;
|
|
562
|
+
host.innerHTML = `
|
|
563
|
+
<div class="p-8 max-w-7xl mx-auto">
|
|
564
|
+
<div class="help-card mb-6">
|
|
565
|
+
<i data-lucide="git-fork" class="icon w-4 h-4"></i>
|
|
566
|
+
<div><b>Call flow inspector.</b> Pick an entry point on the left to see its real downstream call tree (BFS depth 4).
|
|
567
|
+
Highlighted node = entry; arrows = CALLS edges from the actual graph.</div>
|
|
568
|
+
</div>
|
|
569
|
+
<div class="grid grid-cols-[300px_1fr] gap-4">
|
|
570
|
+
<div class="panel p-3 max-h-[78vh] overflow-y-auto">
|
|
571
|
+
<div class="search-wrap mb-3">
|
|
572
|
+
<i data-lucide="search"></i>
|
|
573
|
+
<input class="search" id="flow-search" placeholder="Filter entry points…">
|
|
574
|
+
</div>
|
|
575
|
+
<div id="flow-list" class="space-y-1"></div>
|
|
576
|
+
</div>
|
|
577
|
+
<div class="panel p-5 min-h-[600px]">
|
|
578
|
+
<div class="section-h"><h2 id="flow-title">Flow</h2>
|
|
579
|
+
<span class="pill" id="flow-meta"></span></div>
|
|
580
|
+
<div class="mermaid-host" id="flow-canvas"></div>
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
583
|
+
</div>`;
|
|
584
|
+
const list = document.getElementById('flow-list');
|
|
585
|
+
flows.forEach((f, i) => {
|
|
586
|
+
const el = document.createElement('div');
|
|
587
|
+
el.className = 'flow-item';
|
|
588
|
+
el.innerHTML = `<div class="qn">${formatQn(f.qualname, {maxParts: 3})}</div>
|
|
589
|
+
<div class="meta"><i data-lucide="zap" style="width:11px;height:11px"></i>
|
|
590
|
+
${esc(f.reason)} <span class="text-ink-300">· ${esc(f.file)}</span></div>`;
|
|
591
|
+
el.onclick = () => selectFlow(i);
|
|
592
|
+
list.appendChild(el);
|
|
593
|
+
});
|
|
594
|
+
document.getElementById('flow-search').addEventListener('input', e => {
|
|
595
|
+
const q = e.target.value.toLowerCase();
|
|
596
|
+
[...list.children].forEach((el, i) => {
|
|
597
|
+
el.style.display = JSON.stringify(flows[i]).toLowerCase().includes(q) ? '' : 'none';
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
selectFlow(state.flowSel);
|
|
601
|
+
}
|
|
602
|
+
function shortQn(qn) {
|
|
603
|
+
const parts = String(qn).split('.');
|
|
604
|
+
return parts.length > 3 ? '…' + parts.slice(-3).join('.') : qn;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/* HTML version of shortQn that dims the parent path and highlights the leaf.
|
|
608
|
+
Returns sanitized markup. */
|
|
609
|
+
function formatQn(qn, opts) {
|
|
610
|
+
const max = (opts && opts.maxParts) || 3;
|
|
611
|
+
const parts = String(qn ?? '').split('.');
|
|
612
|
+
if (!parts.length) return '';
|
|
613
|
+
const leaf = parts[parts.length - 1];
|
|
614
|
+
const headParts = parts.slice(0, -1);
|
|
615
|
+
const truncated = headParts.length > max - 1;
|
|
616
|
+
const visibleHead = truncated ? headParts.slice(-(max - 1)) : headParts;
|
|
617
|
+
const prefix = truncated ? '…' : '';
|
|
618
|
+
const head = visibleHead.length
|
|
619
|
+
? `<span class="qn-dim">${prefix}${esc(visibleHead.join('.'))}.</span>`
|
|
620
|
+
: (truncated ? `<span class="qn-dim">${prefix}</span>` : '');
|
|
621
|
+
return `${head}<span class="qn-key">${esc(leaf)}</span>`;
|
|
622
|
+
}
|
|
623
|
+
function selectFlow(i) {
|
|
624
|
+
state.flowSel = i;
|
|
625
|
+
const flow = state.data.flows[i];
|
|
626
|
+
if (!flow) return;
|
|
627
|
+
document.querySelectorAll('.flow-item').forEach((el, j) =>
|
|
628
|
+
el.classList.toggle('active', j === i));
|
|
629
|
+
document.getElementById('flow-title').innerHTML = formatQn(flow.qualname, {maxParts: 4});
|
|
630
|
+
document.getElementById('flow-title').classList.add('qn-mono');
|
|
631
|
+
document.getElementById('flow-meta').textContent = flow.reason;
|
|
632
|
+
const canvas = document.getElementById('flow-canvas');
|
|
633
|
+
canvas.innerHTML = `<pre class="mermaid">${esc(flow.mermaid)}</pre>`;
|
|
634
|
+
mermaid.run({ nodes: canvas.querySelectorAll('.mermaid') });
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ---------- Matrix ----------
|
|
638
|
+
function renderMatrix(host) {
|
|
639
|
+
const m = state.data.matrix;
|
|
640
|
+
if (!m.modules.length) {
|
|
641
|
+
host.innerHTML = `<div class="p-8 text-ink-200">No cross-module CALLS recorded.</div>`;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const max = m.max || 1;
|
|
645
|
+
const colour = v => {
|
|
646
|
+
if (!v) return 'transparent';
|
|
647
|
+
const t = v / max;
|
|
648
|
+
// Cool indigo -> violet -> warm rose for heat.
|
|
649
|
+
const stops = [
|
|
650
|
+
[42, 57, 87], // ink-500
|
|
651
|
+
[99, 102, 241], // brand-600
|
|
652
|
+
[167, 139, 250], // accent-violet
|
|
653
|
+
[248, 113, 113], // accent-rose
|
|
654
|
+
];
|
|
655
|
+
const seg = Math.min(stops.length - 2, Math.floor(t * (stops.length - 1)));
|
|
656
|
+
const lt = (t * (stops.length - 1)) - seg;
|
|
657
|
+
const a = stops[seg], b = stops[seg + 1];
|
|
658
|
+
const r = Math.round(a[0] + lt * (b[0] - a[0]));
|
|
659
|
+
const g = Math.round(a[1] + lt * (b[1] - a[1]));
|
|
660
|
+
const bl= Math.round(a[2] + lt * (b[2] - a[2]));
|
|
661
|
+
return `rgb(${r},${g},${bl})`;
|
|
662
|
+
};
|
|
663
|
+
let html = `<div class="p-8 max-w-7xl mx-auto">
|
|
664
|
+
<div class="help-card mb-6">
|
|
665
|
+
<i data-lucide="grid-3x3" class="icon w-4 h-4"></i>
|
|
666
|
+
<div><b>Module call matrix.</b> Each row is a caller, each column a callee. Cell color and number = number of calls.
|
|
667
|
+
Rotate your head 45° to read column labels - or hover any cell for the exact pair.</div>
|
|
668
|
+
</div>
|
|
669
|
+
<div class="panel p-4">
|
|
670
|
+
<div class="matrix-wrap"><table class="matrix"><thead><tr><th class="corner"></th>`;
|
|
671
|
+
m.modules.forEach(mod => {
|
|
672
|
+
html += `<th title="${esc(mod.qualname)}">${esc(mod.name)}</th>`;
|
|
673
|
+
});
|
|
674
|
+
html += `</tr></thead><tbody>`;
|
|
675
|
+
m.modules.forEach((row, i) => {
|
|
676
|
+
html += `<tr><th title="${esc(row.qualname)}">${esc(row.qualname)}</th>`;
|
|
677
|
+
m.counts[i].forEach((v, j) => {
|
|
678
|
+
const tip = v ? `<b>${esc(row.name)}</b> -> <b>${esc(m.modules[j].name)}</b><br>${v} call${v===1?'':'s'}` : '';
|
|
679
|
+
html += `<td class="cell" data-tip="${tip}" style="background:${colour(v)}">${v || ''}</td>`;
|
|
680
|
+
});
|
|
681
|
+
html += `</tr>`;
|
|
682
|
+
});
|
|
683
|
+
html += `</tbody></table></div>
|
|
684
|
+
<div class="flex items-center gap-3 mt-4 text-[11px] text-ink-200">
|
|
685
|
+
<span>0</span>
|
|
686
|
+
<div class="h-2.5 w-48 rounded-full" style="background:linear-gradient(90deg,#2a3957,#6366f1,#a78bfa,#f87171)"></div>
|
|
687
|
+
<span class="font-mono">${max}</span>
|
|
688
|
+
</div></div></div>`;
|
|
689
|
+
host.innerHTML = html;
|
|
690
|
+
host.querySelectorAll('td.cell').forEach(c => {
|
|
691
|
+
c.addEventListener('mousemove', e => {
|
|
692
|
+
const t = e.target.dataset.tip;
|
|
693
|
+
if (t) showTip(t, e.clientX, e.clientY);
|
|
694
|
+
});
|
|
695
|
+
c.addEventListener('mouseleave', hideTip);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ---------- Sankey ----------
|
|
700
|
+
function renderSankey(host) {
|
|
701
|
+
const data = state.data.sankey;
|
|
702
|
+
host.innerHTML = `<div class="p-8 max-w-7xl mx-auto">
|
|
703
|
+
<div class="help-card mb-6">
|
|
704
|
+
<i data-lucide="waves" class="icon w-4 h-4"></i>
|
|
705
|
+
<div><b>Inter-module call flows.</b> Width of each ribbon = number of calls between two modules.
|
|
706
|
+
Hover anything for exact counts.</div>
|
|
707
|
+
</div>
|
|
708
|
+
<div class="panel p-5">
|
|
709
|
+
<div class="section-h"><h2>Top call flows</h2><span class="text-[11px] text-ink-200">${data.links.length} flows</span></div>
|
|
710
|
+
${data.links.length ? '<svg id="sankey" class="w-full" style="height:680px"></svg>'
|
|
711
|
+
: '<div class="text-ink-200 p-12 text-center">No cross-module flows yet.</div>'}
|
|
712
|
+
</div></div>`;
|
|
713
|
+
if (!data.links.length) return;
|
|
714
|
+
const svg = d3.select('#sankey');
|
|
715
|
+
const { width, height } = svg.node().getBoundingClientRect();
|
|
716
|
+
const sk = d3.sankey().nodeWidth(14).nodePadding(10)
|
|
717
|
+
.extent([[6, 6], [width - 6, height - 6]]);
|
|
718
|
+
const g = sk({
|
|
719
|
+
nodes: data.nodes.map(d => ({...d})),
|
|
720
|
+
links: data.links.map(d => ({...d})),
|
|
721
|
+
});
|
|
722
|
+
const colour = d3.scaleOrdinal()
|
|
723
|
+
.range(['#818cf8','#22d3ee','#34d399','#fbbf24','#f87171','#a78bfa','#fb923c']);
|
|
724
|
+
svg.append('g').selectAll('rect').data(g.nodes).join('rect')
|
|
725
|
+
.attr('x', d => d.x0).attr('y', d => d.y0)
|
|
726
|
+
.attr('height', d => d.y1 - d.y0).attr('width', d => d.x1 - d.x0)
|
|
727
|
+
.attr('fill', d => colour(d.package || d.name))
|
|
728
|
+
.attr('rx', 2)
|
|
729
|
+
.on('mousemove', (e, d) => showTip(`<b>${esc(d.qualname)}</b><br>value: ${Math.round(d.value)}`, e.clientX, e.clientY))
|
|
730
|
+
.on('mouseleave', hideTip);
|
|
731
|
+
svg.append('g').attr('fill', 'none').selectAll('path').data(g.links).join('path')
|
|
732
|
+
.attr('d', d3.sankeyLinkHorizontal())
|
|
733
|
+
.attr('stroke', d => colour(d.source.package || d.source.name))
|
|
734
|
+
.attr('stroke-width', d => Math.max(1, d.width))
|
|
735
|
+
.attr('stroke-opacity', 0.4)
|
|
736
|
+
.on('mousemove', (e, d) => showTip(
|
|
737
|
+
`${esc(d.source.qualname)} → ${esc(d.target.qualname)}<br>${d.value} call(s)`,
|
|
738
|
+
e.clientX, e.clientY))
|
|
739
|
+
.on('mouseleave', hideTip);
|
|
740
|
+
svg.append('g').attr('class', 'd3-label').selectAll('text').data(g.nodes).join('text')
|
|
741
|
+
.attr('x', d => d.x0 < width / 2 ? d.x1 + 8 : d.x0 - 8)
|
|
742
|
+
.attr('y', d => (d.y1 + d.y0) / 2).attr('dy', '0.35em')
|
|
743
|
+
.attr('text-anchor', d => d.x0 < width / 2 ? 'start' : 'end')
|
|
744
|
+
.text(d => d.name);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ---------- Treemap ----------
|
|
748
|
+
function renderTreemap(host) {
|
|
749
|
+
host.innerHTML = `<div class="p-8 max-w-7xl mx-auto">
|
|
750
|
+
<div class="help-card mb-6">
|
|
751
|
+
<i data-lucide="square-stack" class="icon w-4 h-4"></i>
|
|
752
|
+
<div><b>Codebase footprint.</b> Each rectangle = one module. Area = LOC; brighter color = higher hotspot score.
|
|
753
|
+
Hover any cell for full details.</div>
|
|
754
|
+
</div>
|
|
755
|
+
<div class="panel p-5">
|
|
756
|
+
<div class="section-h"><h2>LOC landscape</h2></div>
|
|
757
|
+
<svg id="treemap" class="w-full" style="height:720px"></svg>
|
|
758
|
+
</div></div>`;
|
|
759
|
+
const root = d3.hierarchy(state.data.treemap)
|
|
760
|
+
.sum(d => d.value || 0).sort((a, b) => b.value - a.value);
|
|
761
|
+
const svg = d3.select('#treemap');
|
|
762
|
+
const { width, height } = svg.node().getBoundingClientRect();
|
|
763
|
+
d3.treemap().size([width, height]).paddingInner(3).paddingTop(22).round(true)(root);
|
|
764
|
+
const maxScore = d3.max(root.leaves(), d => d.data.score) || 1;
|
|
765
|
+
const colour = d3.scaleSequential([0, maxScore], d3.interpolateInferno);
|
|
766
|
+
|
|
767
|
+
const pkg = svg.append('g').selectAll('g')
|
|
768
|
+
.data(root.descendants().filter(d => d.depth === 1))
|
|
769
|
+
.join('g').attr('transform', d => `translate(${d.x0},${d.y0})`);
|
|
770
|
+
pkg.append('rect').attr('width', d => d.x1 - d.x0).attr('height', d => d.y1 - d.y0)
|
|
771
|
+
.attr('fill', '#131c2e').attr('stroke', '#243049').attr('rx', 4);
|
|
772
|
+
pkg.append('text').attr('x', 8).attr('y', 14).attr('fill', '#cbd5e1')
|
|
773
|
+
.style('font-size', '11px').style('font-weight', '600').text(d => d.data.name);
|
|
774
|
+
|
|
775
|
+
const leaf = svg.append('g').selectAll('g').data(root.leaves())
|
|
776
|
+
.join('g').attr('transform', d => `translate(${d.x0},${d.y0})`);
|
|
777
|
+
leaf.append('rect').attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
778
|
+
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
779
|
+
.attr('fill', d => d.data.score ? colour(d.data.score) : '#243049')
|
|
780
|
+
.attr('stroke', '#0b1220').attr('stroke-width', 1).attr('rx', 2)
|
|
781
|
+
.style('cursor', 'pointer')
|
|
782
|
+
.on('mousemove', (e, d) => showTip(
|
|
783
|
+
`<b>${esc(d.data.name)}</b><br>${esc(d.data.file)}<br>` +
|
|
784
|
+
`LOC: ${d.data.value} · symbols: ${d.data.symbols} · score: ${d.data.score}`,
|
|
785
|
+
e.clientX, e.clientY))
|
|
786
|
+
.on('mouseleave', hideTip);
|
|
787
|
+
leaf.append('text').attr('x', 6).attr('y', 14).attr('fill', '#fff')
|
|
788
|
+
.style('font-size', '10.5px').style('font-weight', '500')
|
|
789
|
+
.style('pointer-events', 'none')
|
|
790
|
+
.text(d => {
|
|
791
|
+
const w = d.x1 - d.x0, h = d.y1 - d.y0;
|
|
792
|
+
if (w < 60 || h < 22) return '';
|
|
793
|
+
const name = d.data.name.split('.').pop();
|
|
794
|
+
return name.length * 6 > w - 12 ? name.slice(0, Math.floor((w-12)/6)) + '…' : name;
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ---------- Architecture (links to pyvis) ----------
|
|
799
|
+
function pyvisHref(path) {
|
|
800
|
+
const t = document.documentElement.classList.contains('theme-light') ? 'light' : 'dark';
|
|
801
|
+
return path + '?theme=' + t;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function renderExplorers(host) {
|
|
805
|
+
const tile = (href, title, desc, icon) => `
|
|
806
|
+
<a href="${href}" target="_blank" rel="noopener" class="panel p-5 block hover:border-brand-500 transition group">
|
|
807
|
+
<div class="flex items-start gap-3">
|
|
808
|
+
<div class="w-10 h-10 rounded-lg bg-app-3 flex items-center justify-center text-brand-500 group-hover:bg-brand-600 group-hover:text-white transition">
|
|
809
|
+
<i data-lucide="${icon}" class="w-5 h-5"></i>
|
|
810
|
+
</div>
|
|
811
|
+
<div>
|
|
812
|
+
<div class="font-semibold text-[15px]">${title}</div>
|
|
813
|
+
<div class="text-[12px] text-app-2 mt-1 leading-relaxed">${desc}</div>
|
|
814
|
+
</div>
|
|
815
|
+
</div>
|
|
816
|
+
</a>`;
|
|
817
|
+
host.innerHTML = `<div class="p-8 max-w-6xl mx-auto">
|
|
818
|
+
<div class="help-card mb-6">
|
|
819
|
+
<i data-lucide="compass" class="icon w-4 h-4"></i>
|
|
820
|
+
<div><b>Interactive node-link explorers.</b> Force-directed graphs powered by pyvis with in-page search and filtering. Best for hands-on exploration.</div>
|
|
821
|
+
</div>
|
|
822
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
823
|
+
${tile(pyvisHref('/architecture.html'), 'Architecture', 'One node per module, edges aggregated by kind. Best high-level node-link view.', 'network')}
|
|
824
|
+
${tile(pyvisHref('/callgraph.html'), 'Call graph', 'Every function and method, sized by fan-in. Use the filter menu to narrow.', 'workflow')}
|
|
825
|
+
${tile(pyvisHref('/inheritance.html'), 'Inheritance', 'Classes only. INHERITS / IMPLEMENTS edges drawn.', 'git-branch')}
|
|
826
|
+
</div></div>`;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ---------- Files ----------
|
|
830
|
+
function renderFiles(host) {
|
|
831
|
+
const files = state.data.files;
|
|
832
|
+
const rows = files.map(f => {
|
|
833
|
+
const slug = f.file.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_|_$/g, '') || 'file';
|
|
834
|
+
return `<tr>
|
|
835
|
+
<td><a class="link" href="${pyvisHref('/files/' + slug + '.html')}" target="_blank" rel="noopener"><code>${esc(f.file)}</code></a></td>
|
|
836
|
+
<td class="text-app-2">${esc(f.language)}</td>
|
|
837
|
+
<td class="num"><span class="pill">${f.symbols}</span></td>
|
|
838
|
+
</tr>`;
|
|
839
|
+
}).join('');
|
|
840
|
+
host.innerHTML = `<div class="p-8 max-w-6xl mx-auto">
|
|
841
|
+
<div class="help-card mb-6">
|
|
842
|
+
<i data-lucide="folder-tree" class="icon w-4 h-4"></i>
|
|
843
|
+
<div><b>Per-file pyvis pages.</b> Click any file to see its symbols + 1-hop neighbours.</div>
|
|
844
|
+
</div>
|
|
845
|
+
<div class="panel p-5">
|
|
846
|
+
<div class="section-h"><h2>Files (${files.length})</h2></div>
|
|
847
|
+
<table class="data"><thead><tr><th>Path</th><th>Language</th><th class="num">Symbols</th></tr></thead>
|
|
848
|
+
<tbody>${rows}</tbody></table>
|
|
849
|
+
</div></div>`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ---------- Graph3D shim ----------
|
|
853
|
+
// Delegates to window.renderGraph3d (defined in views/graph3d.js). Kept as a
|
|
854
|
+
// named function so VIEW_RENDERERS can reference it without a hoist trap.
|
|
855
|
+
function renderGraph3dShim(host) {
|
|
856
|
+
if (typeof window.renderGraph3d === 'function') return window.renderGraph3d(host);
|
|
857
|
+
const msg = document.createElement('div');
|
|
858
|
+
msg.className = 'p-8 text-ink-200';
|
|
859
|
+
msg.textContent = '3D view module failed to load.';
|
|
860
|
+
host.appendChild(msg);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// ---------- esc ----------
|
|
864
|
+
function esc(s) {
|
|
865
|
+
return String(s ?? '').replace(/[&<>"']/g, c =>
|
|
866
|
+
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ---------- Bootstrap ----------
|
|
870
|
+
async function load() {
|
|
871
|
+
const r = await fetch('/api/data.json');
|
|
872
|
+
state.data = await r.json();
|
|
873
|
+
document.getElementById('repo-name').textContent = state.data.repo || 'graph';
|
|
874
|
+
document.getElementById('last-built').textContent = state.data.built_at
|
|
875
|
+
? 'built ' + state.data.built_at : '';
|
|
876
|
+
setHeaderStats();
|
|
877
|
+
buildNav();
|
|
878
|
+
const hash = (location.hash || '#overview').slice(1);
|
|
879
|
+
activate(VIEWS.find(v => v.id === hash) ? hash : 'overview');
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
document.getElementById('sb-toggle').addEventListener('click', () => {
|
|
883
|
+
const collapsed = document.documentElement.classList.toggle('sb-collapsed');
|
|
884
|
+
try { localStorage.setItem('cg-sb', collapsed ? 'collapsed' : 'expanded'); } catch (e) {}
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
document.getElementById('theme-toggle').addEventListener('click', () => {
|
|
888
|
+
const light = document.documentElement.classList.toggle('theme-light');
|
|
889
|
+
try { localStorage.setItem('cg-theme', light ? 'light' : 'dark'); } catch (e) {}
|
|
890
|
+
// Re-init mermaid with new theme + re-render current view so SVGs redraw.
|
|
891
|
+
initMermaid();
|
|
892
|
+
if (state.data) render(state.view);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
document.getElementById('rebuild-btn').addEventListener('click', async (e) => {
|
|
896
|
+
const btn = e.currentTarget;
|
|
897
|
+
btn.disabled = true;
|
|
898
|
+
btn.innerHTML = '<div class="spinner"></div><span>Rebuilding…</span>';
|
|
899
|
+
try {
|
|
900
|
+
const r = await fetch('/api/rebuild', { method: 'POST' });
|
|
901
|
+
if (!r.ok) throw new Error('rebuild failed');
|
|
902
|
+
await load();
|
|
903
|
+
render(state.view);
|
|
904
|
+
toast('Rebuilt', 'success');
|
|
905
|
+
} catch (err) {
|
|
906
|
+
toast('Rebuild failed: ' + err.message, 'error');
|
|
907
|
+
} finally {
|
|
908
|
+
btn.disabled = false;
|
|
909
|
+
btn.innerHTML = '<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i><span>Rebuild</span>';
|
|
910
|
+
lucide.createIcons();
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
load().catch(err => {
|
|
915
|
+
document.getElementById('view-host').innerHTML =
|
|
916
|
+
`<div class="p-8"><div class="help-card"><i data-lucide="alert-triangle" class="icon w-4 h-4"></i>
|
|
917
|
+
<div><b>Failed to load data.</b> ${esc(err.message)}</div></div></div>`;
|
|
918
|
+
lucide.createIcons();
|
|
919
|
+
});
|