From 259d2c8b5eb26c97e00882d2fe4daf4eaf0a6d57 Mon Sep 17 00:00:00 2001 From: mochan Date: Wed, 10 Jun 2026 18:54:52 +0530 Subject: [PATCH] refactor(web): split app.js into per-view modules + shared UI helpers Co-Authored-By: Claude Sonnet 4.6 --- codegraph/web/static/app.js | 745 ++----------------------- codegraph/web/static/index.html | 9 + codegraph/web/static/ui/helpers.js | 101 ++++ codegraph/web/static/views/flows.js | 68 +++ codegraph/web/static/views/hld.js | 334 +++++++++++ codegraph/web/static/views/matrix.js | 66 +++ codegraph/web/static/views/overview.js | 58 ++ codegraph/web/static/views/sankey.js | 52 ++ codegraph/web/static/views/treemap.js | 55 ++ tests/test_web.py | 2 +- 10 files changed, 777 insertions(+), 713 deletions(-) create mode 100644 codegraph/web/static/ui/helpers.js create mode 100644 codegraph/web/static/views/flows.js create mode 100644 codegraph/web/static/views/hld.js create mode 100644 codegraph/web/static/views/matrix.js create mode 100644 codegraph/web/static/views/overview.js create mode 100644 codegraph/web/static/views/sankey.js create mode 100644 codegraph/web/static/views/treemap.js diff --git a/codegraph/web/static/app.js b/codegraph/web/static/app.js index 3f2b20b..e718008 100644 --- a/codegraph/web/static/app.js +++ b/codegraph/web/static/app.js @@ -1,4 +1,4 @@ -/* codegraph dashboard - vanilla JS app */ +/* codegraph dashboard - app shell */ 'use strict'; // ----- Theme + sidebar persistence (apply early) ----- @@ -36,56 +36,11 @@ const VIEWS = [ { id: 'files', label: 'Files', icon: 'folder-tree' }, ]; -// ---- Tooltip ---- -const tt = document.getElementById('tooltip'); -function showTip(html, x, y) { - 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'; -} -function hideTip() { tt.style.opacity = '0'; } - -// ---- 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 ---- -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' }; -} +// ---- Mermaid init ---- function initMermaid() { mermaid.initialize({ startOnLoad: false, theme: 'base', - themeVariables: mermaidThemeVars(), + themeVariables: CGUI.mermaidThemeVars(), flowchart: { padding: 18, nodeSpacing: 38, rankSpacing: 54, curve: 'basis', htmlLabels: true, useMaxWidth: true }, }); @@ -147,661 +102,39 @@ function render(id) { } const VIEW_RENDERERS = { - overview: renderOverview, - hld: renderHld, - flows: renderFlows, - matrix: renderMatrix, - sankey: renderSankey, - treemap: renderTreemap, - graph3d: renderGraph3dShim, - architecture: (host) => { + overview: host => CGViews.overview(host), + hld: host => CGViews.hld(host), + flows: host => CGViews.flows(host), + matrix: host => CGViews.matrix(host), + sankey: host => CGViews.sankey(host), + treemap: host => CGViews.treemap(host), + graph3d: host => renderGraph3dShim(host), + architecture: host => { if (typeof window.renderArchitectureView === 'function') { window.renderArchitectureView(host); } else { host.innerHTML = '
Architecture view not loaded.
'; } }, - explorers: renderExplorers, - files: renderFiles, + explorers: host => CGViews.explorers(host), + files: host => CGViews.files(host), }; -// ---------- Overview ---------- -function renderOverview(host) { - const m = state.data.metrics, iss = state.data.issues; - const card = (n, l, accent) => ` -
-
${n}
-
${l}
-
`; - const rows = obj => Object.entries(obj).sort((a,b)=>b[1]-a[1]).map(([k,v]) => - `${esc(k)}${v}`).join(''); - const hot = 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' : '')} -
-
-

Nodes by kind

- ${rows(m.by_kind)}
-

Edges by kind

- ${rows(m.by_edge)}
-

Languages

- ${rows(m.languages)}
-
-
-

Top hotspots

- score = fan_in*2 + fan_out + LOC/50
- - - - ${hot} -
SymbolFileFan-inFan-outLOCScore
-
-
`; -} - -// ---------- HLD ---------- -const hldNav = { layer: null, module: null, symbol: null }; - -function renderHld(host) { - const hld = state.data.hld; - if (!hld) { host.innerHTML = '
No HLD payload.
'; return; } - const m = hld.metrics; - const card = (n, l) => ` -
${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)}
-
-
-
-

