This commit is contained in:
Oxy8
2026-03-02 14:32:42 -03:00
parent 022da71e6a
commit bf03d333f9
29 changed files with 200764 additions and 200011 deletions

17
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:lts-alpine
WORKDIR /app
EXPOSE 5173
# Copy dependency definitions
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the source code
COPY . .
# Start the dev server with --host for external access
CMD ["npm", "run", "dev", "--", "--host", "--port", "5173"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>10M Spheres Quadtree WebGL2 Renderer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2564
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "react-vite-tailwind",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"layout": "tsx scripts/compute_layout.ts"
},
"dependencies": {
"@webgpu/types": "^0.1.69",
"clsx": "2.1.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "3.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.17",
"@types/node": "^22.0.0",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.1",
"tailwindcss": "4.1.17",
"typescript": "5.9.3",
"tsx": "^4.0.0",
"vite": "7.2.4",
"vite-plugin-singlefile": "2.3.0"
}
}

100000
frontend/public/edges.csv Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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}`);

View File

@@ -0,0 +1,61 @@
/**
* Random Tree Generator
*
* Generates a random tree with 1MAX_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,
};
}

345
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,345 @@
import { useEffect, useRef, useState } from "react";
import { Renderer } from "./renderer";
export default function App() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<Renderer | null>(null);
const [status, setStatus] = useState("Loading node positions…");
const [nodeCount, setNodeCount] = useState(0);
const [stats, setStats] = useState({
fps: 0,
drawn: 0,
mode: "",
zoom: 0,
ptSize: 0,
});
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());
const [backendStats, setBackendStats] = useState<{ nodes: number; edges: number; parsed_triples: number } | null>(null);
// 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(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let renderer: Renderer;
try {
renderer = new Renderer(canvas);
rendererRef.current = renderer;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
return;
}
let cancelled = false;
// Optional: fetch backend stats (proxied via Vite) so you can confirm backend is up.
fetch("/api/stats")
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (!j || cancelled) return;
if (typeof j.nodes === "number" && typeof j.edges === "number" && typeof j.parsed_triples === "number") {
setBackendStats({ nodes: j.nodes, edges: j.edges, parsed_triples: j.parsed_triples });
}
})
.catch(() => {
// Backend is optional; ignore failures.
});
// Fetch CSVs, parse, and init renderer
(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 ──
let dragging = false;
let didDrag = false; // true if mouse moved significantly during drag
let downX = 0;
let downY = 0;
let lastX = 0;
let lastY = 0;
const DRAG_THRESHOLD = 5; // pixels
const onDown = (e: MouseEvent) => {
dragging = true;
didDrag = false;
downX = e.clientX;
downY = e.clientY;
lastX = e.clientX;
lastY = e.clientY;
};
const onMove = (e: MouseEvent) => {
mousePos.current = { x: e.clientX, y: e.clientY };
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);
lastX = e.clientX;
lastY = e.clientY;
};
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;
didDrag = false;
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1 / 0.9;
renderer.zoomAt(factor, e.clientX, e.clientY);
};
const onMouseLeave = () => {
setHoveredNode(null);
};
canvas.addEventListener("mousedown", onDown);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
canvas.addEventListener("wheel", onWheel, { passive: false });
canvas.addEventListener("mouseleave", onMouseLeave);
// ── Render loop ──
let frameCount = 0;
let lastTime = performance.now();
let raf = 0;
const frame = () => {
const result = renderer.render();
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();
if (now - lastTime >= 500) {
const fps = (frameCount / (now - lastTime)) * 1000;
setStats({
fps: Math.round(fps),
drawn: result.drawnCount,
mode: result.mode,
zoom: result.zoom,
ptSize: result.ptSize,
});
frameCount = 0;
lastTime = now;
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
canvas.removeEventListener("mousedown", onDown);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
canvas.removeEventListener("wheel", onWheel);
canvas.removeEventListener("mouseleave", onMouseLeave);
};
}, []);
// Sync selection state to renderer
useEffect(() => {
if (rendererRef.current) {
rendererRef.current.updateSelection(selectedNodes);
}
}, [selectedNodes]);
return (
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
<canvas
ref={canvasRef}
style={{ display: "block", width: "100%", height: "100%" }}
/>
{/* Loading overlay */}
{status && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.9)",
color: "#0f0",
fontFamily: "monospace",
fontSize: "16px",
}}
>
{status}
</div>
)}
{/* Error overlay */}
{error && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.9)",
color: "#f44",
fontFamily: "monospace",
fontSize: "16px",
}}
>
Error: {error}
</div>
)}
{/* HUD */}
{!status && !error && (
<>
<div
style={{
position: "absolute",
top: 10,
left: 10,
background: "rgba(0,0,0,0.75)",
color: "#0f0",
fontFamily: "monospace",
padding: "8px 12px",
fontSize: "12px",
lineHeight: "1.6",
borderRadius: "4px",
pointerEvents: "none",
}}
>
<div>FPS: {stats.fps}</div>
<div>Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}</div>
<div>Mode: {stats.mode}</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 style={{ color: "#f80" }}>Selected: {selectedNodes.size}</div>
{backendStats && (
<div style={{ color: "#8f8" }}>
Backend: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges
</div>
)}
</div>
<div
style={{
position: "absolute",
bottom: 10,
left: 10,
background: "rgba(0,0,0,0.75)",
color: "#888",
fontFamily: "monospace",
padding: "6px 10px",
fontSize: "11px",
borderRadius: "4px",
pointerEvents: "none",
}}
>
Drag to pan · Scroll to zoom · Click to select
</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>
);
}

1
frontend/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

113
frontend/src/quadtree.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* Quadtree that spatially sorts a particle array in-place at build time.
* Stores only leaf index ranges [start, end) into the sorted array.
* NO per-frame methods — this is purely a build-time spatial index.
*/
export interface Leaf {
start: number;
end: number;
minX: number;
minY: number;
maxX: number;
maxY: number;
}
/**
* Spatially sort particles using a quadtree and return
* the sorted array + leaf ranges.
*
* Takes raw Float32Arrays (no object allocation).
* Uses in-place partitioning (zero temporary arrays).
*/
export function buildSpatialIndex(
xs: Float32Array,
ys: Float32Array
): { sorted: Float32Array; leaves: Leaf[]; order: Uint32Array } {
const n = xs.length;
const order = new Uint32Array(n);
for (let i = 0; i < n; i++) order[i] = i;
// Find bounds
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (let i = 0; i < n; i++) {
const x = xs[i], y = ys[i];
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
const leaves: Leaf[] = [];
// In-place quicksort-style partitioning
function partition(
vals: Float32Array,
start: number,
end: number,
mid: number
): number {
let lo = start,
hi = end - 1;
while (lo <= hi) {
while (lo <= hi && vals[order[lo]] < mid) lo++;
while (lo <= hi && vals[order[hi]] >= mid) hi--;
if (lo < hi) {
const t = order[lo];
order[lo] = order[hi];
order[hi] = t;
lo++;
hi--;
}
}
return lo;
}
function build(
start: number,
end: number,
bMinX: number,
bMinY: number,
bMaxX: number,
bMaxY: number,
depth: number
): void {
const count = end - start;
if (count <= 0) return;
// Leaf: stop subdividing
if (count <= 4096 || depth >= 12) {
leaves.push({ start, end, minX: bMinX, minY: bMinY, maxX: bMaxX, maxY: bMaxY });
return;
}
const midX = (bMinX + bMaxX) / 2;
const midY = (bMinY + bMaxY) / 2;
// Partition by X, then each half by Y
const splitX = partition(xs, start, end, midX);
const splitLeftY = partition(ys, start, splitX, midY);
const splitRightY = partition(ys, splitX, end, midY);
// BL, TL, BR, TR
build(start, splitLeftY, bMinX, bMinY, midX, midY, depth + 1);
build(splitLeftY, splitX, bMinX, midY, midX, bMaxY, depth + 1);
build(splitX, splitRightY, midX, bMinY, bMaxX, midY, depth + 1);
build(splitRightY, end, midX, midY, bMaxX, bMaxY, depth + 1);
}
build(0, n, minX, minY, maxX, maxY, 0);
// Reorder particles to match tree layout
const sorted = new Float32Array(n * 2);
for (let i = 0; i < n; i++) {
const src = order[i];
sorted[i * 2] = xs[src];
sorted[i * 2 + 1] = ys[src];
}
return { sorted, leaves, order };
}

651
frontend/src/renderer.ts Normal file
View File

@@ -0,0 +1,651 @@
import { buildSpatialIndex, type Leaf } from "./quadtree";
/* ── Shaders ────────────────────────────────────────────── */
const VERT = `#version 300 es
precision highp float;
in vec2 a_pos;
uniform vec2 u_center;
uniform vec2 u_scale;
uniform float u_ptSize;
void main() {
gl_Position = vec4((a_pos - u_center) * u_scale, 0.0, 1.0);
gl_PointSize = u_ptSize;
}
`;
const 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(0.3, 0.55, 1.0, 0.5);
}
`;
const LINE_FRAG = `#version 300 es
precision mediump float;
out vec4 o;
void main() {
o = vec4(0.3, 0.55, 1.0, 0.15); // faint lines
}
`;
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 ──────────────────────────────────────────────── */
export interface RenderStats {
drawnCount: number;
mode: string;
zoom: number;
ptSize: number;
}
/* ── Constants ──────────────────────────────────────────── */
const WORLD_RADIUS = 4.0; // sphere world-space radius
const MAX_DRAW = 2_000_000; // max particles to draw per frame
/* ── Renderer ───────────────────────────────────────────── */
export class Renderer {
private gl: WebGL2RenderingContext;
private canvas: HTMLCanvasElement;
private program: WebGLProgram;
private lineProgram: WebGLProgram;
private selectedProgram: WebGLProgram;
private neighborProgram: WebGLProgram;
private vao: WebGLVertexArrayObject;
// Data
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;
// Multi-draw extension
private multiDrawExt: any = null;
private visibleLeafIndices: Uint32Array = new Uint32Array(0);
private startsArray: Int32Array = new Int32Array(0);
private countsArray: Int32Array = new Int32Array(0);
// Uniform locations
private uCenter: WebGLUniformLocation;
private uScale: WebGLUniformLocation;
private uPtSize: WebGLUniformLocation;
private uCenterLine: 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;
// Selection
private selectionIbo: WebGLBuffer;
private selectionCount = 0;
// Neighbors
private neighborIbo: WebGLBuffer;
private neighborCount = 0;
// Camera state
private cx = 0;
private cy = 0;
private zoom = 0.06; // pixels per world unit (starts zoomed out to see everything)
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
const gl = canvas.getContext("webgl2", { antialias: false, alpha: false });
if (!gl) throw new Error("WebGL2 not supported");
this.gl = gl;
this.multiDrawExt = gl.getExtension('WEBGL_multi_draw');
// Compile programs
this.program = this.compileProgram(VERT, 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);
this.uCenter = gl.getUniformLocation(this.program, "u_center")!;
this.uScale = gl.getUniformLocation(this.program, "u_scale")!;
this.uPtSize = gl.getUniformLocation(this.program, "u_ptSize")!;
this.uCenterLine = gl.getUniformLocation(this.lineProgram, "u_center")!;
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
const range = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) as Float32Array;
this.maxPtSize = range[1] || 256;
// Create VAO + VBO (empty for now)
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
const vbo = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
// We forced a_pos to location 0 in compileProgram
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
this.linesIbo = gl.createBuffer()!;
this.selectionIbo = gl.createBuffer()!;
this.neighborIbo = gl.createBuffer()!;
// Blending
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.clearColor(0.02, 0.02, 0.05, 1.0);
}
/**
* 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.
*/
init(
xs: Float32Array,
ys: Float32Array,
vertexIds: Uint32Array,
edges: Uint32Array
): number {
const t0 = performance.now();
const gl = this.gl;
const count = xs.length;
const edgeCount = edges.length / 2;
this.nodeCount = count;
// Build quadtree (spatially sorts the array)
const { sorted, leaves, order } = buildSpatialIndex(xs, ys);
this.leaves = leaves;
this.sorted = sorted;
// Pre-allocate arrays for render loop (zero-allocation rendering)
this.visibleLeafIndices = new Uint32Array(leaves.length);
this.startsArray = new Int32Array(leaves.length);
this.countsArray = new Int32Array(leaves.length);
// Upload sorted particles to GPU as STATIC VBO (never changes)
gl.bindVertexArray(this.vao);
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
gl.bindVertexArray(null);
// Build vertex ID → original input index mapping
const vertexIdToOriginal = new Map<number, number>();
for (let i = 0; i < count; i++) {
vertexIdToOriginal.set(vertexIds[i], i);
}
// Build original input index → sorted index mapping
// order[sortedIdx] = originalIdx, so invert it
const originalToSorted = new Uint32Array(count);
for (let i = 0; i < count; i++) {
originalToSorted[order[i]] = i;
}
// Remap edges from vertex IDs to sorted indices
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;
// Build per-node neighbor list from edges for selection queries
const neighborMap = new Map<number, number[]>();
for (let i = 0; i < validEdges; i++) {
const src = lineIndices[i * 2];
const dst = lineIndices[i * 2 + 1];
if (!neighborMap.has(src)) neighborMap.set(src, []);
neighborMap.get(src)!.push(dst);
if (!neighborMap.has(dst)) neighborMap.set(dst, []);
neighborMap.get(dst)!.push(src);
}
this.neighborMap = neighborMap;
// Build per-leaf edge index for efficient visible-only edge drawing
// Find which leaf each sorted index belongs to
const nodeToLeaf = new Uint32Array(count);
for (let li = 0; li < leaves.length; li++) {
const lf = leaves[li];
for (let j = lf.start; j < lf.end; j++) {
nodeToLeaf[j] = li;
}
}
// 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.bufferData(gl.ELEMENT_ARRAY_BUFFER, sortedEdgeIndices, gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
return performance.now() - t0;
}
/* ── Camera ───────────────────────────────────────────── */
pan(dx: number, dy: number): void {
// dx, dy in CSS pixels; convert to world units
const dpr = window.devicePixelRatio || 1;
this.cx -= (dx * dpr) / this.zoom;
this.cy -= (dy * dpr) / this.zoom;
}
zoomAt(factor: number, screenX: number, screenY: number): void {
const dpr = window.devicePixelRatio || 1;
const px = screenX * dpr;
const py = screenY * dpr;
// World position under cursor before zoom
const wx = this.cx + (px - this.canvas.width / 2) / this.zoom;
const wy = this.cy + (py - this.canvas.height / 2) / this.zoom;
this.zoom *= factor;
this.zoom = Math.max(1e-4, Math.min(1e7, this.zoom));
// Adjust pan so the same world point stays under cursor
this.cx = wx - (px - this.canvas.width / 2) / this.zoom;
this.cy = wy - (py - this.canvas.height / 2) / this.zoom;
}
getZoom(): number {
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(): RenderStats {
const gl = this.gl;
const canvas = this.canvas;
// Resize
const dpr = window.devicePixelRatio || 1;
const cw = (canvas.clientWidth * dpr) | 0;
const ch = (canvas.clientHeight * dpr) | 0;
if (canvas.width !== cw || canvas.height !== ch) {
canvas.width = cw;
canvas.height = ch;
}
gl.viewport(0, 0, cw, ch);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.bindVertexArray(this.vao);
// Uniforms: transform world coords to NDC
gl.useProgram(this.program);
gl.uniform2f(this.uCenter, this.cx, this.cy);
gl.uniform2f(
this.uScale,
(this.zoom * 2) / cw,
(-this.zoom * 2) / ch
);
// Point size: world diameter → screen pixels, clamped
const ptSize = Math.max(
1.0,
Math.min(this.maxPtSize, WORLD_RADIUS * 2 * this.zoom)
);
gl.uniform1f(this.uPtSize, ptSize);
// Frustum bounding box
const viewW = cw / this.zoom;
const viewH = ch / this.zoom;
const vMinX = this.cx - viewW / 2;
const vMaxX = this.cx + viewW / 2;
const vMinY = this.cy - viewH / 2;
const vMaxY = this.cy + viewH / 2;
let visibleCount = 0;
let totalVisibleParticles = 0;
// 1. Find all visible leaves and total particles inside frustum
for (let i = 0; i < this.leaves.length; i++) {
const lf = this.leaves[i];
if (
lf.maxX < vMinX ||
lf.minX > vMaxX ||
lf.maxY < vMinY ||
lf.minY > vMaxY
)
continue;
this.visibleLeafIndices[visibleCount++] = i;
totalVisibleParticles += (lf.end - lf.start);
}
// 2. Calculate dynamic sampling ratio based ONLY on visible particles
const ratio = Math.min(1.0, MAX_DRAW / Math.max(1, totalVisibleParticles));
let drawnCount = 0;
// 3. Prepare index/count arrays for drawing
for (let i = 0; i < visibleCount; i++) {
const leafIdx = this.visibleLeafIndices[i];
const lf = this.leaves[leafIdx];
const leafTotal = lf.end - lf.start;
// Since the leaf is randomly unordered internally, taking the first N points
// is a perfect uniform spatial sample of this leaf.
const drawCount = Math.max(1, Math.floor(leafTotal * ratio));
this.startsArray[i] = lf.start;
this.countsArray[i] = drawCount;
drawnCount += drawCount;
}
// 4. Draw Points!
if (visibleCount > 0) {
if (this.multiDrawExt) {
this.multiDrawExt.multiDrawArraysWEBGL(
gl.POINTS,
this.startsArray, 0,
this.countsArray, 0,
visibleCount
);
} else {
// Fallback: batch contiguous runs to minimize draw calls
let currentStart = this.startsArray[0];
let currentCount = this.countsArray[0];
for (let i = 1; i < visibleCount; i++) {
if (currentStart + currentCount === this.startsArray[i]) {
currentCount += this.countsArray[i]; // Merge contiguous
} else {
gl.drawArrays(gl.POINTS, currentStart, currentCount);
currentStart = this.startsArray[i];
currentCount = this.countsArray[i];
}
}
gl.drawArrays(gl.POINTS, currentStart, currentCount);
}
}
// 5. Draw Lines if deeply zoomed in (< 20k total visible particles)
if (totalVisibleParticles < 20000 && visibleCount > 0) {
gl.useProgram(this.lineProgram);
gl.uniform2f(this.uCenterLine, this.cx, this.cy);
gl.uniform2f(this.uScaleLine, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
for (let i = 0; i < visibleCount; i++) {
const leafIdx = this.visibleLeafIndices[i];
const edgeCount = this.leafEdgeCounts[leafIdx];
if (edgeCount === 0) continue;
// Each edge is 2 indices (1 line segment)
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
const edgeStart = this.leafEdgeStarts[leafIdx];
gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
}
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);
const mode = ratio === 1.0
? '100% visible nodes'
: ((ratio * 100).toFixed(1) + '% of visible nodes');
return { drawnCount, mode, zoom: this.zoom, ptSize };
}
/* ── Helpers ──────────────────────────────────────────── */
private compileProgram(vSrc: string, fSrc: string): WebGLProgram {
const gl = this.gl;
const vs = gl.createShader(gl.VERTEX_SHADER)!;
gl.shaderSource(vs, vSrc);
gl.compileShader(vs);
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS))
throw new Error("VS: " + gl.getShaderInfoLog(vs));
const fs = gl.createShader(gl.FRAGMENT_SHADER)!;
gl.shaderSource(fs, fSrc);
gl.compileShader(fs);
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS))
throw new Error("FS: " + gl.getShaderInfoLog(fs));
const prog = gl.createProgram()!;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
// Force a_pos to location 0 before linking so both programs match VAO
gl.bindAttribLocation(prog, 0, "a_pos");
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS))
throw new Error("Link: " + gl.getProgramInfoLog(prog));
gl.deleteShader(vs);
gl.deleteShader(fs);
return prog;
}
}

6
frontend/src/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

31
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["node"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import path from "path";
import { fileURLToPath } from "url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), viteSingleFile()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
proxy: {
// Backend is reachable as http://backend:8000 inside docker-compose; localhost outside.
"/api": process.env.VITE_BACKEND_URL || "http://localhost:8000",
},
},
});