Radial tree + Forces
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.direnv/
|
||||||
|
.envrc
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:lts-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependency definitions
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy the rest of the source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose the standard Vite port
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Compute layout, then start the dev server with --host for external access
|
||||||
|
CMD ["sh", "-c", "npm run layout && npm run dev -- --host"]
|
||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
command: sh -c "npx tsx scripts/compute_layout.ts && npm run dev -- --host"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules # Prevents local node_modules from overwriting the container's
|
||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1770464364,
|
||||||
|
"narHash": "sha256-z5NJPSBwsLf/OfD8WTmh79tlSU8XgIbwmk6qB1/TFzY=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "23d72dabcb3b12469f57b37170fcbc1789bd7457",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-25.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
28
flake.nix
Normal file
28
flake.nix
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
description = "basic";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, ... }:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
config = { allowUnfree = true; };
|
||||||
|
};
|
||||||
|
|
||||||
|
myJdk = pkgs.openjdk11; # Simplified reference, usually aliases correctly
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShell.x86_64-linux = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.vscode-fhs
|
||||||
|
pkgs.antigravity-fhs
|
||||||
|
pkgs.bashInteractive
|
||||||
|
];
|
||||||
|
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"layout": "tsx scripts/compute_layout.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webgpu/types": "^0.1.69",
|
"@webgpu/types": "^0.1.69",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"@vitejs/plugin-react": "5.1.1",
|
"@vitejs/plugin-react": "5.1.1",
|
||||||
"tailwindcss": "4.1.17",
|
"tailwindcss": "4.1.17",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
|
"tsx": "^4.0.0",
|
||||||
"vite": "7.2.4",
|
"vite": "7.2.4",
|
||||||
"vite-plugin-singlefile": "2.3.0"
|
"vite-plugin-singlefile": "2.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
100000
public/edges.csv
Normal file
100000
public/edges.csv
Normal file
File diff suppressed because it is too large
Load Diff
100001
public/node_positions.csv
Normal file
100001
public/node_positions.csv
Normal file
File diff suppressed because it is too large
Load Diff
354
scripts/compute_layout.ts
Normal file
354
scripts/compute_layout.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
#!/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<number>([
|
||||||
|
// 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<number, number>();
|
||||||
|
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}`);
|
||||||
61
scripts/generate_tree.ts
Normal file
61
scripts/generate_tree.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Random Tree Generator
|
||||||
|
*
|
||||||
|
* Generates a random tree with 1–MAX_CHILDREN children per node.
|
||||||
|
* Exports a function that returns the tree data in memory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Configuration
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const TARGET_NODES = 100000; // Approximate number of nodes to generate
|
||||||
|
const MAX_CHILDREN = 3; // Each node gets 1..MAX_CHILDREN children
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Tree data types
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export interface TreeData {
|
||||||
|
root: number;
|
||||||
|
nodeCount: number;
|
||||||
|
childrenOf: Map<number, number[]>;
|
||||||
|
parentOf: Map<number, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Generator
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function generateTree(): TreeData {
|
||||||
|
const childrenOf = new Map<number, number[]>();
|
||||||
|
const parentOf = new Map<number, number>();
|
||||||
|
|
||||||
|
const root = 0;
|
||||||
|
let nextId = 1;
|
||||||
|
const queue: number[] = [root];
|
||||||
|
let head = 0;
|
||||||
|
|
||||||
|
while (head < queue.length && nextId < TARGET_NODES) {
|
||||||
|
const parent = queue[head++];
|
||||||
|
const nKids = 1 + Math.floor(Math.random() * MAX_CHILDREN); // 1..MAX_CHILDREN
|
||||||
|
|
||||||
|
const kids: number[] = [];
|
||||||
|
for (let c = 0; c < nKids && nextId < TARGET_NODES; c++) {
|
||||||
|
const child = nextId++;
|
||||||
|
kids.push(child);
|
||||||
|
parentOf.set(child, parent);
|
||||||
|
queue.push(child);
|
||||||
|
}
|
||||||
|
childrenOf.set(parent, kids);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Generated tree: ${nextId} nodes, ${parentOf.size} edges, root=${root}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
nodeCount: nextId,
|
||||||
|
childrenOf,
|
||||||
|
parentOf,
|
||||||
|
};
|
||||||
|
}
|
||||||
154
src/App.tsx
154
src/App.tsx
@@ -3,7 +3,9 @@ import { Renderer } from "./renderer";
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [status, setStatus] = useState("Generating 10M particles & building spatial index…");
|
const rendererRef = useRef<Renderer | null>(null);
|
||||||
|
const [status, setStatus] = useState("Loading node positions…");
|
||||||
|
const [nodeCount, setNodeCount] = useState(0);
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
fps: 0,
|
fps: 0,
|
||||||
drawn: 0,
|
drawn: 0,
|
||||||
@@ -12,6 +14,11 @@ export default function App() {
|
|||||||
ptSize: 0,
|
ptSize: 0,
|
||||||
});
|
});
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [hoveredNode, setHoveredNode] = useState<{ x: number; y: number; screenX: number; screenY: number } | null>(null);
|
||||||
|
const [selectedNodes, setSelectedNodes] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Store mouse position in a ref so it can be accessed in render loop without re-renders
|
||||||
|
const mousePos = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -20,45 +27,135 @@ export default function App() {
|
|||||||
let renderer: Renderer;
|
let renderer: Renderer;
|
||||||
try {
|
try {
|
||||||
renderer = new Renderer(canvas);
|
renderer = new Renderer(canvas);
|
||||||
|
rendererRef.current = renderer;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build quadtree + upload (runs once, ~200ms)
|
let cancelled = false;
|
||||||
const buildMs = renderer.init();
|
|
||||||
setStatus("");
|
// Fetch CSVs, parse, and init renderer
|
||||||
console.log(`Init complete in ${buildMs.toFixed(0)}ms`);
|
(async () => {
|
||||||
|
try {
|
||||||
|
setStatus("Fetching data files…");
|
||||||
|
const [nodesResponse, edgesResponse] = await Promise.all([
|
||||||
|
fetch("/node_positions.csv"),
|
||||||
|
fetch("/edges.csv"),
|
||||||
|
]);
|
||||||
|
if (!nodesResponse.ok) throw new Error(`Failed to fetch nodes: ${nodesResponse.status}`);
|
||||||
|
if (!edgesResponse.ok) throw new Error(`Failed to fetch edges: ${edgesResponse.status}`);
|
||||||
|
|
||||||
|
const [nodesText, edgesText] = await Promise.all([
|
||||||
|
nodesResponse.text(),
|
||||||
|
edgesResponse.text(),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setStatus("Parsing positions…");
|
||||||
|
const nodeLines = nodesText.split("\n").slice(1).filter(l => l.trim().length > 0);
|
||||||
|
const count = nodeLines.length;
|
||||||
|
|
||||||
|
const xs = new Float32Array(count);
|
||||||
|
const ys = new Float32Array(count);
|
||||||
|
const vertexIds = new Uint32Array(count);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const parts = nodeLines[i].split(",");
|
||||||
|
vertexIds[i] = parseInt(parts[0], 10);
|
||||||
|
xs[i] = parseFloat(parts[1]);
|
||||||
|
ys[i] = parseFloat(parts[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Parsing edges…");
|
||||||
|
const edgeLines = edgesText.split("\n").slice(1).filter(l => l.trim().length > 0);
|
||||||
|
const edgeData = new Uint32Array(edgeLines.length * 2);
|
||||||
|
for (let i = 0; i < edgeLines.length; i++) {
|
||||||
|
const parts = edgeLines[i].split(",");
|
||||||
|
edgeData[i * 2] = parseInt(parts[0], 10);
|
||||||
|
edgeData[i * 2 + 1] = parseInt(parts[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setStatus("Building spatial index…");
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const buildMs = renderer.init(xs, ys, vertexIds, edgeData);
|
||||||
|
setNodeCount(renderer.getNodeCount());
|
||||||
|
setStatus("");
|
||||||
|
console.log(`Init complete: ${count.toLocaleString()} nodes, ${edgeLines.length.toLocaleString()} edges in ${buildMs.toFixed(0)}ms`);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// ── Input handling ──
|
// ── Input handling ──
|
||||||
let dragging = false;
|
let dragging = false;
|
||||||
|
let didDrag = false; // true if mouse moved significantly during drag
|
||||||
|
let downX = 0;
|
||||||
|
let downY = 0;
|
||||||
let lastX = 0;
|
let lastX = 0;
|
||||||
let lastY = 0;
|
let lastY = 0;
|
||||||
|
const DRAG_THRESHOLD = 5; // pixels
|
||||||
|
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
dragging = true;
|
dragging = true;
|
||||||
|
didDrag = false;
|
||||||
|
downX = e.clientX;
|
||||||
|
downY = e.clientY;
|
||||||
lastX = e.clientX;
|
lastX = e.clientX;
|
||||||
lastY = e.clientY;
|
lastY = e.clientY;
|
||||||
};
|
};
|
||||||
const onMove = (e: MouseEvent) => {
|
const onMove = (e: MouseEvent) => {
|
||||||
|
mousePos.current = { x: e.clientX, y: e.clientY };
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
|
|
||||||
|
// Check if we've moved enough to consider it a drag
|
||||||
|
const dx = e.clientX - downX;
|
||||||
|
const dy = e.clientY - downY;
|
||||||
|
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
||||||
|
didDrag = true;
|
||||||
|
}
|
||||||
|
|
||||||
renderer.pan(e.clientX - lastX, e.clientY - lastY);
|
renderer.pan(e.clientX - lastX, e.clientY - lastY);
|
||||||
lastX = e.clientX;
|
lastX = e.clientX;
|
||||||
lastY = e.clientY;
|
lastY = e.clientY;
|
||||||
};
|
};
|
||||||
const onUp = () => {
|
const onUp = (e: MouseEvent) => {
|
||||||
|
if (dragging && !didDrag) {
|
||||||
|
// This was a click, not a drag - handle selection
|
||||||
|
const node = renderer.findNodeIndexAt(e.clientX, e.clientY);
|
||||||
|
if (node) {
|
||||||
|
setSelectedNodes((prev: Set<number>) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(node.index)) {
|
||||||
|
next.delete(node.index); // Deselect if already selected
|
||||||
|
} else {
|
||||||
|
next.add(node.index); // Select
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
dragging = false;
|
dragging = false;
|
||||||
|
didDrag = false;
|
||||||
};
|
};
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const factor = e.deltaY > 0 ? 0.9 : 1 / 0.9;
|
const factor = e.deltaY > 0 ? 0.9 : 1 / 0.9;
|
||||||
renderer.zoomAt(factor, e.clientX, e.clientY);
|
renderer.zoomAt(factor, e.clientX, e.clientY);
|
||||||
};
|
};
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
setHoveredNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
canvas.addEventListener("mousedown", onDown);
|
canvas.addEventListener("mousedown", onDown);
|
||||||
window.addEventListener("mousemove", onMove);
|
window.addEventListener("mousemove", onMove);
|
||||||
window.addEventListener("mouseup", onUp);
|
window.addEventListener("mouseup", onUp);
|
||||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
canvas.addEventListener("mouseleave", onMouseLeave);
|
||||||
|
|
||||||
// ── Render loop ──
|
// ── Render loop ──
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
@@ -69,6 +166,14 @@ export default function App() {
|
|||||||
const result = renderer.render();
|
const result = renderer.render();
|
||||||
frameCount++;
|
frameCount++;
|
||||||
|
|
||||||
|
// Find hovered node using quadtree
|
||||||
|
const node = renderer.findNodeAt(mousePos.current.x, mousePos.current.y);
|
||||||
|
if (node) {
|
||||||
|
setHoveredNode({ ...node, screenX: mousePos.current.x, screenY: mousePos.current.y });
|
||||||
|
} else {
|
||||||
|
setHoveredNode(null);
|
||||||
|
}
|
||||||
|
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - lastTime >= 500) {
|
if (now - lastTime >= 500) {
|
||||||
const fps = (frameCount / (now - lastTime)) * 1000;
|
const fps = (frameCount / (now - lastTime)) * 1000;
|
||||||
@@ -88,14 +193,23 @@ export default function App() {
|
|||||||
raf = requestAnimationFrame(frame);
|
raf = requestAnimationFrame(frame);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
canvas.removeEventListener("mousedown", onDown);
|
canvas.removeEventListener("mousedown", onDown);
|
||||||
window.removeEventListener("mousemove", onMove);
|
window.removeEventListener("mousemove", onMove);
|
||||||
window.removeEventListener("mouseup", onUp);
|
window.removeEventListener("mouseup", onUp);
|
||||||
canvas.removeEventListener("wheel", onWheel);
|
canvas.removeEventListener("wheel", onWheel);
|
||||||
|
canvas.removeEventListener("mouseleave", onMouseLeave);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Sync selection state to renderer
|
||||||
|
useEffect(() => {
|
||||||
|
if (rendererRef.current) {
|
||||||
|
rendererRef.current.updateSelection(selectedNodes);
|
||||||
|
}
|
||||||
|
}, [selectedNodes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
|
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
|
||||||
<canvas
|
<canvas
|
||||||
@@ -160,10 +274,11 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>FPS: {stats.fps}</div>
|
<div>FPS: {stats.fps}</div>
|
||||||
<div>Drawn: {stats.drawn.toLocaleString()} / 10,000,000</div>
|
<div>Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}</div>
|
||||||
<div>Mode: {stats.mode}</div>
|
<div>Mode: {stats.mode}</div>
|
||||||
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
|
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
|
||||||
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
|
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
|
||||||
|
<div style={{ color: "#f80" }}>Selected: {selectedNodes.size}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -179,8 +294,31 @@ export default function App() {
|
|||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Drag to pan · Scroll to zoom
|
Drag to pan · Scroll to zoom · Click to select
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hover tooltip */}
|
||||||
|
{hoveredNode && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: hoveredNode.screenX + 15,
|
||||||
|
top: hoveredNode.screenY + 15,
|
||||||
|
background: "rgba(0,0,0,0.85)",
|
||||||
|
color: "#0ff",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: "12px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
border: "1px solid rgba(0,255,255,0.3)",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface Leaf {
|
|||||||
export function buildSpatialIndex(
|
export function buildSpatialIndex(
|
||||||
xs: Float32Array,
|
xs: Float32Array,
|
||||||
ys: Float32Array
|
ys: Float32Array
|
||||||
): { sorted: Float32Array; leaves: Leaf[] } {
|
): { sorted: Float32Array; leaves: Leaf[]; order: Uint32Array } {
|
||||||
const n = xs.length;
|
const n = xs.length;
|
||||||
const order = new Uint32Array(n);
|
const order = new Uint32Array(n);
|
||||||
for (let i = 0; i < n; i++) order[i] = i;
|
for (let i = 0; i < n; i++) order[i] = i;
|
||||||
@@ -109,5 +109,5 @@ export function buildSpatialIndex(
|
|||||||
sorted[i * 2 + 1] = ys[src];
|
sorted[i * 2 + 1] = ys[src];
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sorted, leaves };
|
return { sorted, leaves, order };
|
||||||
}
|
}
|
||||||
|
|||||||
363
src/renderer.ts
363
src/renderer.ts
@@ -32,6 +32,26 @@ void main() {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const SELECTED_FRAG = `#version 300 es
|
||||||
|
precision mediump float;
|
||||||
|
out vec4 o;
|
||||||
|
void main() {
|
||||||
|
vec2 c = gl_PointCoord * 2.0 - 1.0;
|
||||||
|
if (dot(c, c) > 1.0) discard;
|
||||||
|
o = vec4(1.0, 0.5, 0.0, 0.9); // orange for selected
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NEIGHBOR_FRAG = `#version 300 es
|
||||||
|
precision mediump float;
|
||||||
|
out vec4 o;
|
||||||
|
void main() {
|
||||||
|
vec2 c = gl_PointCoord * 2.0 - 1.0;
|
||||||
|
if (dot(c, c) > 1.0) discard;
|
||||||
|
o = vec4(1.0, 0.9, 0.0, 0.8); // yellow for neighbors
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
/* ── Types ──────────────────────────────────────────────── */
|
/* ── Types ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
export interface RenderStats {
|
export interface RenderStats {
|
||||||
@@ -43,10 +63,8 @@ export interface RenderStats {
|
|||||||
|
|
||||||
/* ── Constants ──────────────────────────────────────────── */
|
/* ── Constants ──────────────────────────────────────────── */
|
||||||
|
|
||||||
const COUNT = 10_000_000;
|
|
||||||
const EXTENT = 30_000; // particles span [-15000, 15000]
|
|
||||||
const WORLD_RADIUS = 4.0; // sphere world-space radius
|
const WORLD_RADIUS = 4.0; // sphere world-space radius
|
||||||
const MAX_DRAW = 600_000; // max particles to draw per frame
|
const MAX_DRAW = 2_000_000; // max particles to draw per frame
|
||||||
|
|
||||||
/* ── Renderer ───────────────────────────────────────────── */
|
/* ── Renderer ───────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -55,10 +73,18 @@ export class Renderer {
|
|||||||
private canvas: HTMLCanvasElement;
|
private canvas: HTMLCanvasElement;
|
||||||
private program: WebGLProgram;
|
private program: WebGLProgram;
|
||||||
private lineProgram: WebGLProgram;
|
private lineProgram: WebGLProgram;
|
||||||
|
private selectedProgram: WebGLProgram;
|
||||||
|
private neighborProgram: WebGLProgram;
|
||||||
private vao: WebGLVertexArrayObject;
|
private vao: WebGLVertexArrayObject;
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
private leaves: Leaf[] = [];
|
private leaves: Leaf[] = [];
|
||||||
|
private sorted: Float32Array = new Float32Array(0);
|
||||||
|
private nodeCount = 0;
|
||||||
|
private edgeCount = 0;
|
||||||
|
private neighborMap: Map<number, number[]> = new Map();
|
||||||
|
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
|
||||||
|
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
|
||||||
private maxPtSize = 256;
|
private maxPtSize = 256;
|
||||||
|
|
||||||
// Multi-draw extension
|
// Multi-draw extension
|
||||||
@@ -75,8 +101,24 @@ export class Renderer {
|
|||||||
private uCenterLine: WebGLUniformLocation;
|
private uCenterLine: WebGLUniformLocation;
|
||||||
private uScaleLine: WebGLUniformLocation;
|
private uScaleLine: WebGLUniformLocation;
|
||||||
|
|
||||||
|
private uCenterSelected: WebGLUniformLocation;
|
||||||
|
private uScaleSelected: WebGLUniformLocation;
|
||||||
|
private uPtSizeSelected: WebGLUniformLocation;
|
||||||
|
|
||||||
|
private uCenterNeighbor: WebGLUniformLocation;
|
||||||
|
private uScaleNeighbor: WebGLUniformLocation;
|
||||||
|
private uPtSizeNeighbor: WebGLUniformLocation;
|
||||||
|
|
||||||
private linesIbo: WebGLBuffer;
|
private linesIbo: WebGLBuffer;
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
private selectionIbo: WebGLBuffer;
|
||||||
|
private selectionCount = 0;
|
||||||
|
|
||||||
|
// Neighbors
|
||||||
|
private neighborIbo: WebGLBuffer;
|
||||||
|
private neighborCount = 0;
|
||||||
|
|
||||||
// Camera state
|
// Camera state
|
||||||
private cx = 0;
|
private cx = 0;
|
||||||
private cy = 0;
|
private cy = 0;
|
||||||
@@ -93,6 +135,8 @@ export class Renderer {
|
|||||||
// Compile programs
|
// Compile programs
|
||||||
this.program = this.compileProgram(VERT, FRAG);
|
this.program = this.compileProgram(VERT, FRAG);
|
||||||
this.lineProgram = this.compileProgram(VERT, LINE_FRAG);
|
this.lineProgram = this.compileProgram(VERT, LINE_FRAG);
|
||||||
|
this.selectedProgram = this.compileProgram(VERT, SELECTED_FRAG);
|
||||||
|
this.neighborProgram = this.compileProgram(VERT, NEIGHBOR_FRAG);
|
||||||
|
|
||||||
gl.useProgram(this.program);
|
gl.useProgram(this.program);
|
||||||
|
|
||||||
@@ -103,6 +147,14 @@ export class Renderer {
|
|||||||
this.uCenterLine = gl.getUniformLocation(this.lineProgram, "u_center")!;
|
this.uCenterLine = gl.getUniformLocation(this.lineProgram, "u_center")!;
|
||||||
this.uScaleLine = gl.getUniformLocation(this.lineProgram, "u_scale")!;
|
this.uScaleLine = gl.getUniformLocation(this.lineProgram, "u_scale")!;
|
||||||
|
|
||||||
|
this.uCenterSelected = gl.getUniformLocation(this.selectedProgram, "u_center")!;
|
||||||
|
this.uScaleSelected = gl.getUniformLocation(this.selectedProgram, "u_scale")!;
|
||||||
|
this.uPtSizeSelected = gl.getUniformLocation(this.selectedProgram, "u_ptSize")!;
|
||||||
|
|
||||||
|
this.uCenterNeighbor = gl.getUniformLocation(this.neighborProgram, "u_center")!;
|
||||||
|
this.uScaleNeighbor = gl.getUniformLocation(this.neighborProgram, "u_scale")!;
|
||||||
|
this.uPtSizeNeighbor = gl.getUniformLocation(this.neighborProgram, "u_ptSize")!;
|
||||||
|
|
||||||
// Query hardware max point size
|
// Query hardware max point size
|
||||||
const range = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) as Float32Array;
|
const range = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) as Float32Array;
|
||||||
this.maxPtSize = range[1] || 256;
|
this.maxPtSize = range[1] || 256;
|
||||||
@@ -119,6 +171,8 @@ export class Renderer {
|
|||||||
gl.bindVertexArray(null);
|
gl.bindVertexArray(null);
|
||||||
|
|
||||||
this.linesIbo = gl.createBuffer()!;
|
this.linesIbo = gl.createBuffer()!;
|
||||||
|
this.selectionIbo = gl.createBuffer()!;
|
||||||
|
this.neighborIbo = gl.createBuffer()!;
|
||||||
|
|
||||||
// Blending
|
// Blending
|
||||||
gl.enable(gl.BLEND);
|
gl.enable(gl.BLEND);
|
||||||
@@ -127,24 +181,27 @@ export class Renderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate 10M random particles, build quadtree, upload to GPU.
|
* Load particles from pre-computed positions and edges, build quadtree, upload to GPU.
|
||||||
|
* vertexIds: original vertex IDs from CSV (parallel to xs/ys)
|
||||||
|
* edges: flat array of [srcVertexId, dstVertexId, ...]
|
||||||
* Call once at startup. Returns build time in ms.
|
* Call once at startup. Returns build time in ms.
|
||||||
*/
|
*/
|
||||||
init(): number {
|
init(
|
||||||
|
xs: Float32Array,
|
||||||
|
ys: Float32Array,
|
||||||
|
vertexIds: Uint32Array,
|
||||||
|
edges: Uint32Array
|
||||||
|
): number {
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
|
const count = xs.length;
|
||||||
// Generate random positions as typed arrays (no object allocation)
|
const edgeCount = edges.length / 2;
|
||||||
const xs = new Float32Array(COUNT);
|
this.nodeCount = count;
|
||||||
const ys = new Float32Array(COUNT);
|
|
||||||
for (let i = 0; i < COUNT; i++) {
|
|
||||||
xs[i] = (Math.random() - 0.5) * EXTENT;
|
|
||||||
ys[i] = (Math.random() - 0.5) * EXTENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build quadtree (spatially sorts the array)
|
// Build quadtree (spatially sorts the array)
|
||||||
const { sorted, leaves } = buildSpatialIndex(xs, ys);
|
const { sorted, leaves, order } = buildSpatialIndex(xs, ys);
|
||||||
this.leaves = leaves;
|
this.leaves = leaves;
|
||||||
|
this.sorted = sorted;
|
||||||
|
|
||||||
// Pre-allocate arrays for render loop (zero-allocation rendering)
|
// Pre-allocate arrays for render loop (zero-allocation rendering)
|
||||||
this.visibleLeafIndices = new Uint32Array(leaves.length);
|
this.visibleLeafIndices = new Uint32Array(leaves.length);
|
||||||
@@ -156,56 +213,85 @@ export class Renderer {
|
|||||||
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
||||||
gl.bindVertexArray(null);
|
gl.bindVertexArray(null);
|
||||||
|
|
||||||
// Build relationships (lines)
|
// Build vertex ID → original input index mapping
|
||||||
const lineIndices = new Uint32Array(COUNT * 4);
|
const vertexIdToOriginal = new Map<number, number>();
|
||||||
for (let j = 0; j < leaves.length; j++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const lf = leaves[j];
|
vertexIdToOriginal.set(vertexIds[i], i);
|
||||||
const leafSize = lf.end - lf.start;
|
}
|
||||||
if (leafSize <= 1) continue;
|
|
||||||
|
|
||||||
for (let i = lf.start; i < lf.end; i++) {
|
// Build original input index → sorted index mapping
|
||||||
const x = sorted[i * 2];
|
// order[sortedIdx] = originalIdx, so invert it
|
||||||
const y = sorted[i * 2 + 1];
|
const originalToSorted = new Uint32Array(count);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
originalToSorted[order[i]] = i;
|
||||||
|
}
|
||||||
|
|
||||||
let best1 = -1, best2 = -1;
|
// Remap edges from vertex IDs to sorted indices
|
||||||
let dist1 = Infinity, dist2 = Infinity;
|
const lineIndices = new Uint32Array(edgeCount * 2);
|
||||||
|
let validEdges = 0;
|
||||||
|
for (let i = 0; i < edgeCount; i++) {
|
||||||
|
const srcId = edges[i * 2];
|
||||||
|
const dstId = edges[i * 2 + 1];
|
||||||
|
const srcOrig = vertexIdToOriginal.get(srcId);
|
||||||
|
const dstOrig = vertexIdToOriginal.get(dstId);
|
||||||
|
if (srcOrig === undefined || dstOrig === undefined) continue;
|
||||||
|
lineIndices[validEdges * 2] = originalToSorted[srcOrig];
|
||||||
|
lineIndices[validEdges * 2 + 1] = originalToSorted[dstOrig];
|
||||||
|
validEdges++;
|
||||||
|
}
|
||||||
|
this.edgeCount = validEdges;
|
||||||
|
|
||||||
// Find closest 2 points within a small local sliding window (spatially coherent)
|
// Build per-node neighbor list from edges for selection queries
|
||||||
const windowSize = Math.min(12, leafSize - 1);
|
const neighborMap = new Map<number, number[]>();
|
||||||
for (let w = 1; w <= windowSize; w++) {
|
for (let i = 0; i < validEdges; i++) {
|
||||||
const candidate = lf.start + ((i - lf.start + w) % leafSize);
|
const src = lineIndices[i * 2];
|
||||||
const cx = sorted[candidate * 2];
|
const dst = lineIndices[i * 2 + 1];
|
||||||
const cy = sorted[candidate * 2 + 1];
|
if (!neighborMap.has(src)) neighborMap.set(src, []);
|
||||||
const dx = x - cx;
|
neighborMap.get(src)!.push(dst);
|
||||||
const dy = y - cy;
|
if (!neighborMap.has(dst)) neighborMap.set(dst, []);
|
||||||
const d2 = dx*dx + dy*dy;
|
neighborMap.get(dst)!.push(src);
|
||||||
|
}
|
||||||
|
this.neighborMap = neighborMap;
|
||||||
|
|
||||||
if (d2 < dist1) {
|
// Build per-leaf edge index for efficient visible-only edge drawing
|
||||||
dist2 = dist1;
|
// Find which leaf each sorted index belongs to
|
||||||
best2 = best1;
|
const nodeToLeaf = new Uint32Array(count);
|
||||||
dist1 = d2;
|
for (let li = 0; li < leaves.length; li++) {
|
||||||
best1 = candidate;
|
const lf = leaves[li];
|
||||||
} else if (d2 < dist2) {
|
for (let j = lf.start; j < lf.end; j++) {
|
||||||
dist2 = d2;
|
nodeToLeaf[j] = li;
|
||||||
best2 = candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
if (best1 === -1) best1 = lf.start + ((i - lf.start + 1) % leafSize);
|
|
||||||
if (best2 === -1) best2 = lf.start + ((i - lf.start + 2) % leafSize);
|
|
||||||
|
|
||||||
const base = i * 4;
|
|
||||||
lineIndices[base + 0] = i;
|
|
||||||
lineIndices[base + 1] = best1;
|
|
||||||
lineIndices[base + 2] = i;
|
|
||||||
lineIndices[base + 3] = best2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload lines to GPU (NOT bound to VAO)
|
// Count edges per leaf (by source node)
|
||||||
|
const leafEdgeCounts = new Uint32Array(leaves.length);
|
||||||
|
for (let i = 0; i < validEdges; i++) {
|
||||||
|
leafEdgeCounts[nodeToLeaf[lineIndices[i * 2]]]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute prefix sums for edge offsets per leaf
|
||||||
|
const leafEdgeOffsets = new Uint32Array(leaves.length);
|
||||||
|
for (let i = 1; i < leaves.length; i++) {
|
||||||
|
leafEdgeOffsets[i] = leafEdgeOffsets[i - 1] + leafEdgeCounts[i - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort edges by source leaf into a new buffer
|
||||||
|
const sortedEdgeIndices = new Uint32Array(validEdges * 2);
|
||||||
|
const leafEdgeCurrent = new Uint32Array(leaves.length);
|
||||||
|
for (let i = 0; i < validEdges; i++) {
|
||||||
|
const leafIdx = nodeToLeaf[lineIndices[i * 2]];
|
||||||
|
const pos = leafEdgeOffsets[leafIdx] + leafEdgeCurrent[leafIdx];
|
||||||
|
sortedEdgeIndices[pos * 2] = lineIndices[i * 2];
|
||||||
|
sortedEdgeIndices[pos * 2 + 1] = lineIndices[i * 2 + 1];
|
||||||
|
leafEdgeCurrent[leafIdx]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.leafEdgeStarts = leafEdgeOffsets;
|
||||||
|
this.leafEdgeCounts = leafEdgeCounts;
|
||||||
|
|
||||||
|
// Upload sorted edges to GPU
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
|
||||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, lineIndices, gl.STATIC_DRAW);
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, sortedEdgeIndices, gl.STATIC_DRAW);
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||||
|
|
||||||
return performance.now() - t0;
|
return performance.now() - t0;
|
||||||
@@ -241,6 +327,135 @@ export class Renderer {
|
|||||||
return this.zoom;
|
return this.zoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getNodeCount(): number {
|
||||||
|
return this.nodeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert screen coordinates (CSS pixels) to world coordinates.
|
||||||
|
*/
|
||||||
|
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const px = screenX * dpr;
|
||||||
|
const py = screenY * dpr;
|
||||||
|
const wx = this.cx + (px - this.canvas.width / 2) / this.zoom;
|
||||||
|
const wy = this.cy + (py - this.canvas.height / 2) / this.zoom;
|
||||||
|
return { x: wx, y: wy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the node closest to the given screen position.
|
||||||
|
* Uses the quadtree to narrow down the search.
|
||||||
|
* Returns the node's world coordinates if found within the visual radius, or null.
|
||||||
|
*/
|
||||||
|
findNodeAt(screenX: number, screenY: number): { x: number; y: number } | null {
|
||||||
|
const result = this.findNodeIndexAt(screenX, screenY);
|
||||||
|
return result ? { x: result.x, y: result.y } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the node closest to the given screen position.
|
||||||
|
* Returns the node's index and world coordinates if found, or null.
|
||||||
|
*/
|
||||||
|
findNodeIndexAt(screenX: number, screenY: number): { index: number; x: number; y: number } | null {
|
||||||
|
if (this.sorted.length === 0) return null;
|
||||||
|
|
||||||
|
const world = this.screenToWorld(screenX, screenY);
|
||||||
|
const wx = world.x;
|
||||||
|
const wy = world.y;
|
||||||
|
|
||||||
|
// Calculate the search radius in world units (based on point size on screen)
|
||||||
|
// We use a slightly larger radius for easier hovering
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const ptSizeScreen = Math.max(1.0, Math.min(this.maxPtSize, WORLD_RADIUS * 2 * this.zoom));
|
||||||
|
const hitRadius = (ptSizeScreen / this.zoom / dpr) * 0.75; // world units
|
||||||
|
const hitRadiusSq = hitRadius * hitRadius;
|
||||||
|
|
||||||
|
let closestDist = Infinity;
|
||||||
|
let closestIndex = -1;
|
||||||
|
let closestX = 0;
|
||||||
|
let closestY = 0;
|
||||||
|
|
||||||
|
// Traverse all leaves and check if they intersect with the hit area
|
||||||
|
for (let i = 0; i < this.leaves.length; i++) {
|
||||||
|
const lf = this.leaves[i];
|
||||||
|
|
||||||
|
// Quick AABB check: does this leaf possibly contain points near our target?
|
||||||
|
if (
|
||||||
|
wx + hitRadius < lf.minX ||
|
||||||
|
wx - hitRadius > lf.maxX ||
|
||||||
|
wy + hitRadius < lf.minY ||
|
||||||
|
wy - hitRadius > lf.maxY
|
||||||
|
) {
|
||||||
|
continue; // Leaf is too far away
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all points in this leaf
|
||||||
|
for (let j = lf.start; j < lf.end; j++) {
|
||||||
|
const px = this.sorted[j * 2];
|
||||||
|
const py = this.sorted[j * 2 + 1];
|
||||||
|
const dx = px - wx;
|
||||||
|
const dy = py - wy;
|
||||||
|
const distSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (distSq < hitRadiusSq && distSq < closestDist) {
|
||||||
|
closestDist = distSq;
|
||||||
|
closestIndex = j;
|
||||||
|
closestX = px;
|
||||||
|
closestY = py;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closestIndex >= 0 ? { index: closestIndex, x: closestX, y: closestY } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the selection buffer with the given set of node indices.
|
||||||
|
* Also computes neighbors of selected nodes.
|
||||||
|
* Call this whenever React's selection state changes.
|
||||||
|
*/
|
||||||
|
updateSelection(selectedIndices: Set<number>): void {
|
||||||
|
const gl = this.gl;
|
||||||
|
|
||||||
|
// Upload selected indices
|
||||||
|
const indices = new Uint32Array(selectedIndices);
|
||||||
|
this.selectionCount = indices.length;
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.selectionIbo);
|
||||||
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW);
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||||
|
|
||||||
|
// Compute neighbors of selected nodes (excluding already selected)
|
||||||
|
const neighborSet = new Set<number>();
|
||||||
|
for (const nodeIdx of selectedIndices) {
|
||||||
|
const nodeNeighbors = this.neighborMap.get(nodeIdx);
|
||||||
|
if (!nodeNeighbors) continue;
|
||||||
|
for (const n of nodeNeighbors) {
|
||||||
|
if (!selectedIndices.has(n)) {
|
||||||
|
neighborSet.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload neighbor indices
|
||||||
|
const neighborIndices = new Uint32Array(neighborSet);
|
||||||
|
this.neighborCount = neighborIndices.length;
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.neighborIbo);
|
||||||
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, neighborIndices, gl.DYNAMIC_DRAW);
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the coordinates of a node by its index.
|
||||||
|
*/
|
||||||
|
getNodeCoords(index: number): { x: number; y: number } | null {
|
||||||
|
if (index < 0 || index * 2 + 1 >= this.sorted.length) return null;
|
||||||
|
return {
|
||||||
|
x: this.sorted[index * 2],
|
||||||
|
y: this.sorted[index * 2 + 1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Render ───────────────────────────────────────────── */
|
/* ── Render ───────────────────────────────────────────── */
|
||||||
|
|
||||||
render(): RenderStats {
|
render(): RenderStats {
|
||||||
@@ -358,17 +573,41 @@ export class Renderer {
|
|||||||
|
|
||||||
for (let i = 0; i < visibleCount; i++) {
|
for (let i = 0; i < visibleCount; i++) {
|
||||||
const leafIdx = this.visibleLeafIndices[i];
|
const leafIdx = this.visibleLeafIndices[i];
|
||||||
const lf = this.leaves[leafIdx];
|
const edgeCount = this.leafEdgeCounts[leafIdx];
|
||||||
const leafTotal = lf.end - lf.start;
|
if (edgeCount === 0) continue;
|
||||||
if (leafTotal <= 1) continue;
|
// Each edge is 2 indices (1 line segment)
|
||||||
// Each node has 4 indices (2 lines)
|
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
|
||||||
// Offset is in bytes: start index * 4 (indices per point) * 4 (bytes per uint32)
|
const edgeStart = this.leafEdgeStarts[leafIdx];
|
||||||
gl.drawElements(gl.LINES, leafTotal * 4, gl.UNSIGNED_INT, lf.start * 16);
|
gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. Draw Neighbor Nodes (yellow) - drawn before selected so selected appears on top
|
||||||
|
if (this.neighborCount > 0) {
|
||||||
|
gl.useProgram(this.neighborProgram);
|
||||||
|
gl.uniform2f(this.uCenterNeighbor, this.cx, this.cy);
|
||||||
|
gl.uniform2f(this.uScaleNeighbor, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
|
||||||
|
gl.uniform1f(this.uPtSizeNeighbor, ptSize);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.neighborIbo);
|
||||||
|
gl.drawElements(gl.POINTS, this.neighborCount, gl.UNSIGNED_INT, 0);
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Draw Selected Nodes on top (orange)
|
||||||
|
if (this.selectionCount > 0) {
|
||||||
|
gl.useProgram(this.selectedProgram);
|
||||||
|
gl.uniform2f(this.uCenterSelected, this.cx, this.cy);
|
||||||
|
gl.uniform2f(this.uScaleSelected, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
|
||||||
|
gl.uniform1f(this.uPtSizeSelected, ptSize);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.selectionIbo);
|
||||||
|
gl.drawElements(gl.POINTS, this.selectionCount, gl.UNSIGNED_INT, 0);
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||||
|
}
|
||||||
|
|
||||||
gl.bindVertexArray(null);
|
gl.bindVertexArray(null);
|
||||||
|
|
||||||
const mode = ratio === 1.0
|
const mode = ratio === 1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user