From ba30c74461c82bc28f8fa0dfc33b59afd0ad8a8e Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sun, 22 Mar 2026 16:46:09 -0500 Subject: [PATCH] feat: Improve visualizer graph quality and UI - Bridge pass: connect isolated seed nodes that share callees - Kind labels on nodes (fn, class, comp, etc.) for quick identification - Quick action buttons in detail panel (Expand Callees, Callers, Call Graph, Impact) - Wider detail panel (460px) for better code readability - Multiline node labels showing name + kind Co-Authored-By: Claude Opus 4.6 (1M context) --- src/visualizer/public/index.html | 26 +++++++++++++--- src/visualizer/server.ts | 52 +++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/visualizer/public/index.html b/src/visualizer/public/index.html index e07f820..bc736b7 100644 --- a/src/visualizer/public/index.html +++ b/src/visualizer/public/index.html @@ -42,7 +42,7 @@ --font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', Consolas, monospace; --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; --sidebar-width: 320px; - --panel-width: 420px; + --panel-width: 460px; --header-height: 52px; --radius: 8px; --radius-sm: 6px; @@ -991,8 +991,8 @@ 'height': 'label', 'padding': '12px', 'shape': 'data(shape)', - 'text-wrap': 'none', - 'text-max-width': '160px', + 'text-wrap': 'wrap', + 'text-max-width': '180px', 'transition-property': 'background-opacity, border-color, border-opacity, opacity, text-opacity', 'transition-duration': '0.2s', } @@ -1130,16 +1130,24 @@ // ==================================================================== // Graph Operations // ==================================================================== + const kindLabels = { + 'function': 'fn', 'method': 'method', 'class': 'class', 'interface': 'iface', + 'component': 'comp', 'route': 'route', 'enum': 'enum', 'type_alias': 'type', + 'struct': 'struct', 'trait': 'trait', 'variable': 'var', 'constant': 'const', + 'property': 'prop', 'field': 'field', 'file': 'file', 'module': 'mod', + }; + function addNodeToGraph(node) { if (cy.getElementById(node.id).length > 0) return; const color = kindColors[node.kind] || '#8b949e'; const shape = kindShapes[node.kind] || 'round-rectangle'; + const kindLabel = kindLabels[node.kind] || node.kind; cy.add({ group: 'nodes', data: { id: node.id, nodeId: node.id, - label: node.name, + label: `${node.name}\n${kindLabel}`, color: color, shape: shape, kind: node.kind, @@ -1541,6 +1549,16 @@ let html = ''; + // Quick actions + html += `
+
+ + + + +
+
`; + // Meta info html += `
Info
diff --git a/src/visualizer/server.ts b/src/visualizer/server.ts index 033a45c..3e4582f 100644 --- a/src/visualizer/server.ts +++ b/src/visualizer/server.ts @@ -397,11 +397,55 @@ ${symbolIndex}`; addEdge(item.edge); } - // Seeds with no relevant neighbors stay isolated — user can - // right-click → expand to explore manually. No noise added. + // For seeds with no relevant neighbors, add top-3 callees + // so they're not completely floating + if (relevant.length === 0 && irrelevant.length > 0) { + for (const item of irrelevant.slice(0, 3)) { + if (nodeMap.size >= maxNodes) break; + // Only add if it connects to another node already in the graph + if (nodeMap.has(item.node.id)) { + addEdge(item.edge); + } + } + } } - // Step 3: Cross-connection pass — find edges between all result nodes + // Step 3: Bridge pass — find shared callees between isolated seeds + // If two seeds both call the same function, add it as a bridge node + const isolatedSeeds = Array.from(seedMap.keys()).filter(id => { + return !edgeList.some(e => e.source === id || e.target === id); + }); + + if (isolatedSeeds.length > 1) { + // Collect callees for each isolated seed + const seedCallees = new Map(); + for (const seedId of isolatedSeeds) { + const callees = this.cg.getCallees(seedId, 1); + for (const item of callees) { + const existing = seedCallees.get(item.node.id); + if (existing) { + existing.seeds.push(seedId); + } else { + seedCallees.set(item.node.id, { node: item.node, seeds: [seedId] }); + } + } + } + // Add bridge nodes that connect 2+ isolated seeds + for (const [bridgeId, { node: bridgeNode, seeds }] of seedCallees) { + if (seeds.length >= 2 && nodeMap.size < maxNodes) { + nodeMap.set(bridgeId, bridgeNode); + // Add edges from each seed to the bridge + for (const seedId of seeds) { + const callees = this.cg.getCallees(seedId, 1); + for (const item of callees) { + if (item.node.id === bridgeId) addEdge(item.edge); + } + } + } + } + } + + // Step 4: Cross-connection pass — find edges between all result nodes for (const [nodeId] of nodeMap) { const callers = this.cg.getCallers(nodeId, 1); const callees = this.cg.getCallees(nodeId, 1); @@ -412,7 +456,7 @@ ${symbolIndex}`; } } - // Step 4: Filter edges and remove isolated non-root nodes + // Step 5: Filter edges and remove isolated non-root nodes const finalEdges = edgeList.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target)); const connectedIds = new Set();