+
+
+
+
+
+
+
+
+
diff --git a/codegraph/web/static/ui/helpers.js b/codegraph/web/static/ui/helpers.js
new file mode 100644
index 0000000..5f5c035
--- /dev/null
+++ b/codegraph/web/static/ui/helpers.js
@@ -0,0 +1,101 @@
+/* codegraph dashboard - shared UI helpers */
+'use strict';
+
+// Global namespaces used by all view scripts.
+window.CGUI = window.CGUI || {};
+window.CGViews = window.CGViews || {};
+
+// ---- esc ----
+CGUI.esc = function esc(s) {
+ return String(s ?? '').replace(/[&<>"']/g, c =>
+ ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+};
+
+// ---- shortQn ----
+CGUI.shortQn = function shortQn(qn) {
+ const parts = String(qn).split('.');
+ return parts.length > 3 ? '…' + parts.slice(-3).join('.') : qn;
+};
+
+/* HTML version of shortQn that dims the parent path and highlights the leaf.
+ Returns sanitized markup. */
+CGUI.formatQn = function formatQn(qn, opts) {
+ const esc = CGUI.esc;
+ const max = (opts && opts.maxParts) || 3;
+ const parts = String(qn ?? '').split('.');
+ if (!parts.length) return '';
+ const leaf = parts[parts.length - 1];
+ const headParts = parts.slice(0, -1);
+ const truncated = headParts.length > max - 1;
+ const visibleHead = truncated ? headParts.slice(-(max - 1)) : headParts;
+ const prefix = truncated ? '…' : '';
+ const head = visibleHead.length
+ ? `${prefix}${esc(visibleHead.join('.'))}. `
+ : (truncated ? `${prefix} ` : '');
+ return `${head}${esc(leaf)} `;
+};
+
+// ---- Tooltip ----
+CGUI.showTip = function showTip(html, x, y) {
+ const tt = document.getElementById('tooltip');
+ tt.innerHTML = html;
+ const r = tt.getBoundingClientRect();
+ let lx = x + 14, ly = y + 14;
+ if (lx + r.width > innerWidth - 8) lx = x - r.width - 14;
+ if (ly + r.height > innerHeight - 8) ly = y - r.height - 14;
+ tt.style.left = lx + 'px';
+ tt.style.top = ly + 'px';
+ tt.style.opacity = '1';
+};
+CGUI.hideTip = function hideTip() {
+ document.getElementById('tooltip').style.opacity = '0';
+};
+
+// ---- Toast ----
+CGUI.toast = function toast(msg, kind) {
+ const host = document.getElementById('toast-host');
+ const el = document.createElement('div');
+ el.className = 'toast ' + (kind || '');
+ el.textContent = msg;
+ host.appendChild(el);
+ setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity 0.3s';
+ setTimeout(() => el.remove(), 350); }, 2400);
+};
+
+// ---- Mermaid theme helpers ----
+CGUI.mermaidThemeVars = function mermaidThemeVars() {
+ const light = document.documentElement.classList.contains('theme-light');
+ return light
+ ? { fontFamily: 'Inter, system-ui, sans-serif', fontSize: '14px',
+ background: 'transparent',
+ primaryColor: '#eef2ff', primaryTextColor: '#0f172a',
+ primaryBorderColor: '#a5b4fc', lineColor: '#6366f1',
+ secondaryColor: '#f5f3ff', tertiaryColor: '#ffffff',
+ clusterBkg: 'rgba(238,242,255,0.7)', clusterBorder: '#a5b4fc',
+ nodeBorder: '#a5b4fc', mainBkg: '#eef2ff',
+ edgeLabelBackground: '#ffffff', titleColor: '#1e293b' }
+ : { fontFamily: 'Inter, system-ui, sans-serif', fontSize: '14px',
+ background: 'transparent',
+ primaryColor: '#1d2942', primaryTextColor: '#e6ecf5',
+ primaryBorderColor: '#3b4a6a', lineColor: '#5b6b8c',
+ secondaryColor: '#161f33', tertiaryColor: '#0f1626',
+ clusterBkg: 'rgba(15,22,38,0.6)', clusterBorder: '#3b4a6a',
+ nodeBorder: '#3b4a6a', mainBkg: '#1d2942',
+ edgeLabelBackground: '#0a0f1c', titleColor: '#c4cfe2' };
+};
+
+// ---- HLD symbol kind helpers (shared by hld.js) ----
+CGUI.kindIcon = function kindIcon(k) {
+ return k === 'CLASS' ? 'box' : k === 'METHOD' ? 'corner-down-right' : 'function-square';
+};
+CGUI.kindColor = function kindColor(k) {
+ return k === 'CLASS' ? 'var(--accent-violet)'
+ : k === 'METHOD' ? 'var(--accent-cyan)'
+ : 'var(--accent-emerald)';
+};
+
+// ---- pyvisHref (shared by explorers + files views) ----
+CGUI.pyvisHref = function pyvisHref(path) {
+ const t = document.documentElement.classList.contains('theme-light') ? 'light' : 'dark';
+ return path + '?theme=' + t;
+};
diff --git a/codegraph/web/static/views/flows.js b/codegraph/web/static/views/flows.js
new file mode 100644
index 0000000..1d80c12
--- /dev/null
+++ b/codegraph/web/static/views/flows.js
@@ -0,0 +1,68 @@
+/* codegraph dashboard - Flows view */
+'use strict';
+
+CGViews.flows = function renderFlows(host) {
+ const esc = CGUI.esc;
+ const formatQn = CGUI.formatQn;
+ const flows = window.state.data.flows;
+ if (!flows.length) {
+ host.innerHTML = `No call chains found.
`;
+ return;
+ }
+ if (window.state.flowSel >= flows.length) window.state.flowSel = 0;
+ host.innerHTML = `
+
+
+
+
Call flow inspector. Pick an entry point on the left to see its real downstream call tree (BFS depth 4).
+ Highlighted node = entry; arrows = CALLS edges from the actual graph.
+
+
+
`;
+ const list = document.getElementById('flow-list');
+ flows.forEach((f, i) => {
+ const el = document.createElement('div');
+ el.className = 'flow-item';
+ el.innerHTML = `${formatQn(f.qualname, {maxParts: 3})}
+
+ ${esc(f.reason)} · ${esc(f.file)}
`;
+ el.onclick = () => _selectFlow(i);
+ list.appendChild(el);
+ });
+ document.getElementById('flow-search').addEventListener('input', e => {
+ const q = e.target.value.toLowerCase();
+ [...list.children].forEach((el, i) => {
+ el.style.display = JSON.stringify(flows[i]).toLowerCase().includes(q) ? '' : 'none';
+ });
+ });
+ _selectFlow(window.state.flowSel);
+};
+
+function _selectFlow(i) {
+ const esc = CGUI.esc;
+ const formatQn = CGUI.formatQn;
+ window.state.flowSel = i;
+ const flow = window.state.data.flows[i];
+ if (!flow) return;
+ document.querySelectorAll('.flow-item').forEach((el, j) =>
+ el.classList.toggle('active', j === i));
+ document.getElementById('flow-title').innerHTML = formatQn(flow.qualname, {maxParts: 4});
+ document.getElementById('flow-title').classList.add('qn-mono');
+ document.getElementById('flow-meta').textContent = flow.reason;
+ const canvas = document.getElementById('flow-canvas');
+ canvas.innerHTML = `${esc(flow.mermaid)} `;
+ mermaid.run({ nodes: canvas.querySelectorAll('.mermaid') });
+}
diff --git a/codegraph/web/static/views/hld.js b/codegraph/web/static/views/hld.js
new file mode 100644
index 0000000..dc3d8bf
--- /dev/null
+++ b/codegraph/web/static/views/hld.js
@@ -0,0 +1,334 @@
+/* codegraph dashboard - HLD view */
+'use strict';
+
+// Module-local nav state (persists across re-renders within session).
+const _hldNav = { layer: null, module: null, symbol: null };
+
+CGViews.hld = function renderHld(host) {
+ const esc = CGUI.esc;
+ const hld = window.state.data.hld;
+ if (!hld) { host.innerHTML = 'No HLD payload.
'; return; }
+ const m = hld.metrics;
+ const card = (n, l) => `
+ `;
+
+ host.innerHTML = `
+
+
+
+
How to read this. Top = system context. Below = the layered architecture (heaviest modules per layer; +N more means more exist — see Navigator). The Navigator drills Layer → Module → Symbol ; selecting a symbol draws a live focus graph of who calls it and what it calls.
+
+
+ ${card(m.layers, 'Layers')}
+ ${card(m.components, 'Modules')}
+ ${card(m.cross_layer_edges, 'Cross-layer edges')}
+ ${card(m.total_cross_layer_calls, 'Cross-layer calls')}
+
+
+
System context
+ C4-style
+
${esc(hld.mermaid_context)}
+
+
+
Layered architecture
+ top modules per layer · +N more = drill in Navigator
+
${esc(hld.mermaid_layered)}
+
+
+
`;
+
+ if (_hldNav.layer && !(hld.components[_hldNav.layer] || []).length) _hldNav.layer = null;
+ _hldRenderNav();
+ mermaid.run({ nodes: host.querySelectorAll('.mermaid') });
+};
+
+function _hldRenderNav() {
+ const esc = CGUI.esc;
+ const formatQn = CGUI.formatQn;
+ const kindIcon = CGUI.kindIcon;
+ const kindColor = CGUI.kindColor;
+ const hld = window.state.data.hld;
+ const layers = hld.layers.filter(L => (hld.components[L.id] || []).length);
+
+ const colLayers = document.getElementById('hld-col-layers');
+ const colMods = document.getElementById('hld-col-modules');
+ const colSyms = document.getElementById('hld-col-symbols');
+ const crumb = document.getElementById('hld-crumb');
+ const detail = document.getElementById('hld-detail');
+
+ // ---- Layers column
+ colLayers.innerHTML = `Layers
` +
+ layers.map(L => {
+ const n = (hld.components[L.id] || []).length;
+ const active = _hldNav.layer === L.id ? ' active' : '';
+ return `
+
+
+
${esc(L.title)}
+
${esc(L.subtitle)}
+
+
${n}
+
+
`;
+ }).join('');
+ colLayers.querySelectorAll('[data-layer]').forEach(el => {
+ el.onclick = () => { _hldNav.layer = el.dataset.layer;
+ _hldNav.module = null; _hldNav.symbol = null; _hldRenderNav(); };
+ });
+
+ // ---- Modules column
+ if (!_hldNav.layer) {
+ colMods.innerHTML = `Modules
+ Pick a layer →
`;
+ } else {
+ const modules = (hld.components[_hldNav.layer] || [])
+ .slice().sort((a, b) => b.symbols - a.symbols);
+ colMods.innerHTML = `Modules · ${esc(_hldLayerTitle(_hldNav.layer))}
` +
+ modules.map(c => {
+ const active = _hldNav.module === c.qualname ? ' active' : '';
+ return `
+
+
+
${formatQn(c.qualname, {maxParts: 2})}
+
${esc(c.file || '')}
+
+
${c.symbols}
+
+
`;
+ }).join('') || 'No modules.
';
+ colMods.querySelectorAll('[data-module]').forEach(el => {
+ el.onclick = () => { _hldNav.module = el.dataset.module;
+ _hldNav.symbol = null; _hldRenderNav(); };
+ });
+ }
+
+ // ---- Symbols column
+ if (!_hldNav.module) {
+ colSyms.innerHTML = `Symbols
+ Pick a module →
`;
+ } else {
+ const mod = (hld.modules || {})[_hldNav.module];
+ const symbols = mod ? (mod.symbols || []) : [];
+ colSyms.innerHTML = `Symbols · ${esc(CGUI.shortQn(_hldNav.module))}
` +
+ (symbols.length
+ ? symbols.map(s => {
+ const active = _hldNav.symbol === s.qualname ? ' active' : '';
+ return `
+
+
+
${esc(s.name)}
+
${s.kind} · L${s.line || '?'}
+
+
${s.fan_in}/${s.fan_out}
+
`;
+ }).join('')
+ : 'No symbols recorded.
');
+ colSyms.querySelectorAll('[data-symbol]').forEach(el => {
+ el.onclick = () => { _hldNav.symbol = el.dataset.symbol; _hldRenderNav(); };
+ });
+ }
+
+ // ---- Crumb
+ const parts = [];
+ parts.push(`All layers `);
+ if (_hldNav.layer) parts.push(`/
+ ${esc(_hldLayerTitle(_hldNav.layer))} `);
+ if (_hldNav.module) parts.push(`/
+ ${esc(CGUI.shortQn(_hldNav.module))} `);
+ if (_hldNav.symbol) parts.push(`/
+ ${esc(CGUI.shortQn(_hldNav.symbol))} `);
+ crumb.innerHTML = parts.join(' ');
+ crumb.querySelectorAll('[data-jump]').forEach(el => {
+ el.onclick = () => {
+ if (el.dataset.jump === 'root') { _hldNav.layer = _hldNav.module = _hldNav.symbol = null; }
+ else if (el.dataset.jump === 'layer') { _hldNav.module = _hldNav.symbol = null; }
+ else if (el.dataset.jump === 'module') { _hldNav.symbol = null; }
+ _hldRenderNav();
+ };
+ });
+
+ // ---- Detail panel + focus graph (only when a symbol is selected)
+ const focus = document.getElementById('hld-focus');
+ if (_hldNav.symbol) {
+ const mod = (hld.modules || {})[_hldNav.module];
+ const sym = mod && (mod.symbols || []).find(s => s.qualname === _hldNav.symbol);
+ if (sym) detail.innerHTML = _symbolDetailHtml(sym, mod);
+ detail.querySelectorAll('[data-jumpqn]').forEach(el => {
+ el.onclick = () => _jumpToQualname(el.dataset.jumpqn);
+ });
+ if (sym) _drawFocusGraph(focus, sym);
+ } else {
+ detail.innerHTML = '';
+ if (focus) { focus.style.display = 'none'; focus.innerHTML = ''; }
+ }
+
+ lucide.createIcons();
+}
+
+function _symbolDetailHtml(sym, mod) {
+ const esc = CGUI.esc;
+ const formatQn = CGUI.formatQn;
+ const kindIcon = CGUI.kindIcon;
+ const kindColor = CGUI.kindColor;
+ const callRow = qn => `
+
+ ${formatQn(qn, {maxParts: 3})}
`;
+ const callerRow = qn => `
+
+ ${formatQn(qn, {maxParts: 3})}
`;
+
+ return `
+
+
+
+
+
${formatQn(sym.qualname, {maxParts: 5})}
+
+ ${sym.kind}
+ L${sym.line || '?'}
+ in: ${sym.fan_in}
+ out: ${sym.fan_out}
+ ${esc(mod ? mod.file : '')}
+
+
+
+
+
+
+
Called by (${sym.fan_in})
+ ${(sym.callers && sym.callers.length)
+ ? sym.callers.map(callerRow).join('')
+ : '
No callers in graph.
'}
+
+
+
Calls (${sym.fan_out})
+ ${(sym.callees && sym.callees.length)
+ ? sym.callees.map(callRow).join('')
+ : '
Calls nothing tracked.
'}
+
+
`;
+}
+
+function _jumpToQualname(qn) {
+ // Find the module that owns this qualname (longest prefix match) and select it.
+ const mods = (window.state.data.hld.modules || {});
+ const candidates = Object.keys(mods).filter(mq => qn === mq || qn.startsWith(mq + '.'));
+ if (!candidates.length) return;
+ const mqn = candidates.sort((a, b) => b.length - a.length)[0];
+ const mod = mods[mqn];
+ _hldNav.layer = mod.layer;
+ _hldNav.module = mqn;
+ _hldNav.symbol = (mod.symbols || []).some(s => s.qualname === qn) ? qn : null;
+ _hldRenderNav();
+}
+
+function _hldLayerTitle(id) {
+ const L = (window.state.data.hld.layers || []).find(x => x.id === id);
+ return L ? L.title : id;
+}
+
+/* Radial focus graph for the selected symbol. Center = symbol; left arc =
+ callers; right arc = callees. Edges are dashed and animated (CSS) to give
+ a sense of data flowing inward / outward. Click any node to jump. */
+function _drawFocusGraph(svg, sym) {
+ if (!svg) return;
+ const callers = (sym.callers || []).slice(0, 8);
+ const callees = (sym.callees || []).slice(0, 8);
+ if (!callers.length && !callees.length) {
+ svg.style.display = 'none'; svg.innerHTML = ''; return;
+ }
+ svg.style.display = 'block';
+ d3.select(svg).selectAll('*').remove();
+
+ const W = svg.parentElement.clientWidth - 4;
+ const H = 320;
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
+ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
+ svg.setAttribute('width', W); svg.setAttribute('height', H);
+
+ const cx = W / 2, cy = H / 2;
+ const R = Math.min(H * 0.42, W * 0.32);
+
+ const arcPositions = (n, side) => {
+ if (n === 0) return [];
+ const span = Math.min(Math.PI * 0.85, 0.5 + n * 0.18);
+ const start = side === 'left' ? Math.PI - span / 2 : -span / 2;
+ return d3.range(n).map(i => {
+ const t = n === 1 ? 0.5 : i / (n - 1);
+ const a = start + t * span;
+ return [cx + R * Math.cos(a), cy + R * Math.sin(a)];
+ });
+ };
+
+ const left = arcPositions(callers.length, 'left');
+ const right = arcPositions(callees.length, 'right');
+
+ const root = d3.select(svg);
+ const g = root.append('g');
+
+ // Edges (callers → center, center → callees). dashoffset CSS animation.
+ callers.forEach((qn, i) => {
+ const [x, y] = left[i];
+ g.append('path')
+ .attr('class', 'focus-edge focus-in')
+ .attr('d', `M${x},${y} Q${(x+cx)/2},${(y+cy)/2 - 18} ${cx},${cy}`);
+ });
+ callees.forEach((qn, i) => {
+ const [x, y] = right[i];
+ g.append('path')
+ .attr('class', 'focus-edge focus-out')
+ .attr('d', `M${cx},${cy} Q${(cx+x)/2},${(cy+y)/2 - 18} ${x},${y}`);
+ });
+
+ // Caller / callee nodes.
+ const node = (qn, x, y, side) => {
+ const grp = g.append('g')
+ .attr('class', 'focus-node')
+ .attr('transform', `translate(${x},${y})`)
+ .style('cursor', 'pointer')
+ .on('click', () => _jumpToQualname(qn));
+ grp.append('circle').attr('r', 8)
+ .attr('class', side === 'in' ? 'focus-dot focus-dot-in' : 'focus-dot focus-dot-out');
+ const label = CGUI.shortQn(qn);
+ grp.append('text')
+ .attr('y', 22).attr('text-anchor', 'middle')
+ .attr('class', 'focus-label')
+ .text(label.length > 26 ? label.slice(0, 25) + '…' : label);
+ };
+ callers.forEach((qn, i) => node(qn, left[i][0], left[i][1], 'in'));
+ callees.forEach((qn, i) => node(qn, right[i][0], right[i][1], 'out'));
+
+ // Center node.
+ const center = g.append('g').attr('transform', `translate(${cx},${cy})`);
+ center.append('circle').attr('r', 22).attr('class', 'focus-core');
+ center.append('circle').attr('r', 28).attr('class', 'focus-core-ring');
+ center.append('text').attr('y', 5).attr('text-anchor', 'middle')
+ .attr('class', 'focus-core-label')
+ .text(CGUI.shortQn(sym.qualname).slice(0, 18));
+
+ // Side captions.
+ if (callers.length) {
+ g.append('text').attr('x', 16).attr('y', 22)
+ .attr('class', 'focus-caption')
+ .text(`called by · ${sym.fan_in}`);
+ }
+ if (callees.length) {
+ g.append('text').attr('x', W - 16).attr('y', 22)
+ .attr('text-anchor', 'end')
+ .attr('class', 'focus-caption')
+ .text(`calls · ${sym.fan_out}`);
+ }
+}
diff --git a/codegraph/web/static/views/matrix.js b/codegraph/web/static/views/matrix.js
new file mode 100644
index 0000000..274bd7b
--- /dev/null
+++ b/codegraph/web/static/views/matrix.js
@@ -0,0 +1,66 @@
+/* codegraph dashboard - Matrix view */
+'use strict';
+
+CGViews.matrix = function renderMatrix(host) {
+ const esc = CGUI.esc;
+ const showTip = CGUI.showTip;
+ const hideTip = CGUI.hideTip;
+ const m = window.state.data.matrix;
+ if (!m.modules.length) {
+ host.innerHTML = `No cross-module CALLS recorded.
`;
+ return;
+ }
+ const max = m.max || 1;
+ const colour = v => {
+ if (!v) return 'transparent';
+ const t = v / max;
+ // Cool indigo -> violet -> warm rose for heat.
+ const stops = [
+ [42, 57, 87], // ink-500
+ [99, 102, 241], // brand-600
+ [167, 139, 250], // accent-violet
+ [248, 113, 113], // accent-rose
+ ];
+ const seg = Math.min(stops.length - 2, Math.floor(t * (stops.length - 1)));
+ const lt = (t * (stops.length - 1)) - seg;
+ const a = stops[seg], b = stops[seg + 1];
+ const r = Math.round(a[0] + lt * (b[0] - a[0]));
+ const g = Math.round(a[1] + lt * (b[1] - a[1]));
+ const bl= Math.round(a[2] + lt * (b[2] - a[2]));
+ return `rgb(${r},${g},${bl})`;
+ };
+ let html = `
+
+
+
Module call matrix. Each row is a caller, each column a callee. Cell color and number = number of calls.
+ Rotate your head 45° to read column labels - or hover any cell for the exact pair.
+
+
+
`;
+ m.modules.forEach(mod => {
+ html += `${esc(mod.name)} `;
+ });
+ html += ``;
+ m.modules.forEach((row, i) => {
+ html += `${esc(row.qualname)} `;
+ m.counts[i].forEach((v, j) => {
+ const tip = v ? `${esc(row.name)} -> ${esc(m.modules[j].name)} ${v} call${v===1?'':'s'}` : '';
+ html += `${v || ''} `;
+ });
+ html += ` `;
+ });
+ html += `
+
`;
+ host.innerHTML = html;
+ host.querySelectorAll('td.cell').forEach(c => {
+ c.addEventListener('mousemove', e => {
+ const t = e.target.dataset.tip;
+ if (t) showTip(t, e.clientX, e.clientY);
+ });
+ c.addEventListener('mouseleave', hideTip);
+ });
+};
diff --git a/codegraph/web/static/views/overview.js b/codegraph/web/static/views/overview.js
new file mode 100644
index 0000000..d2fe0b6
--- /dev/null
+++ b/codegraph/web/static/views/overview.js
@@ -0,0 +1,58 @@
+/* codegraph dashboard - Overview view */
+'use strict';
+
+CGViews.overview = function renderOverview(host) {
+ const esc = CGUI.esc;
+ const formatQn = CGUI.formatQn;
+ const m = window.state.data.metrics, iss = window.state.data.issues;
+ const card = (n, l, accent) => `
+ `;
+ const rows = obj => Object.entries(obj).sort((a,b)=>b[1]-a[1]).map(([k,v]) =>
+ `${esc(k)} ${v} `).join('');
+ const hot = window.state.data.hotspots.map(h => `
+
+ ${formatQn(h.qualname, {maxParts: 4})}
+ ${esc(h.file)}
+ ${h.fan_in}
+ ${h.fan_out}
+ ${h.loc}
+ ${h.score}
+ `).join('');
+
+ host.innerHTML = `
+
+
+
+
Where to start. Open HLD for a clean layered diagram of how the codebase is wired.
+ Use Call flows to step through specific functions, or Matrix to see who calls whom in one glance.
+
+
+ ${card(m.nodes, 'Nodes')}
+ ${card(m.edges, 'Edges')}
+ ${card(m.unresolved, 'Unresolved', m.unresolved ? 'text-accent-amber' : '')}
+ ${card(iss.cycles, 'Cycles', iss.cycles ? 'text-accent-rose' : '')}
+ ${card(iss.dead, 'Dead-code candidates', iss.dead ? 'text-accent-amber' : '')}
+ ${card(iss.untested, 'Untested fns', iss.untested ? 'text-accent-amber' : '')}
+
+
+
+
Top hotspots
+ score = fan_in*2 + fan_out + LOC/50
+
+ Symbol File Fan-in
+ Fan-out LOC Score
+ ${hot}
+
+
+
`;
+};
diff --git a/codegraph/web/static/views/sankey.js b/codegraph/web/static/views/sankey.js
new file mode 100644
index 0000000..6eb6122
--- /dev/null
+++ b/codegraph/web/static/views/sankey.js
@@ -0,0 +1,52 @@
+/* codegraph dashboard - Sankey view */
+'use strict';
+
+CGViews.sankey = function renderSankey(host) {
+ const esc = CGUI.esc;
+ const showTip = CGUI.showTip;
+ const hideTip = CGUI.hideTip;
+ const data = window.state.data.sankey;
+ host.innerHTML = `
+
+
+
Inter-module call flows. Width of each ribbon = number of calls between two modules.
+ Hover anything for exact counts.
+
+
+
Top call flows ${data.links.length} flows
+ ${data.links.length ? '
'
+ : '
No cross-module flows yet.
'}
+
`;
+ if (!data.links.length) return;
+ const svg = d3.select('#sankey');
+ const { width, height } = svg.node().getBoundingClientRect();
+ const sk = d3.sankey().nodeWidth(14).nodePadding(10)
+ .extent([[6, 6], [width - 6, height - 6]]);
+ const g = sk({
+ nodes: data.nodes.map(d => ({...d})),
+ links: data.links.map(d => ({...d})),
+ });
+ const colour = d3.scaleOrdinal()
+ .range(['#818cf8','#22d3ee','#34d399','#fbbf24','#f87171','#a78bfa','#fb923c']);
+ svg.append('g').selectAll('rect').data(g.nodes).join('rect')
+ .attr('x', d => d.x0).attr('y', d => d.y0)
+ .attr('height', d => d.y1 - d.y0).attr('width', d => d.x1 - d.x0)
+ .attr('fill', d => colour(d.package || d.name))
+ .attr('rx', 2)
+ .on('mousemove', (e, d) => showTip(`${esc(d.qualname)} value: ${Math.round(d.value)}`, e.clientX, e.clientY))
+ .on('mouseleave', hideTip);
+ svg.append('g').attr('fill', 'none').selectAll('path').data(g.links).join('path')
+ .attr('d', d3.sankeyLinkHorizontal())
+ .attr('stroke', d => colour(d.source.package || d.source.name))
+ .attr('stroke-width', d => Math.max(1, d.width))
+ .attr('stroke-opacity', 0.4)
+ .on('mousemove', (e, d) => showTip(
+ `${esc(d.source.qualname)} → ${esc(d.target.qualname)} ${d.value} call(s)`,
+ e.clientX, e.clientY))
+ .on('mouseleave', hideTip);
+ svg.append('g').attr('class', 'd3-label').selectAll('text').data(g.nodes).join('text')
+ .attr('x', d => d.x0 < width / 2 ? d.x1 + 8 : d.x0 - 8)
+ .attr('y', d => (d.y1 + d.y0) / 2).attr('dy', '0.35em')
+ .attr('text-anchor', d => d.x0 < width / 2 ? 'start' : 'end')
+ .text(d => d.name);
+};
diff --git a/codegraph/web/static/views/treemap.js b/codegraph/web/static/views/treemap.js
new file mode 100644
index 0000000..22cd9fb
--- /dev/null
+++ b/codegraph/web/static/views/treemap.js
@@ -0,0 +1,55 @@
+/* codegraph dashboard - Treemap view */
+'use strict';
+
+CGViews.treemap = function renderTreemap(host) {
+ const esc = CGUI.esc;
+ const showTip = CGUI.showTip;
+ const hideTip = CGUI.hideTip;
+ host.innerHTML = `
+
+
+
Codebase footprint. Each rectangle = one module. Area = LOC; brighter color = higher hotspot score.
+ Hover any cell for full details.
+
+
`;
+ const root = d3.hierarchy(window.state.data.treemap)
+ .sum(d => d.value || 0).sort((a, b) => b.value - a.value);
+ const svg = d3.select('#treemap');
+ const { width, height } = svg.node().getBoundingClientRect();
+ d3.treemap().size([width, height]).paddingInner(3).paddingTop(22).round(true)(root);
+ const maxScore = d3.max(root.leaves(), d => d.data.score) || 1;
+ const colour = d3.scaleSequential([0, maxScore], d3.interpolateInferno);
+
+ const pkg = svg.append('g').selectAll('g')
+ .data(root.descendants().filter(d => d.depth === 1))
+ .join('g').attr('transform', d => `translate(${d.x0},${d.y0})`);
+ pkg.append('rect').attr('width', d => d.x1 - d.x0).attr('height', d => d.y1 - d.y0)
+ .attr('fill', '#131c2e').attr('stroke', '#243049').attr('rx', 4);
+ pkg.append('text').attr('x', 8).attr('y', 14).attr('fill', '#cbd5e1')
+ .style('font-size', '11px').style('font-weight', '600').text(d => d.data.name);
+
+ const leaf = svg.append('g').selectAll('g').data(root.leaves())
+ .join('g').attr('transform', d => `translate(${d.x0},${d.y0})`);
+ leaf.append('rect').attr('width', d => Math.max(0, d.x1 - d.x0))
+ .attr('height', d => Math.max(0, d.y1 - d.y0))
+ .attr('fill', d => d.data.score ? colour(d.data.score) : '#243049')
+ .attr('stroke', '#0b1220').attr('stroke-width', 1).attr('rx', 2)
+ .style('cursor', 'pointer')
+ .on('mousemove', (e, d) => showTip(
+ `${esc(d.data.name)} ${esc(d.data.file)} ` +
+ `LOC: ${d.data.value} · symbols: ${d.data.symbols} · score: ${d.data.score}`,
+ e.clientX, e.clientY))
+ .on('mouseleave', hideTip);
+ leaf.append('text').attr('x', 6).attr('y', 14).attr('fill', '#fff')
+ .style('font-size', '10.5px').style('font-weight', '500')
+ .style('pointer-events', 'none')
+ .text(d => {
+ const w = d.x1 - d.x0, h = d.y1 - d.y0;
+ if (w < 60 || h < 22) return '';
+ const name = d.data.name.split('.').pop();
+ return name.length * 6 > w - 12 ? name.slice(0, Math.floor((w-12)/6)) + '…' : name;
+ });
+};
diff --git a/tests/test_web.py b/tests/test_web.py
index 670e3a2..65f095d 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -104,7 +104,7 @@ def test_rebuild_endpoint(server: tuple[ThreadingHTTPServer, str]) -> None:
#: Static files to scan (relative to the static root, excluding vendor/).
_STATIC_ROOT = Path(__file__).parent.parent / "codegraph" / "web" / "static"
-_SCAN_GLOBS = ["*.html", "*.js", "*.css", "views/*.html", "views/*.js", "views/*.css"]
+_SCAN_GLOBS = ["*.html", "*.js", "*.css", "ui/*.js", "views/*.html", "views/*.js", "views/*.css"]
def _collect_static_files() -> list[Path]: