Skip to content

Instantly share code, notes, and snippets.

@sark1337
Last active March 15, 2026 16:35
Show Gist options
  • Select an option

  • Save sark1337/4aa625a5993be5b1e47bfc6e25179d70 to your computer and use it in GitHub Desktop.

Select an option

Save sark1337/4aa625a5993be5b1e47bfc6e25179d70 to your computer and use it in GitHub Desktop.
Interactive codebase graph visualization with force-directed physics
#!/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);
}
#!/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