Navigator

-
-
-
-
-
-
-
-
- -
-
`; - - if (hldNav.layer && !(hld.components[hldNav.layer] || []).length) hldNav.layer = null; - hldRenderNav(); - mermaid.run({ nodes: host.querySelectorAll('.mermaid') }); -} - -function hldRenderNav() { - const hld = 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(layerTitle(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(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(layerTitle(hldNav.layer))}`); - if (hldNav.module) parts.push(`/ - ${esc(shortQn(hldNav.module))}`); - if (hldNav.symbol) parts.push(`/ - ${esc(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 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 = (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 layerTitle(id) { - const L = (state.data.hld.layers || []).find(x => x.id === id); - return L ? L.title : id; -} -function kindIcon(k) { - return k === 'CLASS' ? 'box' : k === 'METHOD' ? 'corner-down-right' : 'function-square'; -} -function kindColor(k) { - return k === 'CLASS' ? 'var(--accent-violet)' - : k === 'METHOD' ? 'var(--accent-cyan)' - : 'var(--accent-emerald)'; -} - -/* 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 = 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(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}`); - } -} - -// ---------- Flows ---------- -function renderFlows(host) { - const flows = state.data.flows; - if (!flows.length) { - host.innerHTML = `
No call chains found.
`; - return; - } - if (state.flowSel >= flows.length) 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.
-
-
-
-
- - -
-
-
-
-

Flow

-
-
-
-
-
`; - 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(state.flowSel); -} -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. */ -function formatQn(qn, opts) { - 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)}`; -} -function selectFlow(i) { - state.flowSel = i; - const flow = 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') }); -} - -// ---------- Matrix ---------- -function renderMatrix(host) { - const m = 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 += ``; - }); - html += ``; - m.modules.forEach((row, i) => { - html += ``; - m.counts[i].forEach((v, j) => { - const tip = v ? `${esc(row.name)} -> ${esc(m.modules[j].name)}
${v} call${v===1?'':'s'}` : ''; - html += ``; - }); - html += ``; - }); - html += `
${esc(mod.name)}
${esc(row.qualname)}${v || ''}
-
- 0 -
- ${max} -
`; - 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); - }); -} - -// ---------- Sankey ---------- -function renderSankey(host) { - const data = 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); -} - -// ---------- Treemap ---------- -function renderTreemap(host) { - host.innerHTML = `
-
- -
Codebase footprint. Each rectangle = one module. Area = LOC; brighter color = higher hotspot score. - Hover any cell for full details.
-
-
-

LOC landscape

