Last active
March 15, 2026 16:35
-
-
Save sark1337/4aa625a5993be5b1e47bfc6e25179d70 to your computer and use it in GitHub Desktop.
Interactive codebase graph visualization with force-directed physics
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const { execSync } = require('child_process'); | |
| // Parse args | |
| const args = process.argv.slice(2); | |
| let targetDir = '.'; | |
| let shouldPublish = false; | |
| let outputMode = 'stdout'; // stdout, file, url | |
| let maxNodes = 500; // Limit nodes for large codebases | |
| let exportsOnly = false; // Only show exported symbols | |
| for (let i = 0; i < args.length; i++) { | |
| if (args[i] === '--publish') { | |
| shouldPublish = true; | |
| outputMode = 'url'; | |
| } else if (args[i] === '--output') { | |
| outputMode = args[++i] || 'stdout'; | |
| } else if (args[i] === '--max-nodes') { | |
| maxNodes = parseInt(args[++i]) || 500; | |
| } else if (args[i] === '--exports-only') { | |
| exportsOnly = true; | |
| } else if (!args[i].startsWith('--')) { | |
| targetDir = args[i]; | |
| } | |
| } | |
| targetDir = path.resolve(targetDir); | |
| if (!fs.existsSync(targetDir)) { | |
| console.error(`Error: Directory not found: ${targetDir}`); | |
| process.exit(1); | |
| } | |
| console.error(`Analyzing: ${targetDir}`); | |
| // Language parsers | |
| const parseJS = (content, filePath) => { | |
| const symbols = []; | |
| const edges = []; | |
| const basename = path.basename(filePath); | |
| const group = path.dirname(filePath).split('/').slice(-1)[0] || 'root'; | |
| // Export declarations | |
| const exportMatches = content.matchAll(/(?:export\s+)?(?:const|let|var|function|class|async\s+function)\s+(\w+)/g); | |
| for (const m of exportMatches) { | |
| symbols.push({ id: `${basename}:${m[1]}`, label: m[1], group, file: filePath }); | |
| } | |
| // Export type/interface | |
| const typeMatches = content.matchAll(/(?:export\s+)?(?:type|interface)\s+(\w+)/g); | |
| for (const m of typeMatches) { | |
| symbols.push({ id: `${basename}:${m[1]}`, label: m[1], group, file: filePath, isType: true }); | |
| } | |
| // Default exports | |
| if (/export\s+default/.test(content)) { | |
| symbols.push({ id: `${basename}:default`, label: `${basename} (default)`, group, file: filePath }); | |
| } | |
| // Import statements | |
| const importMatches = content.matchAll(/import\s+[^'"]*\s+from\s+['"]([^'"]+)['"]/g); | |
| const localImports = []; | |
| for (const m of importMatches) { | |
| const imported = m[1]; | |
| if (imported.startsWith('.')) { | |
| // Local import - create edge | |
| localImports.push(imported); | |
| } | |
| } | |
| // Function calls within file | |
| const callMatches = content.matchAll(/(\w+)\s*\(/g); | |
| const calls = new Set(); | |
| for (const m of callMatches) { | |
| if (m[1] !== 'if' && m[1] !== 'for' && m[1] !== 'while' && m[1] !== 'switch') { | |
| calls.add(m[1]); | |
| } | |
| } | |
| return { symbols, edges, imports: localImports, calls: [...calls], file: filePath, group }; | |
| }; | |
| const parsePython = (content, filePath) => { | |
| const symbols = []; | |
| const edges = []; | |
| const group = path.dirname(filePath).split('/').slice(-1)[0] || 'root'; | |
| const basename = path.basename(filePath); | |
| // Class/function definitions | |
| const defMatches = content.matchAll(/(?:class|def)\s+(\w+)/g); | |
| for (const m of defMatches) { | |
| symbols.push({ id: `${basename}:${m[1]}`, label: m[1], group, file: filePath }); | |
| } | |
| // Imports | |
| const importMatches = content.matchAll(/(?:from|import)\s+(\w+)/g); | |
| const imports = []; | |
| for (const m of importMatches) { | |
| imports.push(m[1]); | |
| } | |
| return { symbols, edges, imports, calls: [], file: filePath, group }; | |
| }; | |
| const parseGo = (content, filePath) => { | |
| const symbols = []; | |
| const edges = []; | |
| const group = path.dirname(filePath).split('/').slice(-1)[0] || 'root'; | |
| const basename = path.basename(filePath); | |
| // Func declarations | |
| const funcMatches = content.matchAll(/func\s+(?:\([^)]+\)\s+)?(\w+)/g); | |
| for (const m of funcMatches) { | |
| symbols.push({ id: `${basename}:${m[1]}`, label: m[1], group, file: filePath }); | |
| } | |
| // Type declarations | |
| const typeMatches = content.matchAll(/type\s+(\w+)/g); | |
| for (const m of typeMatches) { | |
| symbols.push({ id: `${basename}:${m[1]}`, label: m[1], group, file: filePath, isType: true }); | |
| } | |
| return { symbols, edges, imports: [], calls: [], file: filePath, group }; | |
| }; | |
| const parseRust = (content, filePath) => { | |
| const symbols = []; | |
| const edges = []; | |
| const group = path.dirname(filePath).split('/').slice(-1)[0] || 'root'; | |
| const basename = path.basename(filePath); | |
| // fn declarations | |
| const fnMatches = content.matchAll(/(?:pub\s+)?fn\s+(\w+)/g); | |
| for (const m of fnMatches) { | |
| symbols.push({ id: `${basename}:${m[1]}`, label: m[1], group, file: filePath }); | |
| } | |
| // struct/enum/trait | |
| const typeMatches = content.matchAll(/(?:pub\s+)?(?:struct|enum|trait)\s+(\w+)/g); | |
| for (const m of typeMatches) { | |
| symbols.push({ id: `${basename}:${m[1]}`, label: m[1], group, file: filePath, isType: true }); | |
| } | |
| return { symbols, edges, imports: [], calls: [], file: filePath, group }; | |
| }; | |
| const parsers = { | |
| '.js': parseJS, | |
| '.jsx': parseJS, | |
| '.ts': parseJS, | |
| '.tsx': parseJS, | |
| '.mjs': parseJS, | |
| '.py': parsePython, | |
| '.go': parseGo, | |
| '.rs': parseRust, | |
| }; | |
| // Should ignore directories | |
| const shouldIgnore = (dirPath) => { | |
| const name = path.basename(dirPath); | |
| return ['node_modules', '.git', 'dist', 'build', 'vendor', 'target', '__pycache__', '.next', 'coverage'].includes(name); | |
| }; | |
| // Scan directory | |
| function scanDir(dir, files = []) { | |
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| const fullPath = path.join(dir, entry.name); | |
| if (entry.isDirectory()) { | |
| if (!shouldIgnore(fullPath)) { | |
| scanDir(fullPath, files); | |
| } | |
| } else if (entry.isFile()) { | |
| const ext = path.extname(entry.name); | |
| if (parsers[ext]) { | |
| files.push(fullPath); | |
| } | |
| } | |
| } | |
| return files; | |
| } | |
| // Analyze all files | |
| const files = scanDir(targetDir); | |
| console.error(`Found ${files.length} source files`); | |
| const allSymbols = []; | |
| const allEdges = []; | |
| const fileData = []; | |
| for (const file of files) { | |
| const ext = path.extname(file); | |
| const parser = parsers[ext]; | |
| if (!parser) continue; | |
| try { | |
| const content = fs.readFileSync(file, 'utf-8'); | |
| const result = parser(content, file); | |
| fileData.push(result); | |
| allSymbols.push(...result.symbols.map(s => ({ ...s, file }))); | |
| } catch (e) { | |
| console.error(`Error parsing ${file}: ${e.message}`); | |
| } | |
| } | |
| // Filter symbols if needed | |
| console.error(`Found ${allSymbols.length} symbols`); | |
| let filteredSymbols = allSymbols; | |
| if (exportsOnly) { | |
| // Only keep exported symbols (those with 'export' in their definition or default exports) | |
| filteredSymbols = allSymbols.filter(s => !s.isType && !s.label.startsWith('_')); | |
| console.error(`Filtered to ${filteredSymbols.length} exported symbols`); | |
| } | |
| if (filteredSymbols.length > maxNodes) { | |
| // Keep symbols from files with most connections | |
| const clusterFiles = new Map(); | |
| for (const sym of filteredSymbols) { | |
| if (!clusterFiles.has(sym.group)) clusterFiles.set(sym.group, []); | |
| clusterFiles.get(sym.group).push(sym); | |
| } | |
| // Sample evenly across clusters | |
| filteredSymbols = []; | |
| const perCluster = Math.floor(maxNodes / clusterFiles.size); | |
| for (const [cluster, syms] of clusterFiles) { | |
| filteredSymbols.push(...syms.slice(0, perCluster)); | |
| } | |
| console.error(`Limited to ${filteredSymbols.length} symbols across ${clusterFiles.size} clusters`); | |
| } | |
| // Create edges based on imports and references | |
| const symbolMap = new Map(); | |
| for (const sym of filteredSymbols) { | |
| symbolMap.set(sym.label, sym); | |
| } | |
| for (const fd of fileData) { | |
| // Link local imports | |
| for (const imp of fd.imports) { | |
| const targetFile = path.resolve(path.dirname(fd.file), imp); | |
| for (const sym of allSymbols) { | |
| if (sym.file === targetFile || sym.file === targetFile + '.ts' || sym.file === targetFile + '.js' || sym.file === targetFile + '/index.ts' || sym.file === targetFile + '/index.js') { | |
| allEdges.push({ from: fd.file, to: sym.file, type: 'import' }); | |
| } | |
| } | |
| } | |
| // Link calls within same file | |
| const fileSymbols = allSymbols.filter(s => s.file === fd.file); | |
| for (const call of fd.calls) { | |
| const targetSym = symbolMap.get(call); | |
| if (targetSym && targetSym.file !== fd.file) { | |
| allEdges.push({ from: fd.file, to: targetSym.file, type: 'call', symbol: call }); | |
| } | |
| } | |
| } | |
| // Dedupe edges | |
| const edgeSet = new Set(); | |
| const edges = allEdges.filter(e => { | |
| const key = `${e.from}|${e.to}`; | |
| if (edgeSet.has(key)) return false; | |
| edgeSet.add(key); | |
| return true; | |
| }); | |
| // Cluster by directory | |
| const clusters = new Map(); | |
| for (const sym of filteredSymbols) { | |
| const dir = path.dirname(sym.file).replace(targetDir, '').replace(/^\//, '') || 'root'; | |
| const parts = dir.split('/'); | |
| const cluster = parts.length >= 2 && parts[0] === 'src' ? parts.slice(1).join('/') : parts.slice(-1)[0]; | |
| sym.group = cluster || 'root'; | |
| clusters.set(sym.group, (clusters.get(sym.group) || 0) + 1); | |
| } | |
| // Assign colors to clusters | |
| const clusterColors = {}; | |
| const palette = ['#f472b6', '#60a5fa', '#fbbf24', '#a78bfa', '#34d399', '#f87171', '#2dd4bf', '#fb923c', '#818cf8', '#facc15']; | |
| const sortedClusters = [...clusters.entries()].sort((a, b) => b[1] - a[1]); | |
| sortedClusters.forEach(([cluster], i) => { | |
| clusterColors[cluster] = palette[i % palette.length]; | |
| }); | |
| console.error(`Found ${filteredSymbols.length} symbols in ${clusters.size} clusters with ${edges.length} edges`); | |
| // Generate HTML | |
| function generateHTML(symbols, edges, clusterColors, projectName) { | |
| const nodesJSON = JSON.stringify(symbols.map(s => ({ | |
| id: s.id, | |
| label: s.label, | |
| group: s.group, | |
| file: s.file.replace(targetDir, '').replace(/^\//, ''), | |
| }))); | |
| const edgesJSON = JSON.stringify(edges.map(e => ({ | |
| from: e.from.replace(targetDir, '').replace(/^\//, ''), | |
| to: e.to.replace(targetDir, '').replace(/^\//, ''), | |
| }))); | |
| const clusterDataJSON = JSON.stringify(sortedClusters.map(([name, count]) => ({ | |
| name, | |
| count, | |
| color: clusterColors[name] | |
| }))); | |
| return `<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>${projectName} - Codebase Graph</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: radial-gradient(ellipse at center, #1a1a2e 0%, #0a0a0a 100%); | |
| color: #fff; | |
| overflow: hidden; | |
| -webkit-overflow-scrolling: touch; | |
| } | |
| #container { display: flex; height: 100vh; width: 100vw; } | |
| #sidebar { | |
| width: 220px; | |
| background: rgba(20,20,30,0.98); | |
| padding: 12px; | |
| overflow-y: auto; | |
| border-right: 1px solid #333; | |
| flex-shrink: 0; | |
| -webkit-overflow-scrolling: touch; | |
| } | |
| #graph { flex: 1; position: relative; cursor: grab; touch-action: none; } | |
| #graph:active { cursor: grabbing; } | |
| h1 { font-size: 14px; margin: 0 0 10px 0; color: #fff; word-break: break-all; } | |
| h2 { font-size: 11px; margin: 12px 0 6px 0; color: #888; text-transform: uppercase; letter-spacing: 1px; } | |
| .stats { | |
| background: #2a2a3a; | |
| padding: 8px; | |
| border-radius: 6px; | |
| margin: 6px 0; | |
| } | |
| .stat-row { display: flex; justify-content: space-between; margin: 3px 0; font-size: 11px; } | |
| .stat-label { color: #888; } | |
| .stat-value { color: #4ade80; font-weight: 600; } | |
| .cluster-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 4px 6px; | |
| margin: 1px 0; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 11px; | |
| transition: background 0.2s; | |
| } | |
| .cluster-item:hover, .cluster-item:active { background: #2a2a3a; } | |
| .cluster-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } | |
| #instructions { | |
| font-size: 10px; | |
| color: #666; | |
| margin-top: 12px; | |
| padding-top: 10px; | |
| border-top: 1px solid #333; | |
| line-height: 1.4; | |
| } | |
| canvas { display: block; touch-action: none; } | |
| #tooltip { | |
| position: absolute; | |
| background: rgba(30,30,45,0.98); | |
| border: 1px solid #555; | |
| border-radius: 6px; | |
| padding: 8px 12px; | |
| font-size: 12px; | |
| pointer-events: none; | |
| display: none; | |
| z-index: 100; | |
| max-width: 260px; | |
| word-break: break-all; | |
| } | |
| #tooltip .file { color: #4ade80; font-weight: 600; } | |
| #tooltip .cluster { color: #888; font-size: 11px; margin-top: 4px; } | |
| #controls { | |
| position: absolute; | |
| bottom: 12px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| display: flex; | |
| gap: 6px; | |
| background: rgba(20,20,30,0.98); | |
| padding: 6px 10px; | |
| border-radius: 16px; | |
| } | |
| #controls button { | |
| background: #2a2a3a; | |
| border: 1px solid #444; | |
| color: #fff; | |
| padding: 6px 12px; | |
| border-radius: 12px; | |
| cursor: pointer; | |
| font-size: 11px; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| #controls button:active { background: #3a3a4a; } | |
| #controls button.active { background: #4ade80; color: #000; border-color: #4ade80; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="container"> | |
| <div id="sidebar"> | |
| <h1>${projectName}</h1> | |
| <div class="stats"> | |
| <div class="stat-row"><span class="stat-label">Symbols</span><span class="stat-value">${symbols.length}</span></div> | |
| <div class="stat-row"><span class="stat-label">Edges</span><span class="stat-value">${edges.length}</span></div> | |
| <div class="stat-row"><span class="stat-label">Clusters</span><span class="stat-value">${clusters.size}</span></div> | |
| </div> | |
| <h2>Clusters</h2> | |
| <div id="clusters"></div> | |
| <div id="instructions"> | |
| <strong>Controls:</strong><br> | |
| • Drag nodes to reposition<br> | |
| • Scroll to zoom<br> | |
| • Hover for details<br> | |
| <br> | |
| <strong>Tip:</strong> For large codebases,<br> | |
| use --max-nodes 300 or --exports-only | |
| </div> | |
| </div> | |
| <div id="graph"> | |
| <canvas id="canvas"></canvas> | |
| <div id="tooltip"><div class="file"></div><div class="cluster"></div></div> | |
| <div id="controls"> | |
| <button id="btn-reset">Reset</button> | |
| <button id="btn-center">Center</button> | |
| <button id="btn-pause">Pause</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const PHYSICS = { | |
| repulsion: 5000, // Higher = more spread | |
| attraction: 0.002, // Lower = looser connections | |
| centerGravity: 0.003, // Lower = more spread | |
| damping: 0.92, | |
| minDistance: 120, // Higher = more space between nodes | |
| maxVelocity: 20, | |
| }; | |
| const clusterColors = ${JSON.stringify(clusterColors)}; | |
| const nodesData = ${nodesJSON}; | |
| const edges = ${edgesJSON}; | |
| const clusterData = ${clusterDataJSON}; | |
| const nodeCount = nodesData.length; | |
| const spread = Math.max(600, Math.sqrt(nodeCount) * 80); // Wider for more nodes | |
| const nodes = nodesData.map((d, i) => ({ | |
| ...d, | |
| x: spread/2 + Math.random() * spread, | |
| y: spread/2 + Math.random() * spread * 0.7, | |
| vx: (Math.random() - 0.5) * 15, | |
| vy: (Math.random() - 0.5) * 15, | |
| radius: 12, | |
| })); | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const tooltip = document.getElementById('tooltip'); | |
| const graphEl = document.getElementById('graph'); | |
| let width, height, centerX, centerY; | |
| let scale = 1, offsetX = 0, offsetY = 0; | |
| let dragging = false, dragNode = null, lastMouseX, lastMouseY; | |
| let paused = false; | |
| function resize() { | |
| const rect = graphEl.getBoundingClientRect(); | |
| width = rect.width; height = rect.height; | |
| centerX = width / 2; centerY = height / 2; | |
| canvas.width = width * devicePixelRatio; | |
| canvas.height = height * devicePixelRatio; | |
| canvas.style.width = width + 'px'; | |
| canvas.style.height = height + 'px'; | |
| ctx.scale(devicePixelRatio, devicePixelRatio); | |
| } | |
| function applyPhysics() { | |
| if (paused) return; | |
| nodes.forEach(node => { | |
| const dx = centerX - node.x, dy = centerY - node.y; | |
| node.vx += dx * PHYSICS.centerGravity; | |
| node.vy += dy * PHYSICS.centerGravity; | |
| nodes.forEach(other => { | |
| if (node === other) return; | |
| const ddx = node.x - other.x, ddy = node.y - other.y; | |
| const dist = Math.sqrt(ddx * ddx + ddy * ddy) || PHYSICS.minDistance; | |
| if (dist < PHYSICS.minDistance * 3) { | |
| const force = PHYSICS.repulsion / (dist * dist); | |
| node.vx += (ddx / dist) * force; | |
| node.vy += (ddy / dist) * force; | |
| } | |
| }); | |
| }); | |
| edges.forEach(edge => { | |
| const source = nodes.find(n => n.id === edge.from || n.file === edge.from); | |
| const target = nodes.find(n => n.id === edge.to || n.file === edge.to); | |
| if (!source || !target) return; | |
| const dx = target.x - source.x, dy = target.y - source.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy) || 1; | |
| const force = dist * PHYSICS.attraction; | |
| source.vx += (dx / dist) * force; | |
| source.vy += (dy / dist) * force; | |
| target.vx -= (dx / dist) * force; | |
| target.vy -= (dy / dist) * force; | |
| }); | |
| nodes.forEach(node => { | |
| if (node === dragNode) return; | |
| node.vx *= PHYSICS.damping; | |
| node.vy *= PHYSICS.damping; | |
| const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy); | |
| if (speed > PHYSICS.maxVelocity) { | |
| node.vx = (node.vx / speed) * PHYSICS.maxVelocity; | |
| node.vy = (node.vy / speed) * PHYSICS.maxVelocity; | |
| } | |
| node.x += node.vx; | |
| node.y += node.vy; | |
| node.x = Math.max(100, Math.min(width - 100, node.x)); | |
| node.y = Math.max(100, Math.min(height - 100, node.y)); | |
| }); | |
| } | |
| function shadeColor(color, percent) { | |
| const num = parseInt(color.replace('#', ''), 16); | |
| const amt = Math.round(2.55 * percent); | |
| const R = (num >> 16) + amt, G = (num >> 8 & 0xFF) + amt, B = (num & 0xFF) + amt; | |
| return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + (B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1); | |
| } | |
| function draw() { | |
| ctx.clearRect(0, 0, width, height); | |
| ctx.save(); | |
| ctx.translate(offsetX, offsetY); | |
| ctx.scale(scale, scale); | |
| edges.forEach(edge => { | |
| const from = nodes.find(n => n.id === edge.from || n.file === edge.from); | |
| const to = nodes.find(n => n.id === edge.to || n.file === edge.to); | |
| if (!from || !to) return; | |
| const dx = to.x - from.x, dy = to.y - from.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| ctx.beginPath(); | |
| ctx.moveTo(from.x, from.y); | |
| ctx.lineTo(to.x, to.y); | |
| ctx.strokeStyle = 'rgba(100, 100, 120, 0.4)'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| }); | |
| nodes.forEach(node => { | |
| const color = clusterColors[node.group] || '#666'; | |
| const grad = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, 30); | |
| grad.addColorStop(0, color + '30'); | |
| grad.addColorStop(1, 'transparent'); | |
| ctx.beginPath(); | |
| ctx.arc(node.x, node.y, 30, 0, Math.PI * 2); | |
| ctx.fillStyle = grad; | |
| ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2); | |
| const ngrad = ctx.createRadialGradient(node.x - 3, node.y - 3, 0, node.x, node.y, node.radius); | |
| ngrad.addColorStop(0, color); | |
| ngrad.addColorStop(1, shadeColor(color, -30)); | |
| ctx.fillStyle = ngrad; | |
| ctx.fill(); | |
| ctx.strokeStyle = 'rgba(255,255,255,0.2)'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| ctx.fillStyle = '#aaa'; | |
| ctx.font = '9px -apple-system, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(node.label.length > 15 ? node.label.slice(0, 14) + '…' : node.label, node.x, node.y + node.radius + 12); | |
| }); | |
| ctx.restore(); | |
| } | |
| function animate() { applyPhysics(); draw(); requestAnimationFrame(animate); } | |
| function getNodeAt(mx, my) { | |
| const x = (mx - offsetX) / scale, y = (my - offsetY) / scale; | |
| for (let i = nodes.length - 1; i >= 0; i--) { | |
| const node = nodes[i]; | |
| const dx = x - node.x, dy = y - node.y; | |
| if (dx * dx + dy * dy < (node.radius + 5) * (node.radius + 5)) return node; | |
| } | |
| return null; | |
| } | |
| canvas.addEventListener('mousedown', e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left, my = e.clientY - rect.top; | |
| dragNode = getNodeAt(mx, my); | |
| if (dragNode) dragging = true; | |
| else { dragging = true; lastMouseX = mx; lastMouseY = my; } | |
| }); | |
| canvas.addEventListener('mousemove', e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left, my = e.clientY - rect.top; | |
| if (dragging && dragNode) { | |
| dragNode.x = (mx - offsetX) / scale; | |
| dragNode.y = (my - offsetY) / scale; | |
| dragNode.vx = 0; dragNode.vy = 0; | |
| } else if (dragging) { | |
| offsetX += mx - lastMouseX; offsetY += my - lastMouseY; | |
| lastMouseX = mx; lastMouseY = my; | |
| } else { | |
| const node = getNodeAt(mx, my); | |
| if (node) { | |
| tooltip.style.display = 'block'; | |
| tooltip.style.left = (mx + 15) + 'px'; | |
| tooltip.style.top = (my + 15) + 'px'; | |
| tooltip.querySelector('.file').textContent = node.file; | |
| tooltip.querySelector('.cluster').textContent = 'Cluster: ' + node.group; | |
| } else tooltip.style.display = 'none'; | |
| } | |
| }); | |
| canvas.addEventListener('mouseup', () => { dragging = false; dragNode = null; }); | |
| canvas.addEventListener('mouseleave', () => { tooltip.style.display = 'none'; dragging = false; dragNode = null; }); | |
| canvas.addEventListener('wheel', e => { e.preventDefault(); scale *= e.deltaY > 0 ? 0.9 : 1.1; scale = Math.max(0.3, Math.min(3, scale)); }); | |
| // Touch support for mobile Safari | |
| let lastTouchDist = null; | |
| canvas.addEventListener('touchstart', e => { | |
| e.preventDefault(); | |
| const rect = canvas.getBoundingClientRect(); | |
| if (e.touches.length === 1) { | |
| const mx = e.touches[0].clientX - rect.left, my = e.touches[0].clientY - rect.top; | |
| dragNode = getNodeAt(mx, my); | |
| if (dragNode) dragging = true; | |
| else { dragging = true; lastMouseX = mx; lastMouseY = my; } | |
| } else if (e.touches.length === 2) { | |
| const dx = e.touches[0].clientX - e.touches[1].clientX; | |
| const dy = e.touches[0].clientY - e.touches[1].clientY; | |
| lastTouchDist = Math.sqrt(dx * dx + dy * dy); | |
| } | |
| }, { passive: false }); | |
| canvas.addEventListener('touchmove', e => { | |
| e.preventDefault(); | |
| const rect = canvas.getBoundingClientRect(); | |
| if (e.touches.length === 1 && dragging) { | |
| const mx = e.touches[0].clientX - rect.left, my = e.touches[0].clientY - rect.top; | |
| if (dragNode) { | |
| dragNode.x = (mx - offsetX) / scale; | |
| dragNode.y = (my - offsetY) / scale; | |
| dragNode.vx = 0; dragNode.vy = 0; | |
| } else { | |
| offsetX += mx - lastMouseX; offsetY += my - lastMouseY; | |
| lastMouseX = mx; lastMouseY = my; | |
| } | |
| } else if (e.touches.length === 2 && lastTouchDist) { | |
| const dx = e.touches[0].clientX - e.touches[1].clientX; | |
| const dy = e.touches[0].clientY - e.touches[1].clientY; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| scale *= dist / lastTouchDist; | |
| scale = Math.max(0.3, Math.min(3, scale)); | |
| lastTouchDist = dist; | |
| } | |
| }, { passive: false }); | |
| canvas.addEventListener('touchend', e => { | |
| if (e.touches.length === 0) { dragging = false; dragNode = null; lastTouchDist = null; } | |
| }, { passive: false }); | |
| document.getElementById('btn-reset').addEventListener('click', () => { | |
| nodes.forEach(n => { n.x = 100 + Math.random() * 600; n.y = 100 + Math.random() * 400; n.vx = (Math.random() - 0.5) * 10; n.vy = (Math.random() - 0.5) * 10; }); | |
| scale = 1; offsetX = 0; offsetY = 0; | |
| }); | |
| document.getElementById('btn-center').addEventListener('click', () => { | |
| let cx = 0, cy = 0; | |
| nodes.forEach(n => { cx += n.x; cy += n.y; }); | |
| cx /= nodes.length; cy /= nodes.length; | |
| offsetX = centerX - cx; offsetY = centerY - cy; | |
| }); | |
| document.getElementById('btn-pause').addEventListener('click', e => { | |
| paused = !paused; | |
| e.target.textContent = paused ? 'Play' : 'Pause'; | |
| e.target.classList.toggle('active', paused); | |
| }); | |
| const clusterList = document.getElementById('clusters'); | |
| clusterData.forEach(c => { | |
| const item = document.createElement('div'); | |
| item.className = 'cluster-item'; | |
| item.innerHTML = '<div class="cluster-dot" style="background:' + c.color + '"></div><span>' + c.name + '</span><span style="margin-left:auto;color:#666">' + c.count + '</span>'; | |
| item.addEventListener('click', () => { | |
| const node = nodes.find(n => n.group === c.name); | |
| if (node) { offsetX = centerX - node.x; offsetY = centerY - node.y; } | |
| }); | |
| clusterList.appendChild(item); | |
| }); | |
| window.addEventListener('resize', () => { resize(); draw(); }); | |
| resize(); | |
| animate(); | |
| </script> | |
| </body> | |
| </html>`; | |
| } | |
| // Generate HTML | |
| const projectName = path.basename(targetDir); | |
| const html = generateHTML(filteredSymbols, edges, clusterColors, projectName); | |
| // Output | |
| if (outputMode === 'url' || shouldPublish) { | |
| // Publish to zenbin | |
| const tempFile = `/tmp/codebase-graph-${Date.now()}.json`; | |
| const payload = JSON.stringify({ title: `${projectName} Codebase Graph`, html }); | |
| fs.writeFileSync(tempFile, payload); | |
| try { | |
| const result = execSync(`curl -s -X POST "https://zenbin.org/v1/pages/codebase-${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}" -H "Content-Type: application/json" -d @${tempFile}`, { encoding: 'utf-8' }); | |
| const response = JSON.parse(result); | |
| if (response.url) { | |
| console.log(response.url); | |
| } else { | |
| console.error('Error:', response); | |
| console.log(html); | |
| } | |
| } catch (e) { | |
| console.error('Failed to publish:', e.message); | |
| console.log(html); | |
| } finally { | |
| fs.unlinkSync(tempFile); | |
| } | |
| } else if (outputMode === 'file') { | |
| const outFile = `${targetDir}/codebase-graph.html`; | |
| fs.writeFileSync(outFile, html); | |
| console.log(outFile); | |
| } else { | |
| // stdout | |
| console.log(html); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * Hive Message Flow Graph | |
| * | |
| * Visualizes communication flow between agents and channels | |
| * - Nodes: Agents and Channels | |
| * - Edges: Messages (showing who mentioned whom) | |
| */ | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| // Try to load lmdb from project's node_modules | |
| let lmdb; | |
| try { | |
| // First try from project directory | |
| const projectDir = path.resolve(process.argv[1]?.replace(/hive-flow-graph$/, '') || '.'); | |
| lmdb = require(path.join(projectDir, 'node_modules/lmdb')); | |
| } catch { | |
| try { | |
| // Try finding project root (look for package.json) | |
| let dir = process.cwd(); | |
| while (dir !== '/' && !fs.existsSync(path.join(dir, 'node_modules/lmdb'))) { | |
| dir = path.dirname(dir); | |
| } | |
| if (fs.existsSync(path.join(dir, 'node_modules/lmdb'))) { | |
| lmdb = require(path.join(dir, 'node_modules/lmdb')); | |
| } else { | |
| // Fall back to global | |
| lmdb = require('lmdb'); | |
| } | |
| } catch (e) { | |
| console.error('Error: Cannot find lmdb module.'); | |
| console.error('Please run this script from within a Hive project directory,'); | |
| console.error('or ensure lmdb is installed globally: npm install -g lmdb'); | |
| process.exit(1); | |
| } | |
| } | |
| const { open } = lmdb; | |
| // Parse args | |
| const args = process.argv.slice(2); | |
| let dbPath = './data/hive.db'; | |
| let shouldPublish = false; | |
| let outputMode = 'stdout'; | |
| let limitDays = 30; // Days of history to include | |
| let liveMode = false; // Connect to live SSE | |
| let sseEndpoint = null; | |
| for (let i = 0; i < args.length; i++) { | |
| if (args[i] === '--publish' || args[i] === '-p') { | |
| shouldPublish = true; | |
| outputMode = 'url'; | |
| } else if (args[i] === '--output' || args[i] === '-o') { | |
| outputMode = args[++i] || 'stdout'; | |
| } else if (args[i] === '--days' || args[i] === '-d') { | |
| limitDays = parseInt(args[++i]) || 30; | |
| } else if (args[i] === '--live' || args[i] === '-l') { | |
| liveMode = true; | |
| sseEndpoint = args[++i] || null; | |
| } else if (args[i] === '--help' || args[i] === '-h') { | |
| console.log(` | |
| Hive Message Flow Graph | |
| Usage: hive-flow-graph [options] [db-path] | |
| Options: | |
| -p, --publish Publish to zenbin.org | |
| -o, --output Output mode: stdout, file | |
| -d, --days Days of history to include (default: 30) | |
| -l, --live <url> Enable live SSE updates from endpoint | |
| -h, --help Show this help | |
| Examples: | |
| hive-flow-graph . # Static graph from DB | |
| hive-flow-graph ~/forge/hive --publish # Publish to zenbin | |
| hive-flow-graph . --live http://localhost:3000/events # Live updates | |
| hive-flow-graph . --days 7 # Last 7 days only | |
| `); | |
| process.exit(0); | |
| } else if (!args[i].startsWith('-')) { | |
| dbPath = args[i]; | |
| } | |
| } | |
| // Resolve database path | |
| const targetDir = path.resolve(dbPath); | |
| if (fs.existsSync(targetDir) && fs.statSync(targetDir).isDirectory()) { | |
| dbPath = path.join(targetDir, 'data/hive.db'); | |
| } else { | |
| dbPath = targetDir; | |
| } | |
| if (!fs.existsSync(dbPath)) { | |
| console.error(`Error: Database not found: ${dbPath}`); | |
| console.error('Specify a Hive project directory or path to hive.db'); | |
| process.exit(1); | |
| } | |
| console.error(`Opening database: ${dbPath}`); | |
| const db = open({ path: dbPath, compression: true }); | |
| // Collect nodes and edges | |
| const nodes = []; | |
| const edges = []; | |
| const edgeWeights = new Map(); // "from->to" -> count | |
| // Get all agents | |
| const agentIds = db.get('agents!list') || []; | |
| console.error(`Found ${agentIds.length} agents`); | |
| for (const agentId of agentIds) { | |
| const agent = db.get(`agent!${agentId}`); | |
| if (agent) { | |
| nodes.push({ | |
| id: `agent:${agentId}`, | |
| label: agent.name || agentId, | |
| type: 'agent', | |
| data: agent, | |
| }); | |
| } | |
| } | |
| // Get all channels | |
| const channelIds = db.get('channels!list') || []; | |
| console.error(`Found ${channelIds.length} channels`); | |
| for (const channelId of channelIds) { | |
| const channel = db.get(`channel!${channelId}`); | |
| if (channel) { | |
| nodes.push({ | |
| id: `channel:${channelId}`, | |
| label: channel.name || channelId, | |
| type: 'channel', | |
| data: channel, | |
| }); | |
| } | |
| } | |
| // Track mentions from posts | |
| const mentions = []; | |
| // Get posts by channel to find agent-channel relationships | |
| for (const channelId of channelIds) { | |
| const postIds = db.get(`posts!channel!${channelId}`) || []; | |
| for (const postId of postIds) { | |
| const post = db.get(`post!${postId}`); | |
| if (!post) continue; | |
| // Age filter | |
| const postTime = post.timestamp ? new Date(post.timestamp).getTime() : Date.now(); | |
| const cutoff = Date.now() - (limitDays * 24 * 60 * 60 * 1000); | |
| if (postTime < cutoff) continue; | |
| const authorId = post.authorId || post.agentId; | |
| if (!authorId) continue; | |
| // Edge: Author -> Channel (they posted here) | |
| const authorKey = `agent:${authorId}`; | |
| const channelKey = `channel:${channelId}`; | |
| const postEdgeKey = `${authorKey}->${channelKey}`; | |
| edgeWeights.set(postEdgeKey, (edgeWeights.get(postEdgeKey) || 0) + 1); | |
| // Parse mentions in content | |
| const content = post.content || ''; | |
| const mentionMatches = content.matchAll(/@([a-zA-Z0-9_-]+)/g); | |
| for (const match of mentionMatches) { | |
| const mentionedName = match[1]; | |
| // Find agent by name | |
| const mentionedAgent = nodes.find(n => n.type === 'agent' && n.label === mentionedName); | |
| if (mentionedAgent) { | |
| mentions.push({ | |
| from: authorKey, | |
| channel: channelKey, | |
| to: mentionedAgent.id, | |
| postId, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // Get mentions directly | |
| for (const agentId of agentIds) { | |
| const mentionIds = db.get(`mentions!agent!${agentId}`) || []; | |
| for (const mentionId of mentionIds) { | |
| const mention = db.get(`mention!${mentionId}`); | |
| if (!mention) continue; | |
| const mentionTime = mention.timestamp ? new Date(mention.timestamp).getTime() : Date.now(); | |
| const cutoff = Date.now() - (limitDays * 24 * 60 * 60 * 1000); | |
| if (mentionTime < cutoff) continue; | |
| const fromAgent = `agent:${mention.authorId || mention.fromAgentId}`; | |
| const toAgent = `agent:${agentId}`; | |
| const channel = mention.channelId ? `channel:${mention.channelId}` : null; | |
| // Edge: Agent mentions Agent in Channel | |
| // We represent this as two edges: | |
| // 1. Agent -> Channel (if channel exists) | |
| // 2. Channel -> Agent (the mention) | |
| if (channel) { | |
| const edge1Key = `${fromAgent}->${channel}`; | |
| const edge2Key = `${channel}->${toAgent}`; | |
| edgeWeights.set(edge1Key, (edgeWeights.get(edge1Key) || 0) + 1); | |
| edgeWeights.set(edge2Key, (edgeWeights.get(edge2Key) || 0) + 1); | |
| } | |
| } | |
| } | |
| // Convert to edges array | |
| for (const [key, weight] of edgeWeights) { | |
| const [from, to] = key.split('->'); | |
| edges.push({ from, to, weight }); | |
| } | |
| console.error(`Found ${edges.length} weighted edges`); | |
| // Close database | |
| db.close(); | |
| // Generate HTML | |
| function generateHTML(nodes, edges, projectName, days, sseEndpoint = null) { | |
| const agentNodes = nodes.filter(n => n.type === 'agent'); | |
| const channelNodes = nodes.filter(n => n.type === 'channel'); | |
| const nodesJSON = JSON.stringify(nodes.map(n => ({ | |
| id: n.id, | |
| label: n.label, | |
| type: n.type, | |
| }))); | |
| const edgesJSON = JSON.stringify(edges.map(e => ({ | |
| from: e.from, | |
| to: e.to, | |
| weight: e.weight, | |
| }))); | |
| // Live SSE script if endpoint provided | |
| const liveScript = sseEndpoint ? ` | |
| // Live SSE connection | |
| let eventSource; | |
| let reconnectAttempts = 0; | |
| const maxReconnectDelay = 30000; | |
| function connectSSE() { | |
| eventSource = new EventSource(sseEndpoint); | |
| eventSource.onopen = () => { reconnectAttempts = 0; updateConnectionStatus('connected'); }; | |
| eventSource.onerror = () => { eventSource.close(); updateConnectionStatus('disconnected'); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay); reconnectAttempts++; setTimeout(connectSSE, delay); }; | |
| eventSource.addEventListener('message', (e) => { try { handleNewMessage(JSON.parse(e.data)); } catch {} }); | |
| eventSource.addEventListener('post', (e) => { try { handleNewMessage(JSON.parse(e.data)); } catch {} }); | |
| eventSource.addEventListener('agent', (e) => { try { handleNewAgent(JSON.parse(e.data)); } catch {} }); | |
| eventSource.addEventListener('channel', (e) => { try { handleNewChannel(JSON.parse(e.data)); } catch {} }); | |
| } | |
| function updateConnectionStatus(s) { const el=document.getElementById('sse-status'); if(el){el.className='status-'+s; el.textContent=s==='connected'?'● Live':'○ Reconnecting...';} } | |
| function handleNewMessage(m) { const a='agent:'+(m.authorId||m.agentId), c='channel:'+m.channelId; ensureNode(a,m.authorName||m.authorId,'agent'); ensureNode(c,m.channelName||m.channelId,'channel'); addEdge(a,c,1); pulseNode(a); pulseNode(c); } | |
| function handleNewAgent(a) { ensureNode('agent:'+a.id,a.name||a.id,'agent'); } | |
| function handleNewChannel(c) { ensureNode('channel:'+c.id,c.name||c.id,'channel'); } | |
| function ensureNode(id,l,t) { if(!nodes.find(n=>n.id===id)) { nodes.push({id,label:l,type:t,x:centerX+(Math.random()-.5)*200,y:centerY+(Math.random()-.5)*200,vx:0,vy:0,radius:t==='agent'?10:14}); } } | |
| function addEdge(f,t,w) { let e=edges.find(e=>e.from===f&&e.to===t); e?e.weight+=w:edges.push({from:f,to:t,weight:w}); } | |
| function pulseNode(id) { const n=nodes.find(n=>n.id===id); if(n){n.pulse=Date.now();n.pulseAlpha=1;} } | |
| updateConnectionStatus('connecting'); | |
| connectSSE(); | |
| ` : ''; | |
| return `<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>${projectName} Message Flow</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: radial-gradient(ellipse at center, #0f0f1a 0%, #000 100%); | |
| color: #fff; | |
| overflow: hidden; | |
| -webkit-overflow-scrolling: touch; | |
| } | |
| #container { display: flex; height: 100vh; width: 100vw; } | |
| #sidebar { | |
| width: 200px; | |
| background: rgba(15,15,25,0.98); | |
| padding: 12px; | |
| overflow-y: auto; | |
| border-right: 1px solid #222; | |
| flex-shrink: 0; | |
| } | |
| #graph { flex: 1; position: relative; cursor: grab; touch-action: none; } | |
| h1 { font-size: 13px; margin: 0 0 8px 0; color: #fff; } | |
| h2 { font-size: 10px; margin: 12px 0 6px 0; color: #666; text-transform: uppercase; letter-spacing: 1px; } | |
| .stats { background: #151520; padding: 8px; border-radius: 4px; margin: 6px 0; } | |
| .stat-row { display: flex; justify-content: space-between; margin: 2px 0; font-size: 10px; } | |
| .stat-label { color: #666; } | |
| .stat-value { color: #4ade80; font-weight: 600; } | |
| .legend { margin: 8px 0; } | |
| .legend-item { display: flex; align-items: center; gap: 6px; font-size: 10px; margin: 4px 0; } | |
| .legend-dot { width: 12px; height: 12px; border-radius: 50%; } | |
| .node-list { max-height: 200px; overflow-y: auto; } | |
| .node-item { font-size: 10px; padding: 2px 4px; margin: 1px 0; border-radius: 3px; cursor: pointer; } | |
| .node-item:hover { background: #1a1a2a; } | |
| .node-item.agent { color: #60a5fa; } | |
| .node-item.channel { color: #f472b6; } | |
| #controls { | |
| position: absolute; | |
| bottom: 12px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| display: flex; | |
| gap: 6px; | |
| background: rgba(15,15,25,0.95); | |
| padding: 6px 10px; | |
| border-radius: 16px; | |
| } | |
| button { | |
| background: #1a1a2a; | |
| border: 1px solid #333; | |
| color: #fff; | |
| padding: 6px 12px; | |
| border-radius: 12px; | |
| cursor: pointer; | |
| font-size: 11px; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| button:active { background: #252540; } | |
| #tooltip { | |
| position: absolute; | |
| background: rgba(20,20,30,0.98); | |
| border: 1px solid #333; | |
| border-radius: 6px; | |
| padding: 8px 12px; | |
| font-size: 11px; | |
| pointer-events: none; | |
| display: none; | |
| z-index: 100; | |
| max-width: 250px; | |
| } | |
| #tooltip .name { color: #4ade80; font-weight: 600; } | |
| #tooltip .type { color: #888; font-size: 10px; margin-top: 2px; } | |
| #tooltip .count { color: #aaa; font-size: 10px; margin-top: 4px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="container"> | |
| <div id="sidebar"> | |
| <h1>${projectName} Flow</h1> | |
| <div class="stats"> | |
| <div class="stat-row"><span class="stat-label">Agents</span><span class="stat-value">${agentNodes.length}</span></div> | |
| <div class="stat-row"><span class="stat-label">Channels</span><span class="stat-value">${channelNodes.length}</span></div> | |
| <div class="stat-row"><span class="stat-label">Interactions</span><span class="stat-value">${edges.reduce((sum, e) => sum + e.weight, 0)}</span></div> | |
| <div class="stat-row"><span class="stat-label">Period</span><span class="stat-value">${days} days</span></div> | |
| </div> | |
| <div class="legend"> | |
| <div class="legend-item"><div class="legend-dot" style="background:#60a5fa"></div>Agent</div> | |
| <div class="legend-item"><div class="legend-dot" style="background:#f472b6"></div>Channel</div> | |
| </div> | |
| ` + (sseEndpoint ? ` <div id="sse-status" class="status-connecting" style="font-size:10px;padding:4px 8px;margin-bottom:8px;background:#151520;border-radius:4px;">connecting...</div>` : '') + ` | |
| <h2>Agents</h2> | |
| <div class="node-list" id="agent-list"></div> | |
| <h2>Channels</h2> | |
| <div class="node-list" id="channel-list"></div> | |
| </div> | |
| <div id="graph"> | |
| <canvas id="canvas"></canvas> | |
| <div id="tooltip"><div class="name"></div><div class="type"></div><div class="count"></div></div> | |
| <div id="controls"> | |
| <button id="btn-reset">Reset</button> | |
| <button id="btn-center">Center</button> | |
| <button id="btn-pause">Pause</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const PHYSICS = { repulsion: 3000, attraction: 0.004, centerGravity: 0.003, damping: 0.92, minDistance: 80, maxVelocity: 15 }; | |
| const nodes = ${nodesJSON}; | |
| const edges = ${edgesJSON}; | |
| // Assign positions in two circles (agents inner, channels outer) | |
| const agents = nodes.filter(n => n.type === 'agent'); | |
| const channels = nodes.filter(n => n.type === 'channel'); | |
| const centerX = 400, centerY = 300; | |
| const agentRadius = 150; | |
| const channelRadius = 280; | |
| agents.forEach((n, i) => { | |
| const angle = (2 * Math.PI * i) / agents.length; | |
| n.x = centerX + Math.cos(angle) * agentRadius + (Math.random() - 0.5) * 30; | |
| n.y = centerY + Math.sin(angle) * agentRadius + (Math.random() - 0.5) * 30; | |
| n.vx = 0; n.vy = 0; | |
| n.radius = 10; | |
| }); | |
| channels.forEach((n, i) => { | |
| const angle = (2 * Math.PI * i) / channels.length + Math.PI / channels.length; | |
| n.x = centerX + Math.cos(angle) * channelRadius + (Math.random() - 0.5) * 40; | |
| n.y = centerY + Math.sin(angle) * channelRadius + (Math.random() - 0.5) * 40; | |
| n.vx = 0; n.vy = 0; | |
| n.radius = 14; | |
| }); | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const tooltip = document.getElementById('tooltip'); | |
| const graphEl = document.getElementById('graph'); | |
| let width, height, centerXCanvas, centerYCanvas; | |
| let scale = 1, offsetX = 0, offsetY = 0; | |
| let dragging = false, dragNode = null, lastMouseX, lastMouseY; | |
| let paused = false; | |
| function resize() { | |
| const rect = graphEl.getBoundingClientRect(); | |
| width = rect.width; height = rect.height; | |
| centerXCanvas = width / 2; centerYCanvas = height / 2; | |
| canvas.width = width * devicePixelRatio; | |
| canvas.height = height * devicePixelRatio; | |
| canvas.style.width = width + 'px'; | |
| canvas.style.height = height + 'px'; | |
| ctx.scale(devicePixelRatio, devicePixelRatio); | |
| } | |
| function applyPhysics() { | |
| if (paused) return; | |
| nodes.forEach(node => { | |
| const dx = centerXCanvas - node.x, dy = centerYCanvas - node.y; | |
| node.vx += dx * PHYSICS.centerGravity; | |
| node.vy += dy * PHYSICS.centerGravity; | |
| nodes.forEach(other => { | |
| if (node === other) return; | |
| const ddx = node.x - other.x, ddy = node.y - other.y; | |
| const dist = Math.sqrt(ddx * ddx + ddy * ddy) || PHYSICS.minDistance; | |
| if (dist < PHYSICS.minDistance * 2.5) { | |
| const force = PHYSICS.repulsion / (dist * dist); | |
| node.vx += (ddx / dist) * force; | |
| node.vy += (ddy / dist) * force; | |
| } | |
| }); | |
| }); | |
| edges.forEach(edge => { | |
| const source = nodes.find(n => n.id === edge.from); | |
| const target = nodes.find(n => n.id === edge.to); | |
| if (!source || !target) return; | |
| const dx = target.x - source.x, dy = target.y - source.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy) || 1; | |
| const force = dist * PHYSICS.attraction * Math.log(edge.weight + 1); | |
| source.vx += (dx / dist) * force; | |
| source.vy += (dy / dist) * force; | |
| target.vx -= (dx / dist) * force; | |
| target.vy -= (dy / dist) * force; | |
| }); | |
| nodes.forEach(node => { | |
| if (node === dragNode) return; | |
| node.vx *= PHYSICS.damping; | |
| node.vy *= PHYSICS.damping; | |
| const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy); | |
| if (speed > PHYSICS.maxVelocity) { | |
| node.vx = (node.vx / speed) * PHYSICS.maxVelocity; | |
| node.vy = (node.vy / speed) * PHYSICS.maxVelocity; | |
| } | |
| node.x += node.vx; | |
| node.y += node.vy; | |
| }); | |
| } | |
| function draw() { | |
| ctx.clearRect(0, 0, width, height); | |
| ctx.save(); | |
| ctx.translate(offsetX, offsetY); | |
| ctx.scale(scale, scale); | |
| // Draw edges with weight-based opacity | |
| edges.forEach(edge => { | |
| const from = nodes.find(n => n.id === edge.from); | |
| const to = nodes.find(n => n.id === edge.to); | |
| if (!from || !to) return; | |
| const alpha = Math.min(0.8, 0.2 + edge.weight * 0.05); | |
| ctx.beginPath(); | |
| ctx.moveTo(from.x, from.y); | |
| ctx.lineTo(to.x, to.y); | |
| ctx.strokeStyle = 'rgba(100, 140, 180, ' + alpha + ')'; | |
| ctx.lineWidth = Math.min(3, 0.5 + edge.weight * 0.3); | |
| ctx.stroke(); | |
| // Arrow | |
| const angle = Math.atan2(to.y - from.y, to.x - from.x); | |
| const arrowDist = to.radius + 5; | |
| const ax = to.x - Math.cos(angle) * arrowDist; | |
| const ay = to.y - Math.sin(angle) * arrowDist; | |
| const arrowLen = 5 + edge.weight; | |
| ctx.beginPath(); | |
| ctx.moveTo(ax, ay); | |
| ctx.lineTo(ax - arrowLen * Math.cos(angle - 0.4), ay - arrowLen * Math.sin(angle - 0.4)); | |
| ctx.lineTo(ax - arrowLen * Math.cos(angle + 0.4), ay - arrowLen * Math.sin(angle + 0.4)); | |
| ctx.closePath(); | |
| ctx.fillStyle = 'rgba(100, 140, 180, ' + alpha + ')'; | |
| ctx.fill(); | |
| }); | |
| // Draw nodes | |
| nodes.forEach(node => { | |
| const color = node.type === 'agent' ? '#60a5fa' : '#f472b6'; | |
| // Glow | |
| const grad = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, node.radius * 3); | |
| grad.addColorStop(0, color + '40'); | |
| grad.addColorStop(1, 'transparent'); | |
| ctx.beginPath(); | |
| ctx.arc(node.x, node.y, node.radius * 3, 0, Math.PI * 2); | |
| ctx.fillStyle = grad; | |
| ctx.fill(); | |
| // Node | |
| ctx.beginPath(); | |
| ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| ctx.strokeStyle = 'rgba(255,255,255,0.3)'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| // Label | |
| ctx.fillStyle = '#ccc'; | |
| ctx.font = '10px -apple-system, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(node.label.length > 12 ? node.label.slice(0, 11) + '…' : node.label, node.x, node.y + node.radius + 12); | |
| }); | |
| ctx.restore(); | |
| } | |
| function animate() { applyPhysics(); draw(); requestAnimationFrame(animate); } | |
| function getNodeAt(mx, my) { | |
| const x = (mx - offsetX) / scale, y = (my - offsetY) / scale; | |
| for (let i = nodes.length - 1; i >= 0; i--) { | |
| const node = nodes[i]; | |
| const dx = x - node.x, dy = y - node.y; | |
| if (dx * dx + dy * dy < (node.radius + 5) * (node.radius + 5)) return node; | |
| } | |
| return null; | |
| } | |
| canvas.addEventListener('mousedown', e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left, my = e.clientY - rect.top; | |
| dragNode = getNodeAt(mx, my); | |
| if (dragNode) dragging = true; | |
| else { dragging = true; lastMouseX = mx; lastMouseY = my; } | |
| }); | |
| canvas.addEventListener('mousemove', e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left, my = e.clientY - rect.top; | |
| if (dragging && dragNode) { | |
| dragNode.x = (mx - offsetX) / scale; | |
| dragNode.y = (my - offsetY) / scale; | |
| dragNode.vx = 0; dragNode.vy = 0; | |
| } else if (dragging) { | |
| offsetX += mx - lastMouseX; offsetY += my - lastMouseY; | |
| lastMouseX = mx; lastMouseY = my; | |
| } else { | |
| const node = getNodeAt(mx, my); | |
| if (node) { | |
| tooltip.style.display = 'block'; | |
| tooltip.style.left = (mx + 15) + 'px'; | |
| tooltip.style.top = (my + 15) + 'px'; | |
| tooltip.querySelector('.name').textContent = node.label; | |
| tooltip.querySelector('.type').textContent = node.type.charAt(0).toUpperCase() + node.type.slice(1); | |
| const inEdges = edges.filter(e => e.to === node.id); | |
| const outEdges = edges.filter(e => e.from === node.id); | |
| tooltip.querySelector('.count').textContent = inEdges.length + ' incoming, ' + outEdges.length + ' outgoing'; | |
| } else tooltip.style.display = 'none'; | |
| } | |
| }); | |
| canvas.addEventListener('mouseup', () => { dragging = false; dragNode = null; }); | |
| canvas.addEventListener('mouseleave', () => { tooltip.style.display = 'none'; dragging = false; dragNode = null; }); | |
| canvas.addEventListener('wheel', e => { e.preventDefault(); scale *= e.deltaY > 0 ? 0.9 : 1.1; scale = Math.max(0.3, Math.min(5, scale)); }); | |
| // Touch support | |
| let lastTouchDist = null; | |
| canvas.addEventListener('touchstart', e => { | |
| e.preventDefault(); | |
| const rect = canvas.getBoundingClientRect(); | |
| if (e.touches.length === 1) { | |
| const mx = e.touches[0].clientX - rect.left, my = e.touches[0].clientY - rect.top; | |
| dragNode = getNodeAt(mx, my); | |
| if (dragNode) dragging = true; | |
| else { dragging = true; lastMouseX = mx; lastMouseY = my; } | |
| } else if (e.touches.length === 2) { | |
| const dx = e.touches[0].clientX - e.touches[1].clientX; | |
| const dy = e.touches[0].clientY - e.touches[1].clientY; | |
| lastTouchDist = Math.sqrt(dx * dx + dy * dy); | |
| } | |
| }, { passive: false }); | |
| canvas.addEventListener('touchmove', e => { | |
| e.preventDefault(); | |
| const rect = canvas.getBoundingClientRect(); | |
| if (e.touches.length === 1 && dragging) { | |
| const mx = e.touches[0].clientX - rect.left, my = e.touches[0].clientY - rect.top; | |
| if (dragNode) { | |
| dragNode.x = (mx - offsetX) / scale; | |
| dragNode.y = (my - offsetY) / scale; | |
| dragNode.vx = 0; dragNode.vy = 0; | |
| } else { | |
| offsetX += mx - lastMouseX; offsetY += my - lastMouseY; | |
| lastMouseX = mx; lastMouseY = my; | |
| } | |
| } else if (e.touches.length === 2 && lastTouchDist) { | |
| const dx = e.touches[0].clientX - e.touches[1].clientX; | |
| const dy = e.touches[0].clientY - e.touches[1].clientY; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| scale *= dist / lastTouchDist; | |
| scale = Math.max(0.3, Math.min(5, scale)); | |
| lastTouchDist = dist; | |
| } | |
| }, { passive: false }); | |
| canvas.addEventListener('touchend', e => { | |
| if (e.touches.length === 0) { dragging = false; dragNode = null; lastTouchDist = null; } | |
| }, { passive: false }); | |
| document.getElementById('btn-reset').addEventListener('click', () => { | |
| agents.forEach((n, i) => { | |
| const angle = (2 * Math.PI * i) / agents.length; | |
| n.x = centerX + Math.cos(angle) * agentRadius + (Math.random() - 0.5) * 30; | |
| n.y = centerY + Math.sin(angle) * agentRadius + (Math.random() - 0.5) * 30; | |
| n.vx = 0; n.vy = 0; | |
| }); | |
| channels.forEach((n, i) => { | |
| const angle = (2 * Math.PI * i) / channels.length + Math.PI / channels.length; | |
| n.x = centerX + Math.cos(angle) * channelRadius + (Math.random() - 0.5) * 40; | |
| n.y = centerY + Math.sin(angle) * channelRadius + (Math.random() - 0.5) * 40; | |
| n.vx = 0; n.vy = 0; | |
| }); | |
| scale = 1; offsetX = 0; offsetY = 0; | |
| }); | |
| document.getElementById('btn-center').addEventListener('click', () => { | |
| let cx = 0, cy = 0; | |
| nodes.forEach(n => { cx += n.x; cy += n.y; }); | |
| cx /= nodes.length; cy /= nodes.length; | |
| offsetX = centerXCanvas - cx; offsetY = centerYCanvas - cy; | |
| }); | |
| document.getElementById('btn-pause').addEventListener('click', e => { | |
| paused = !paused; | |
| e.target.textContent = paused ? 'Play' : 'Pause'; | |
| }); | |
| // Populate sidebar lists | |
| const agentList = document.getElementById('agent-list'); | |
| agents.forEach(n => { | |
| const item = document.createElement('div'); | |
| item.className = 'node-item agent'; | |
| item.textContent = n.label; | |
| item.addEventListener('click', () => { offsetX = centerXCanvas - n.x; offsetY = centerYCanvas - n.y; }); | |
| agentList.appendChild(item); | |
| }); | |
| const channelList = document.getElementById('channel-list'); | |
| channels.forEach(n => { | |
| const item = document.createElement('div'); | |
| item.className = 'node-item channel'; | |
| item.textContent = n.label; | |
| item.addEventListener('click', () => { offsetX = centerXCanvas - n.x; offsetY = centerYCanvas - n.y; }); | |
| channelList.appendChild(item); | |
| }); | |
| window.addEventListener('resize', () => { resize(); draw(); }); | |
| resize(); | |
| animate(); | |
| ${liveScript} | |
| </script> | |
| </body> | |
| </html>`; | |
| } | |
| const projectName = path.basename(path.dirname(dbPath)) || 'Hive'; | |
| const html = generateHTML(nodes, edges, projectName, limitDays, sseEndpoint); | |
| if (outputMode === 'url' || shouldPublish) { | |
| const payload = JSON.stringify({ title: `${projectName} Message Flow`, html }); | |
| const https = require('https'); | |
| const req = https.request({ | |
| hostname: 'zenbin.org', | |
| path: '/v1/pages/hive-flow', | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' } | |
| }, res => { | |
| let data = ''; | |
| res.on('data', chunk => data += chunk); | |
| res.on('end', () => { | |
| try { | |
| const result = JSON.parse(data); | |
| if (result.url) console.log(result.url); | |
| else console.error('No URL in response:', data); | |
| } catch (e) { | |
| console.error('Failed to parse response:', data); | |
| } | |
| }); | |
| }); | |
| req.on('error', e => { | |
| console.error('Failed to publish:', e.message); | |
| process.exit(1); | |
| }); | |
| req.write(payload); | |
| req.end(); | |
| } else if (outputMode === 'file') { | |
| const outFile = `${path.dirname(dbPath)}/message-flow.html`; | |
| fs.writeFileSync(outFile, html); | |
| console.log(outFile); | |
| } else { | |
| console.log(html); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment