// ═══════════════════════════════════════════════════════════════ // Graph Visualization — vis-network wrapper for Code Knowledge Graph // Obsidian Control theme // ═══════════════════════════════════════════════════════════════ (function () { "use strict"; // Color palette matching Obsidian Control const NODE_COLORS = { function: { bg: "#00c8ff", border: "#00a0cc", font: "#060a13" }, method: { bg: "#00c8ff", border: "#00a0cc", font: "#060a13" }, struct: { bg: "#e040fb", border: "#b030d0", font: "#060a13" }, class: { bg: "#e040fb", border: "#b030d0", font: "#060a13" }, enum: { bg: "#ffb020", border: "#cc8c18", font: "#060a13" }, interface: { bg: "#7c4dff", border: "#5c38c0", font: "#ffffff" }, trait: { bg: "#7c4dff", border: "#5c38c0", font: "#ffffff" }, module: { bg: "#ff8a3d", border: "#cc6e30", font: "#060a13" }, file: { bg: "#00e676", border: "#00b85e", font: "#060a13" }, }; const EDGE_COLORS = { calls: "#00c8ff", imports: "#ffb020", inherits: "#e040fb", implements: "#7c4dff", contains: "rgba(94, 114, 145, 0.6)", type_ref: "rgba(94, 114, 145, 0.5)", }; const DEFAULT_NODE_COLOR = { bg: "#5e7291", border: "#3d506b", font: "#e4eaf4" }; const DEFAULT_EDGE_COLOR = "rgba(94, 114, 145, 0.3)"; let network = null; let nodesDataset = null; let edgesDataset = null; let rawNodes = []; let rawEdges = []; function getNodeColor(kind) { return NODE_COLORS[kind] || DEFAULT_NODE_COLOR; } function getEdgeColor(kind) { return EDGE_COLORS[kind] || DEFAULT_EDGE_COLOR; } // Build a vis-network node from raw graph data function toVisNode(node, edgeCounts) { const color = getNodeColor(node.kind); const connections = edgeCounts[node.qualified_name] || 0; const size = Math.max(8, Math.min(35, 8 + connections * 2)); return { id: node.qualified_name, label: node.name, title: `${node.kind}: ${node.qualified_name}\nFile: ${node.file_path}:${node.start_line}`, size: size, color: { background: color.bg, border: color.border, highlight: { background: color.bg, border: "#ffffff" }, hover: { background: color.bg, border: "#ffffff" }, }, font: { color: color.font, size: 10, face: "'JetBrains Mono', monospace", strokeWidth: 2, strokeColor: "#060a13", }, borderWidth: 1, borderWidthSelected: 3, shape: node.kind === "file" ? "diamond" : node.kind === "module" ? "square" : "dot", // Store raw data for click handler _raw: node, }; } function toVisEdge(edge) { const color = getEdgeColor(edge.kind); const isDim = edge.kind === "contains" || edge.kind === "type_ref"; return { from: edge.source, to: edge.target, color: { color: color, highlight: "#ffffff", hover: color, }, width: isDim ? 1 : 2, arrows: { to: { enabled: edge.kind !== "contains", scaleFactor: 0.5, }, }, smooth: { enabled: true, type: "continuous", roundness: 0.3, }, title: `${edge.kind}: ${edge.source} → ${edge.target}`, _raw: edge, }; } /** * Initialize or reload the graph visualization. * Called from Rust via eval(). */ window.__loadGraph = function (nodes, edges) { var container = document.getElementById("graph-canvas"); if (!container) { console.error("[graph-viz] #graph-canvas not found"); return; } // Count edges per node for sizing const edgeCounts = {}; edges.forEach(function (e) { edgeCounts[e.source] = (edgeCounts[e.source] || 0) + 1; edgeCounts[e.target] = (edgeCounts[e.target] || 0) + 1; }); // Deduplicate nodes by qualified_name (keep first occurrence) var seenNodes = {}; var uniqueNodes = []; nodes.forEach(function (n) { if (!seenNodes[n.qualified_name]) { seenNodes[n.qualified_name] = true; uniqueNodes.push(n); } }); // Deduplicate edges by source+target+kind var seenEdges = {}; var uniqueEdges = []; edges.forEach(function (e) { var key = e.source + "|" + e.target + "|" + e.kind; if (!seenEdges[key]) { seenEdges[key] = true; uniqueEdges.push(e); } }); rawNodes = uniqueNodes; rawEdges = uniqueEdges; var visNodes = uniqueNodes.map(function (n) { return toVisNode(n, edgeCounts); }); var visEdges = uniqueEdges.map(toVisEdge); nodesDataset = new vis.DataSet(visNodes); edgesDataset = new vis.DataSet(visEdges); const options = { nodes: { font: { color: "#e4eaf4", size: 10 }, scaling: { min: 8, max: 35 }, }, edges: { font: { color: "#5e7291", size: 9, strokeWidth: 0 }, selectionWidth: 3, }, physics: { enabled: true, solver: "forceAtlas2Based", forceAtlas2Based: { gravitationalConstant: -80, centralGravity: 0.005, springLength: 120, springConstant: 0.04, damping: 0.5, avoidOverlap: 0.6, }, stabilization: { enabled: true, iterations: 1500, updateInterval: 25, }, maxVelocity: 50, minVelocity: 0.75, }, interaction: { hover: true, tooltipDelay: 200, hideEdgesOnDrag: false, hideEdgesOnZoom: false, multiselect: false, navigationButtons: false, keyboard: { enabled: true }, }, layout: { improvedLayout: uniqueNodes.length < 300, }, }; // Destroy previous instance if (network) { network.destroy(); } network = new vis.Network( container, { nodes: nodesDataset, edges: edgesDataset }, options ); // Click handler — sends data to Rust network.on("click", function (params) { if (params.nodes.length > 0) { const nodeId = params.nodes[0]; const visNode = nodesDataset.get(nodeId); if (visNode && visNode._raw && window.__onNodeClick) { window.__onNodeClick(JSON.stringify(visNode._raw)); } } }); // Stabilization overlay var overlay = document.getElementById("graph-stabilization-overlay"); var progressFill = document.getElementById("graph-stab-progress-fill"); var pctLabel = document.getElementById("graph-stab-pct"); // Show the overlay if (overlay) { overlay.classList.remove("graph-stab-fade-out"); overlay.style.display = "flex"; } network.on("stabilizationProgress", function (params) { var pct = Math.round((params.iterations / params.total) * 100); if (progressFill) { progressFill.style.width = pct + "%"; } if (pctLabel) { pctLabel.textContent = pct + "% — computing layout"; } }); network.on("stabilizationIterationsDone", function () { if (progressFill) progressFill.style.width = "100%"; if (pctLabel) pctLabel.textContent = "Complete"; // Fade out overlay if (overlay) { overlay.classList.add("graph-stab-fade-out"); setTimeout(function () { overlay.style.display = "none"; }, 900); } // Keep physics running so nodes float and respond to dragging, // but reduce forces for a calm, settled feel network.setOptions({ physics: { enabled: true, solver: "forceAtlas2Based", forceAtlas2Based: { gravitationalConstant: -40, centralGravity: 0.003, springLength: 120, springConstant: 0.03, damping: 0.7, avoidOverlap: 0.6, }, maxVelocity: 20, minVelocity: 0.75, }, }); }); console.log( "[graph-viz] Loaded " + nodes.length + " nodes, " + edges.length + " edges" ); }; /** * Highlight a node by qualified name. Focuses + selects it. */ window.__highlightNode = function (qualifiedName) { if (!network || !nodesDataset) return; const node = nodesDataset.get(qualifiedName); if (!node) return; network.selectNodes([qualifiedName]); network.focus(qualifiedName, { scale: 1.5, animation: { duration: 500, easingFunction: "easeInOutQuad" }, }); // Trigger click callback too if (node._raw && window.__onNodeClick) { window.__onNodeClick(JSON.stringify(node._raw)); } }; /** * Highlight multiple nodes (e.g., search results or file nodes). */ window.__highlightNodes = function (qualifiedNames) { if (!network || !nodesDataset) return; network.selectNodes(qualifiedNames); if (qualifiedNames.length > 0) { network.fit({ nodes: qualifiedNames, animation: { duration: 500, easingFunction: "easeInOutQuad" }, }); } }; /** * Highlight all nodes belonging to a specific file path. */ window.__highlightFileNodes = function (filePath) { if (!network || !nodesDataset) return; var matching = []; rawNodes.forEach(function (n) { if (n.file_path === filePath) { matching.push(n.qualified_name); } }); if (matching.length > 0) { network.selectNodes(matching); network.fit({ nodes: matching, animation: { duration: 500, easingFunction: "easeInOutQuad" }, }); } }; /** * Clear selection. */ window.__clearGraphSelection = function () { if (!network) return; network.unselectAll(); }; /** * Fit entire graph in view. */ window.__fitGraph = function () { if (!network) return; network.fit({ animation: { duration: 400, easingFunction: "easeInOutQuad" }, }); }; })();