import { useEffect, useRef, useState } from "react"; import { Renderer } from "./renderer"; function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } type GraphMeta = { backend?: string; ttl_path?: string | null; sparql_endpoint?: string | null; include_bnodes?: boolean; node_limit?: number; edge_limit?: number; nodes?: number; edges?: number; }; export default function App() { const canvasRef = useRef(null); const rendererRef = useRef(null); const [status, setStatus] = useState("Waiting for backend…"); 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; label?: string; iri?: string } | null>(null); const [selectedNodes, setSelectedNodes] = useState>(new Set()); const [backendStats, setBackendStats] = useState<{ nodes: number; edges: number; backend?: string } | null>(null); const graphMetaRef = useRef(null); const neighborsReqIdRef = useRef(0); // Store mouse position in a ref so it can be accessed in render loop without re-renders const mousePos = useRef({ x: 0, y: 0 }); const nodesRef = useRef([]); 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; (async () => { try { // Wait for backend (docker-compose also gates startup via healthcheck, but this // handles running the frontend standalone). const deadline = performance.now() + 180_000; let attempt = 0; while (performance.now() < deadline) { attempt++; setStatus(`Waiting for backend… (attempt ${attempt})`); try { const res = await fetch("/api/health"); if (res.ok) break; } catch { // ignore and retry } await sleep(1000); if (cancelled) return; } setStatus("Fetching graph…"); const graphRes = await fetch("/api/graph"); if (!graphRes.ok) throw new Error(`Failed to fetch graph: ${graphRes.status}`); const graph = await graphRes.json(); if (cancelled) return; const nodes = Array.isArray(graph.nodes) ? graph.nodes : []; const edges = Array.isArray(graph.edges) ? graph.edges : []; const meta = graph.meta || null; const count = nodes.length; nodesRef.current = nodes; graphMetaRef.current = meta && typeof meta === "object" ? (meta as GraphMeta) : null; // Build positions from backend-provided node coordinates. setStatus("Preparing buffers…"); const xs = new Float32Array(count); const ys = new Float32Array(count); for (let i = 0; i < count; i++) { const nx = nodes[i]?.x; const ny = nodes[i]?.y; xs[i] = typeof nx === "number" ? nx : 0; ys[i] = typeof ny === "number" ? ny : 0; } const vertexIds = new Uint32Array(count); for (let i = 0; i < count; i++) { const id = nodes[i]?.id; vertexIds[i] = typeof id === "number" ? id >>> 0 : i; } // Build edges as vertex-id pairs. const edgeData = new Uint32Array(edges.length * 2); for (let i = 0; i < edges.length; i++) { const s = edges[i]?.source; const t = edges[i]?.target; edgeData[i * 2] = typeof s === "number" ? s >>> 0 : 0; edgeData[i * 2 + 1] = typeof t === "number" ? t >>> 0 : 0; } // Use /api/graph meta; don't do a second expensive backend call. if (meta && typeof meta.nodes === "number" && typeof meta.edges === "number") { setBackendStats({ nodes: meta.nodes, edges: meta.edges, backend: typeof meta.backend === "string" ? meta.backend : undefined, }); } else { setBackendStats({ nodes: nodes.length, edges: edges.length }); } 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, ${edges.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 hit = renderer.findNodeIndexAt(mousePos.current.x, mousePos.current.y); if (hit) { const origIdx = renderer.sortedIndexToOriginalIndex(hit.index); const meta = origIdx === null ? null : nodesRef.current[origIdx]; setHoveredNode({ x: hit.x, y: hit.y, screenX: mousePos.current.x, screenY: mousePos.current.y, label: meta && typeof meta.label === "string" ? meta.label : undefined, iri: meta && typeof meta.iri === "string" ? meta.iri : undefined, }); } 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(() => { const renderer = rendererRef.current; if (!renderer) return; // Optimistically reflect selection immediately; neighbors will be filled in by backend. renderer.updateSelection(selectedNodes, new Set()); // Invalidate any in-flight neighbor request for the previous selection. const reqId = ++neighborsReqIdRef.current; // Convert selected sorted indices to backend node IDs (graph-export dense IDs). const selectedIds: number[] = []; for (const sortedIdx of selectedNodes) { const origIdx = renderer.sortedIndexToOriginalIndex(sortedIdx); if (origIdx === null) continue; const nodeId = nodesRef.current?.[origIdx]?.id; if (typeof nodeId === "number") selectedIds.push(nodeId); } if (selectedIds.length === 0) { return; } // Always send the full current selection list; backend returns the merged neighbor set. const ctrl = new AbortController(); (async () => { try { const meta = graphMetaRef.current; const body = { selected_ids: selectedIds, node_limit: typeof meta?.node_limit === "number" ? meta.node_limit : undefined, edge_limit: typeof meta?.edge_limit === "number" ? meta.edge_limit : undefined, }; const res = await fetch("/api/neighbors", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: ctrl.signal, }); if (!res.ok) throw new Error(`POST /api/neighbors failed: ${res.status}`); const data = await res.json(); if (ctrl.signal.aborted) return; if (reqId !== neighborsReqIdRef.current) return; const neighborIds: unknown = data?.neighbor_ids; const neighborSorted = new Set(); if (Array.isArray(neighborIds)) { for (const id of neighborIds) { if (typeof id !== "number") continue; const sorted = renderer.vertexIdToSortedIndexOrNull(id); if (sorted === null) continue; if (!selectedNodes.has(sorted)) neighborSorted.add(sorted); } } renderer.updateSelection(selectedNodes, neighborSorted); } catch (e) { if (ctrl.signal.aborted) return; console.warn(e); // Keep the UI usable even if neighbors fail to load. renderer.updateSelection(selectedNodes, new Set()); } })(); return () => ctrl.abort(); }, [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.backend ? ` (${backendStats.backend})` : ""}: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges
)}
Drag to pan · Scroll to zoom · Click to select
{/* Hover tooltip */} {hoveredNode && (
{hoveredNode.label || hoveredNode.iri || "(unknown)"}
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
)} )}
); }