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 = new Map(); private sortedToVertexId: Uint32Array = new Uint32Array(0); 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 sorted index → vertex ID mapping for hover lookups this.sortedToVertexId = new Uint32Array(count); for (let i = 0; i < count; i++) { this.sortedToVertexId[i] = vertexIds[order[i]]; } // Build vertex ID → original input index mapping const vertexIdToOriginal = new Map(); 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(); 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; } /** * Get the original vertex ID for a given sorted index. * Useful for looking up URI labels from the URI map. */ getVertexId(sortedIndex: number): number | undefined { if (sortedIndex < 0 || sortedIndex >= this.sortedToVertexId.length) return undefined; return this.sortedToVertexId[sortedIndex]; } /** * 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): 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(); 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; } }