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 uriMapRef = useRef>(new Map()); 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; index?: number } | null>(null); const [selectedNodes, setSelectedNodes] = useState>(new Set()); // Store mouse position in a ref so it can be accessed in render loop without re-renders const mousePos = useRef({ x: 0, y: 0 }); useEffect(() => { 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; // Fetch CSVs, parse, and init renderer (async () => { try { setStatus("Fetching data files…"); const [nodesResponse, primaryEdgesResponse, secondaryEdgesResponse, uriMapResponse] = await Promise.all([ fetch("/node_positions.csv"), fetch("/primary_edges.csv"), fetch("/secondary_edges.csv"), fetch("/uri_map.csv"), ]); if (!nodesResponse.ok) throw new Error(`Failed to fetch nodes: ${nodesResponse.status}`); if (!primaryEdgesResponse.ok) throw new Error(`Failed to fetch primary edges: ${primaryEdgesResponse.status}`); if (!secondaryEdgesResponse.ok) throw new Error(`Failed to fetch secondary edges: ${secondaryEdgesResponse.status}`); const [nodesText, primaryEdgesText, secondaryEdgesText, uriMapText] = await Promise.all([ nodesResponse.text(), primaryEdgesResponse.text(), secondaryEdgesResponse.text(), uriMapResponse.ok ? uriMapResponse.text() : Promise.resolve(""), ]); 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 pLines = primaryEdgesText.split("\n").slice(1).filter(l => l.trim().length > 0); const sLines = secondaryEdgesText.split("\n").slice(1).filter(l => l.trim().length > 0); const totalEdges = pLines.length + sLines.length; const edgeData = new Uint32Array(totalEdges * 2); let idx = 0; // Parse primary for (let i = 0; i < pLines.length; i++) { const parts = pLines[i].split(","); edgeData[idx++] = parseInt(parts[0], 10); edgeData[idx++] = parseInt(parts[1], 10); } // Parse secondary for (let i = 0; i < sLines.length; i++) { const parts = sLines[i].split(","); edgeData[idx++] = parseInt(parts[0], 10); edgeData[idx++] = parseInt(parts[1], 10); } // Parse URI map if available if (uriMapText) { const uriLines = uriMapText.split("\n").slice(1).filter(l => l.trim().length > 0); for (const line of uriLines) { const parts = line.split(","); if (parts.length >= 4) { const id = parseInt(parts[0], 10); const uri = parts[1]; const label = parts[2]; const isPrimary = parts[3].trim() === "1"; uriMapRef.current.set(id, { uri, label, isPrimary }); } } } 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, ${totalEdges.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 nodeResult = renderer.findNodeIndexAt(mousePos.current.x, mousePos.current.y); if (nodeResult) { setHoveredNode({ x: nodeResult.x, y: nodeResult.y, screenX: mousePos.current.x, screenY: mousePos.current.y, index: nodeResult.index }); } 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}
Drag to pan · Scroll to zoom · Click to select
{/* Hover tooltip */} {hoveredNode && (
{(() => { if (hoveredNode.index !== undefined && rendererRef.current) { const vertexId = rendererRef.current.getVertexId(hoveredNode.index); const info = vertexId !== undefined ? uriMapRef.current.get(vertexId) : undefined; if (info) { return ( <>
{info.label}
{info.uri}
{info.isPrimary &&
⭐ Primary (rdf:type)
} ); } } return <>({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)}); })()}
)} )}
); }