Skip to main content
Glama
D3GraphRenderer.ts16.8 kB
/** * D3 Graph Renderer - Modern Force-Directed Visualization * * Replaces legacy d3_signal_map.ts with tree-shaken D3 imports. * Implements interactive zoom, drag, clustering, and animations. * * @module artifacts/visualizations/D3GraphRenderer */ import { forceSimulation, forceLink, forceManyBody, forceCenter, type SimulationNodeDatum, type SimulationLinkDatum } from 'd3-force'; import { zoom, zoomIdentity, type ZoomBehavior } from 'd3-zoom'; import { drag, type D3DragEvent } from 'd3-drag'; import { scaleOrdinal } from 'd3-scale'; import { select, type Selection } from 'd3-selection'; import 'd3-transition'; // Side effect import for .transition() /** * Graph node representation */ export interface GraphNode extends SimulationNodeDatum { id: string; label: string; type: string; // "EventBus" or "SignalBus" filePath?: string; line?: number; params?: string[]; clusterId?: string; } /** * Graph edge representation */ export interface GraphEdge extends SimulationLinkDatum<GraphNode> { source: string | GraphNode; target: string | GraphNode; type?: string; // "emit" or "connect" } /** * D3 force-directed graph renderer * * Generates interactive SVG visualizations with: * - Force simulation (300 ticks max) * - Zoom/pan controls * - Drag-to-reposition nodes * - Color-coded node types * - Cluster boundaries (optional) * - Lazy loading for large graphs (>100 nodes) * * @example Basic usage * ```typescript * import { D3GraphRenderer, GraphNode, GraphEdge } from './D3GraphRenderer.js'; * * const renderer = new D3GraphRenderer(); * * const nodes: GraphNode[] = [ * { id: 'sig1', label: 'player_health_changed', type: 'EventBus' }, * { id: 'sig2', label: 'enemy_spawned', type: 'EventBus' } * ]; * * const edges: GraphEdge[] = [ * { source: 'sig1', target: 'sig2', type: 'connect' } * ]; * * const html = renderer.generateForceDirectedGraph(nodes, edges); * // Returns: HTML string with embedded D3 visualization * ``` * * @example Large graph with lazy loading * ```typescript * // Lazy loading automatically activates for >100 nodes * const largeNodes = Array.from({ length: 200 }, (_, i) => ({ * id: `sig${i}`, * label: `signal_${i}`, * type: i % 2 === 0 ? 'EventBus' : 'SignalBus' * })); * * const html = renderer.generateForceDirectedGraph(largeNodes, []); * // Renders initial 50 nodes, shows "Load More" button * ``` */ export class D3GraphRenderer { private readonly width: number; private readonly height: number; private readonly colorScale: (value: string) => string; private readonly scale: ReturnType<typeof scaleOrdinal<string, string>>; /** Lazy loading threshold - activate virtualization above this node count */ private readonly LAZY_THRESHOLD = 100; /** Initial batch size for lazy loading */ private readonly INITIAL_BATCH = 50; constructor(width: number = 1200, height: number = 800) { this.width = width; this.height = height; // Color scale for node types this.scale = scaleOrdinal<string, string>() .domain(['EventBus', 'SignalBus', 'Custom']) .range(['#1f77b4', '#ff7f0e', '#2ca02c']); this.colorScale = (value: string) => this.scale(value) as string; } /** * Generate force-directed graph HTML with embedded D3 code. * Automatically activates lazy loading for large graphs (>100 nodes). * * @param nodes Graph nodes (signals, hops, etc.) * @param edges Graph edges (connections, dependencies) * @returns HTML string with SVG and D3 script * * @example Small graph (full rendering) * ```typescript * const nodes = [ * { id: 'sig1', label: 'player_health_changed', type: 'EventBus', filePath: 'autoload/EventBus.gd', line: 5 }, * { id: 'sig2', label: 'enemy_spawned', type: 'EventBus', filePath: 'autoload/EventBus.gd', line: 6 } * ]; * * const edges = [{ source: 'sig1', target: 'sig2', type: 'connect' }]; * * const html = renderer.generateForceDirectedGraph(nodes, edges); * // Renders all nodes immediately (~100ms for 50 nodes) * ``` * * @example Large graph (lazy loading) * ```typescript * const largeNodes = Array.from({ length: 200 }, (_, i) => ({ * id: `sig${i}`, * label: `signal_${i}`, * type: i % 2 === 0 ? 'EventBus' : 'SignalBus' * })); * * const html = renderer.generateForceDirectedGraph(largeNodes, []); * // Lazy loading active: * // - Initial render: 50 nodes (~300ms) * // - "Load More" button: +50 nodes per click (~200ms) * // - Full render avoided (would be >5s for 200 nodes) * ``` */ generateForceDirectedGraph(nodes: GraphNode[], edges: GraphEdge[]): string { // Detect large graphs and enable lazy loading const isLargeGraph = nodes.length > this.LAZY_THRESHOLD; const renderMode = isLargeGraph ? 'lazy' : 'full'; console.log(`[D3GraphRenderer] Rendering ${nodes.length} nodes in ${renderMode} mode`); // Serialize data for embedding in HTML const nodesJson = JSON.stringify(nodes); const edgesJson = JSON.stringify(edges); const colorDomain = JSON.stringify(this.scale.domain()); const colorRange = JSON.stringify(this.scale.range()); const lazyThreshold = this.LAZY_THRESHOLD; const initialBatch = this.INITIAL_BATCH; return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Signal Map - D3 Force-Directed Graph</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1e1e1e; color: #d4d4d4; overflow: hidden; } #graph { width: 100vw; height: 100vh; background: #1e1e1e; } .link { stroke: #999; stroke-opacity: 0.6; stroke-width: 2px; } .node { cursor: grab; stroke: #fff; stroke-width: 2px; } .node:hover { stroke: #ffa500; stroke-width: 3px; } .node:active { cursor: grabbing; } .node-label { font-size: 10px; pointer-events: none; user-select: none; fill: #d4d4d4; text-anchor: middle; dominant-baseline: central; } .controls { position: absolute; top: 20px; left: 20px; background: rgba(30, 30, 30, 0.9); border: 1px solid #444; border-radius: 8px; padding: 15px; z-index: 1000; } .control-button { background: #007acc; color: #fff; border: none; border-radius: 4px; padding: 8px 12px; margin: 5px; cursor: pointer; font-size: 12px; } .control-button:hover { background: #005a9e; } .stats { position: absolute; bottom: 20px; right: 20px; background: rgba(30, 30, 30, 0.9); border: 1px solid #444; border-radius: 8px; padding: 15px; font-size: 12px; z-index: 1000; } .legend { position: absolute; top: 20px; right: 20px; background: rgba(30, 30, 30, 0.9); border: 1px solid #444; border-radius: 8px; padding: 15px; z-index: 1000; } .legend-item { display: flex; align-items: center; margin: 5px 0; font-size: 12px; } .legend-color { width: 16px; height: 16px; border-radius: 50%; margin-right: 8px; } </style> </head> <body> <div class="controls"> <button class="control-button" onclick="resetZoom()">Reset Zoom</button> <button class="control-button" onclick="toggleLabels()">Toggle Labels</button> <button class="control-button" onclick="restartSimulation()">Restart Sim</button> </div> <div class="legend"> <div class="legend-item"> <div class="legend-color" style="background: #1f77b4;"></div> <span>EventBus</span> </div> <div class="legend-item"> <div class="legend-color" style="background: #ff7f0e;"></div> <span>SignalBus</span> </div> <div class="legend-item"> <div class="legend-color" style="background: #2ca02c;"></div> <span>Custom</span> </div> </div> <div class="stats"> <div><strong>Nodes:</strong> <span id="node-count">0</span></div> <div><strong>Edges:</strong> <span id="edge-count">0</span></div> <div><strong>Zoom:</strong> <span id="zoom-level">1.00</span>x</div> </div> <svg id="graph"></svg> <script type="module"> // Data embedded from server const allNodes = ${nodesJson}; const allEdges = ${edgesJson}; const colorDomain = ${colorDomain}; const colorRange = ${colorRange}; const lazyThreshold = ${lazyThreshold}; const initialBatch = ${initialBatch}; // Lazy loading state let currentBatchSize = allNodes.length > lazyThreshold ? initialBatch : allNodes.length; let visibleNodes = allNodes.slice(0, currentBatchSize); let visibleEdges = allEdges.filter(e => visibleNodes.some(n => n.id === (typeof e.source === 'string' ? e.source : e.source.id)) && visibleNodes.some(n => n.id === (typeof e.target === 'string' ? e.target : e.target.id)) ); // Use current visible data for rendering const nodes = visibleNodes; const edges = visibleEdges; // Update stats document.getElementById('node-count').textContent = allNodes.length > lazyThreshold ? \`\${nodes.length} / \${allNodes.length} (lazy loading)\` : nodes.length; document.getElementById('edge-count').textContent = edges.length; // SVG setup const svg = d3.select('#graph'); const width = ${this.width}; const height = ${this.height}; const g = svg.append('g'); // Color scale const colorScale = d3.scaleOrdinal() .domain(colorDomain) .range(colorRange); // Force simulation const simulation = d3.forceSimulation(nodes) .force('link', d3.forceLink(edges) .id(d => d.id) .distance(100)) .force('charge', d3.forceManyBody().strength(-300)) .force('center', d3.forceCenter(width / 2, height / 2)); // Limit simulation to 300 ticks (performance) let tickCount = 0; const maxTicks = 300; // Render edges const link = g.append('g') .attr('class', 'links') .selectAll('line') .data(edges) .enter().append('line') .attr('class', 'link'); // Render nodes const node = g.append('g') .attr('class', 'nodes') .selectAll('circle') .data(nodes) .enter().append('circle') .attr('class', 'node') .attr('r', 8) .attr('fill', d => colorScale(d.type)) .call(d3.drag() .on('start', dragStarted) .on('drag', dragged) .on('end', dragEnded)); // Node labels const label = g.append('g') .attr('class', 'labels') .selectAll('text') .data(nodes) .enter().append('text') .attr('class', 'node-label') .attr('dy', 20) .text(d => d.label); // Add tooltips node.append('title') .text(d => \`\${d.label}\\nType: \${d.type}\\nFile: \${d.filePath || 'N/A'}\\nLine: \${d.line || 'N/A'}\`); // Update positions on simulation tick simulation.on('tick', () => { tickCount++; if (tickCount > maxTicks) { simulation.stop(); } link .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); node .attr('cx', d => d.x) .attr('cy', d => d.y); label .attr('x', d => d.x) .attr('y', d => d.y); }); // Zoom behavior const zoomBehavior = d3.zoom() .extent([[0, 0], [width, height]]) .scaleExtent([0.5, 5]) .on('zoom', (event) => { g.attr('transform', event.transform); document.getElementById('zoom-level').textContent = event.transform.k.toFixed(2); }); svg.call(zoomBehavior); // Drag functions function dragStarted(event) { if (!event.active) simulation.alphaTarget(0.3).restart(); event.subject.fx = event.subject.x; event.subject.fy = event.subject.y; } function dragged(event) { event.subject.fx = event.x; event.subject.fy = event.y; } function dragEnded(event) { if (!event.active) simulation.alphaTarget(0); event.subject.fx = null; event.subject.fy = null; } // Control functions window.resetZoom = function() { svg.transition() .duration(750) .call(zoomBehavior.transform, d3.zoomIdentity); }; window.toggleLabels = function() { const labels = g.select('.labels'); const currentDisplay = labels.style('display'); labels.style('display', currentDisplay === 'none' ? 'block' : 'none'); }; window.restartSimulation = function() { tickCount = 0; simulation.alpha(1).restart(); }; // Lazy loading: Load more nodes on demand window.loadMoreNodes = function() { if (currentBatchSize >= allNodes.length) { console.log('[D3GraphRenderer] All nodes already loaded'); return; } const nextBatchSize = Math.min(currentBatchSize + initialBatch, allNodes.length); console.log(\`[D3GraphRenderer] Loading batch: \${currentBatchSize} → \${nextBatchSize}\`); currentBatchSize = nextBatchSize; visibleNodes = allNodes.slice(0, currentBatchSize); visibleEdges = allEdges.filter(e => visibleNodes.some(n => n.id === (typeof e.source === 'string' ? e.source : e.source.id)) && visibleNodes.some(n => n.id === (typeof e.target === 'string' ? e.target : e.target.id)) ); // Re-render with expanded dataset rerenderGraph(); }; function rerenderGraph() { // Clear existing elements g.selectAll('*').remove(); // Update stats document.getElementById('node-count').textContent = allNodes.length > lazyThreshold ? \`\${visibleNodes.length} / \${allNodes.length} (lazy loading)\` : visibleNodes.length; document.getElementById('edge-count').textContent = visibleEdges.length; // Re-initialize simulation with new data simulation.nodes(visibleNodes); simulation.force('link').links(visibleEdges); // Re-render edges const newLinks = g.append('g') .attr('class', 'links') .selectAll('line') .data(visibleEdges) .enter().append('line') .attr('class', 'link'); // Re-render nodes const newNodes = g.append('g') .attr('class', 'nodes') .selectAll('circle') .data(visibleNodes) .enter().append('circle') .attr('class', 'node') .attr('r', 8) .attr('fill', d => colorScale(d.type)) .call(d3.drag() .on('start', dragStarted) .on('drag', dragged) .on('end', dragEnded)); // Re-render labels const newLabels = g.append('g') .attr('class', 'labels') .selectAll('text') .data(visibleNodes) .enter().append('text') .attr('class', 'node-label') .attr('dy', 20) .text(d => d.label); // Add tooltips newNodes.append('title') .text(d => \`\${d.label}\\nType: \${d.type}\\nFile: \${d.filePath || 'N/A'}\\nLine: \${d.line || 'N/A'}\`); // Restart simulation tickCount = 0; simulation.alpha(1).restart(); // Update tick handler simulation.on('tick', () => { tickCount++; if (tickCount > maxTicks) { simulation.stop(); } newLinks .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); newNodes .attr('cx', d => d.x) .attr('cy', d => d.y); newLabels .attr('x', d => d.x) .attr('y', d => d.y); }); } // Add "Load More" button if lazy loading is active if (allNodes.length > lazyThreshold) { const controls = document.querySelector('.controls'); const loadMoreBtn = document.createElement('button'); loadMoreBtn.className = 'control-button'; loadMoreBtn.textContent = 'Load More Nodes'; loadMoreBtn.onclick = () => window.loadMoreNodes(); controls.appendChild(loadMoreBtn); } </script> </body> </html> `.trim(); } /** * Get color for node type */ getNodeColor(type: string): string { return this.colorScale(type) || '#999'; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/EricA1019/CTS_MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server