backend
This commit is contained in:
345
frontend/src/App.tsx
Normal file
345
frontend/src/App.tsx
Normal 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
1
frontend/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal 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
113
frontend/src/quadtree.ts
Normal 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
651
frontend/src/renderer.ts
Normal 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
6
frontend/src/utils/cn.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user