import { useEffect, useRef, useState } from "react"; import { Renderer } from "./renderer"; export default function App() { const canvasRef = useRef(null); const rendererRef = useRef(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>(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) => { 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 (
{/* Loading overlay */} {status && (
{status}
)} {/* Error overlay */} {error && (
Error: {error}
)} {/* HUD */} {!status && !error && ( <>
FPS: {stats.fps}
Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}
Mode: {stats.mode}
Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit
Pt Size: {stats.ptSize.toFixed(1)}px
Selected: {selectedNodes.size}
{backendStats && (
Backend: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges
)}
Drag to pan · Scroll to zoom · Click to select
{/* Hover tooltip */} {hoveredNode && (
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
)} )}
); }