- -
`; - const root = d3.hierarchy(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; - }); -} - -// ---------- Architecture (links to pyvis) ---------- -function pyvisHref(path) { - const t = document.documentElement.classList.contains('theme-light') ? 'light' : 'dark'; - return path + '?theme=' + t; +// ---------- Graph3D shim ---------- +// Delegates to window.renderGraph3d (defined in views/graph3d.js). Kept as a +// named function so VIEW_RENDERERS can reference it without a hoist trap. +function renderGraph3dShim(host) { + if (typeof window.renderGraph3d === 'function') return window.renderGraph3d(host); + const msg = document.createElement('div'); + msg.className = 'p-8 text-ink-200'; + msg.textContent = '3D view module failed to load.'; + host.appendChild(msg); } +// ---------- Explorers + Files (small, stay in shell) ---------- function renderExplorers(host) { + const esc = CGUI.esc; + const pyvisHref = CGUI.pyvisHref; const tile = (href, title, desc, icon) => `
@@ -826,8 +159,9 @@ function renderExplorers(host) {
`; } -// ---------- Files ---------- function renderFiles(host) { + const esc = CGUI.esc; + const pyvisHref = CGUI.pyvisHref; const files = state.data.files; const rows = files.map(f => { const slug = f.file.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_|_$/g, '') || 'file'; @@ -849,22 +183,9 @@ function renderFiles(host) { `; } -// ---------- Graph3D shim ---------- -// Delegates to window.renderGraph3d (defined in views/graph3d.js). Kept as a -// named function so VIEW_RENDERERS can reference it without a hoist trap. -function renderGraph3dShim(host) { - if (typeof window.renderGraph3d === 'function') return window.renderGraph3d(host); - const msg = document.createElement('div'); - msg.className = 'p-8 text-ink-200'; - msg.textContent = '3D view module failed to load.'; - host.appendChild(msg); -} - -// ---------- esc ---------- -function esc(s) { - return String(s ?? '').replace(/[&<>"']/g, c => - ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); -} +// Wire explorers + files into CGViews so VIEW_RENDERERS dispatch is uniform. +CGViews.explorers = renderExplorers; +CGViews.files = renderFiles; // ---------- Bootstrap ---------- async function load() { @@ -901,9 +222,9 @@ document.getElementById('rebuild-btn').addEventListener('click', async (e) => { if (!r.ok) throw new Error('rebuild failed'); await load(); render(state.view); - toast('Rebuilt', 'success'); + CGUI.toast('Rebuilt', 'success'); } catch (err) { - toast('Rebuild failed: ' + err.message, 'error'); + CGUI.toast('Rebuild failed: ' + err.message, 'error'); } finally { btn.disabled = false; btn.innerHTML = 'Rebuild'; @@ -914,6 +235,6 @@ document.getElementById('rebuild-btn').addEventListener('click', async (e) => { load().catch(err => { document.getElementById('view-host').innerHTML = `
-
Failed to load data. ${esc(err.message)}
`; +
Failed to load data. ${CGUI.esc(err.message)}
`; lucide.createIcons(); }); diff --git a/codegraph/web/static/index.html b/codegraph/web/static/index.html index 3e20c9c..89b805c 100644 --- a/codegraph/web/static/index.html +++ b/codegraph/web/static/index.html @@ -66,6 +66,15 @@

+ + + + + + + + + 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.
+
+
+
+
+ + +
+
+
+
+

Flow

+
+
+
+
+
`; + 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) => ` +
${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)}
+
+
+
+

Navigator

+
+
+
+
+
+
+
+
+ +
+
`; + + 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 += ``; + }); + html += ``; + m.modules.forEach((row, i) => { + html += ``; + m.counts[i].forEach((v, j) => { + const tip = v ? `${esc(row.name)} -> ${esc(m.modules[j].name)}
${v} call${v===1?'':'s'}` : ''; + html += ``; + }); + html += ``; + }); + html += `
${esc(mod.name)}
${esc(row.qualname)}${v || ''}
+
+ 0 +
+ ${max} +
`; + 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) => ` +
+
${n}
+
${l}
+
`; + 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' : '')} +
+
+

Nodes by kind

+ ${rows(m.by_kind)}
+

Edges by kind

+ ${rows(m.by_edge)}
+

Languages

+ ${rows(m.languages)}
+
+
+

Top hotspots

+ score = fan_in*2 + fan_out + LOC/50
+ + + + ${hot} +
SymbolFileFan-inFan-outLOCScore
+
+
`; +}; 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.
+
+
+

LOC landscape

+ +
`; + 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]: