#!/usr/bin/env npx tsx /** * Tree-Aware Force Layout * * Generates a random tree (via generate_tree), computes a radial tree layout, * then applies gentle force refinement and writes node_positions.csv. * * Usage: npm run layout */ import { writeFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { generateTree } from "./generate_tree.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = join(__dirname, "..", "public"); // ══════════════════════════════════════════════════════════ // Configuration // ══════════════════════════════════════════════════════════ const ENABLE_FORCE_SIM = true; // Set to false to skip force simulation const ITERATIONS = 100; // Force iterations (gentle) const REPULSION_K = 80; // Repulsion strength (1% of original 8000) const EDGE_LENGTH = 120; // Desired edge rest length const ATTRACTION_K = 0.0002; // Spring stiffness for edges (1% of original 0.02) const THETA = 0.7; // Barnes-Hut accuracy const INITIAL_MAX_DISP = 15; // Starting max displacement const COOLING = 0.998; // Very slow cooling per iteration const MIN_DIST = 0.5; const PRINT_EVERY = 10; // Print progress every N iterations // Scale radius so the tree is nicely spread const RADIUS_PER_DEPTH = EDGE_LENGTH * 1.2; // ── Special nodes with longer parent-edges ── // Add vertex IDs here to give them longer edges to their parent. // These nodes (and all their descendants) will be pushed further out. const LONG_EDGE_NODES = new Set([ // e.g. 42, 99, 150 ]); const LONG_EDGE_MULTIPLIER = 3.0; // How many times longer than normal // ══════════════════════════════════════════════════════════ // Generate tree (in-memory) // ══════════════════════════════════════════════════════════ const { root, nodeCount: N, childrenOf, parentOf } = generateTree(); const nodeIds: number[] = []; for (let i = 0; i < N; i++) nodeIds.push(i); // Dense index mapping (identity since IDs are 0..N-1) const idToIdx = new Map(); for (let i = 0; i < N; i++) idToIdx.set(i, i); // Edge list as index pairs (child, parent) const edges: Array<[number, number]> = []; for (const [child, parent] of parentOf) { edges.push([child, parent]); } // Per-node neighbor list (for edge traversal) const neighbors: number[][] = Array.from({ length: N }, () => []); for (const [a, b] of edges) { neighbors[a].push(b); neighbors[b].push(a); } console.log(`Tree: ${N} nodes, ${edges.length} edges, root=${root}`); // ══════════════════════════════════════════════════════════ // Step 1: Radial tree layout (generous spacing, no crossings) // ══════════════════════════════════════════════════════════ const x = new Float64Array(N); const y = new Float64Array(N); const depth = new Uint32Array(N); const nodeRadius = new Float64Array(N); // cumulative radius from root // Compute subtree sizes const subtreeSize = new Uint32Array(N).fill(1); { const rootIdx = idToIdx.get(root)!; const stack: Array<{ idx: number; phase: "enter" | "exit" }> = [ { idx: rootIdx, phase: "enter" }, ]; while (stack.length > 0) { const { idx, phase } = stack.pop()!; if (phase === "enter") { stack.push({ idx, phase: "exit" }); const kids = childrenOf.get(nodeIds[idx]); if (kids) { for (const kid of kids) { stack.push({ idx: idToIdx.get(kid)!, phase: "enter" }); } } } else { const kids = childrenOf.get(nodeIds[idx]); if (kids) { for (const kid of kids) { subtreeSize[idx] += subtreeSize[idToIdx.get(kid)!]; } } } } } // Compute depths & max depth let maxDepth = 0; { const rootIdx = idToIdx.get(root)!; const stack: Array<{ idx: number; d: number }> = [{ idx: rootIdx, d: 0 }]; while (stack.length > 0) { const { idx, d } = stack.pop()!; depth[idx] = d; if (d > maxDepth) maxDepth = d; const kids = childrenOf.get(nodeIds[idx]); if (kids) { for (const kid of kids) { stack.push({ idx: idToIdx.get(kid)!, d: d + 1 }); } } } } // BFS radial assignment (cumulative radii to support per-edge lengths) { const rootIdx = idToIdx.get(root)!; x[rootIdx] = 0; y[rootIdx] = 0; nodeRadius[rootIdx] = 0; interface Entry { idx: number; d: number; aStart: number; aEnd: number; } const queue: Entry[] = [{ idx: rootIdx, d: 0, aStart: 0, aEnd: 2 * Math.PI }]; let head = 0; while (head < queue.length) { const { idx, d, aStart, aEnd } = queue[head++]; const kids = childrenOf.get(nodeIds[idx]); if (!kids || kids.length === 0) continue; // Sort children by subtree size (largest sectors together for balance) const sortedKids = [...kids].sort( (a, b) => (subtreeSize[idToIdx.get(b)!]) - (subtreeSize[idToIdx.get(a)!]) ); const totalWeight = sortedKids.reduce( (s, k) => s + subtreeSize[idToIdx.get(k)!], 0 ); let angle = aStart; for (const kid of sortedKids) { const kidIdx = idToIdx.get(kid)!; const w = subtreeSize[kidIdx]; const sector = (w / totalWeight) * (aEnd - aStart); const mid = angle + sector / 2; // Cumulative radius: parent's radius + edge step (longer for special nodes) const step = LONG_EDGE_NODES.has(kid) ? RADIUS_PER_DEPTH * LONG_EDGE_MULTIPLIER : RADIUS_PER_DEPTH; const r = nodeRadius[idx] + step; nodeRadius[kidIdx] = r; x[kidIdx] = r * Math.cos(mid); y[kidIdx] = r * Math.sin(mid); queue.push({ idx: kidIdx, d: d + 1, aStart: angle, aEnd: angle + sector }); angle += sector; } } } console.log(`Radial layout done (depth=${maxDepth}, radius_step=${RADIUS_PER_DEPTH})`); // ══════════════════════════════════════════════════════════ // Step 2: Gentle force refinement (preserves non-crossing) // ══════════════════════════════════════════════════════════ // Barnes-Hut quadtree for repulsion interface BHNode { cx: number; cy: number; mass: number; size: number; children: (BHNode | null)[]; bodyIdx: number; } function buildBHTree(): BHNode { let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (let i = 0; i < N; i++) { if (x[i] < minX) minX = x[i]; if (x[i] > maxX) maxX = x[i]; if (y[i] < minY) minY = y[i]; if (y[i] > maxY) maxY = y[i]; } const size = Math.max(maxX - minX, maxY - minY, 1) * 1.01; const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; const root: BHNode = { cx: 0, cy: 0, mass: 0, size, children: [null, null, null, null], bodyIdx: -1, }; for (let i = 0; i < N; i++) { insert(root, i, cx, cy, size); } return root; } function insert(node: BHNode, idx: number, ncx: number, ncy: number, ns: number): void { if (node.mass === 0) { node.bodyIdx = idx; node.cx = x[idx]; node.cy = y[idx]; node.mass = 1; return; } if (node.bodyIdx >= 0) { const old = node.bodyIdx; node.bodyIdx = -1; putInQuadrant(node, old, ncx, ncy, ns); } putInQuadrant(node, idx, ncx, ncy, ns); const tm = node.mass + 1; node.cx = (node.cx * node.mass + x[idx]) / tm; node.cy = (node.cy * node.mass + y[idx]) / tm; node.mass = tm; } function putInQuadrant(node: BHNode, idx: number, ncx: number, ncy: number, ns: number): void { const hs = ns / 2; const qx = x[idx] >= ncx ? 1 : 0; const qy = y[idx] >= ncy ? 1 : 0; const q = qy * 2 + qx; const ccx = ncx + (qx ? hs / 2 : -hs / 2); const ccy = ncy + (qy ? hs / 2 : -hs / 2); if (!node.children[q]) { node.children[q] = { cx: 0, cy: 0, mass: 0, size: hs, children: [null, null, null, null], bodyIdx: -1, }; } insert(node.children[q]!, idx, ccx, ccy, hs); } function repulse(node: BHNode, idx: number, fx: Float64Array, fy: Float64Array): void { if (node.mass === 0 || node.bodyIdx === idx) return; const dx = x[idx] - node.cx; const dy = y[idx] - node.cy; const d2 = dx * dx + dy * dy; const d = Math.sqrt(d2) || MIN_DIST; if (node.bodyIdx >= 0 || (node.size / d) < THETA) { const f = REPULSION_K * node.mass / (d2 + MIN_DIST); fx[idx] += (dx / d) * f; fy[idx] += (dy / d) * f; return; } for (const c of node.children) { if (c) repulse(c, idx, fx, fy); } } // ── Force simulation ── if (ENABLE_FORCE_SIM) { console.log(`Applying gentle forces (${ITERATIONS} steps, 1% strength)...`); const t0 = performance.now(); let maxDisp = INITIAL_MAX_DISP; for (let iter = 0; iter < ITERATIONS; iter++) { const fx = new Float64Array(N); const fy = new Float64Array(N); // 1. Repulsion const tree = buildBHTree(); for (let i = 0; i < N; i++) { repulse(tree, i, fx, fy); } // 2. Edge attraction (spring toward per-edge rest length) for (const [a, b] of edges) { const dx = x[b] - x[a]; const dy = y[b] - y[a]; const d = Math.sqrt(dx * dx + dy * dy) || MIN_DIST; const aId = nodeIds[a], bId = nodeIds[b]; const isLong = LONG_EDGE_NODES.has(aId) || LONG_EDGE_NODES.has(bId); const restLen = isLong ? EDGE_LENGTH * LONG_EDGE_MULTIPLIER : EDGE_LENGTH; const displacement = d - restLen; const f = ATTRACTION_K * displacement; const ux = dx / d, uy = dy / d; fx[a] += ux * f; fy[a] += uy * f; fx[b] -= ux * f; fy[b] -= uy * f; } // 3. Apply forces with displacement cap (cooling reduces it over time) for (let i = 0; i < N; i++) { const mag = Math.sqrt(fx[i] * fx[i] + fy[i] * fy[i]); if (mag > 0) { const cap = Math.min(maxDisp, mag) / mag; x[i] += fx[i] * cap; y[i] += fy[i] * cap; } } // 4. Cool down maxDisp *= COOLING; if ((iter + 1) % PRINT_EVERY === 0) { let totalForce = 0; for (let i = 0; i < N; i++) totalForce += Math.sqrt(fx[i] * fx[i] + fy[i] * fy[i]); console.log(` iter ${iter + 1}/${ITERATIONS} max_disp=${maxDisp.toFixed(2)} avg_force=${(totalForce / N).toFixed(2)}`); } } const elapsed = performance.now() - t0; console.log(`Force simulation done in ${(elapsed / 1000).toFixed(1)}s`); } else { console.log("Force simulation SKIPPED (ENABLE_FORCE_SIM = false)"); } // ══════════════════════════════════════════════════════════ // Write output // ══════════════════════════════════════════════════════════ // Write node positions const outLines: string[] = ["vertex,x,y"]; for (let i = 0; i < N; i++) { outLines.push(`${nodeIds[i]},${x[i]},${y[i]}`); } const outPath = join(PUBLIC_DIR, "node_positions.csv"); writeFileSync(outPath, outLines.join("\n") + "\n"); console.log(`Wrote ${N} positions to ${outPath}`); // Write edges (so the renderer can draw them) const edgeLines: string[] = ["source,target"]; for (const [child, parent] of parentOf) { edgeLines.push(`${child},${parent}`); } const edgesPath = join(PUBLIC_DIR, "edges.csv"); writeFileSync(edgesPath, edgeLines.join("\n") + "\n"); console.log(`Wrote ${edges.length} edges to ${edgesPath}`);