455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Renderer } from "./renderer";
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
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<HTMLCanvasElement>(null);
|
|
const rendererRef = useRef<Renderer | null>(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<Set<number>>(new Set());
|
|
const [backendStats, setBackendStats] = useState<{ nodes: number; edges: number; backend?: string } | null>(null);
|
|
const graphMetaRef = useRef<GraphMeta | null>(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<any[]>([]);
|
|
|
|
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<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 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<number>();
|
|
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 (
|
|
<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.backend ? ` (${backendStats.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)",
|
|
}}
|
|
>
|
|
<div style={{ color: "#0ff" }}>
|
|
{hoveredNode.label || hoveredNode.iri || "(unknown)"}
|
|
</div>
|
|
<div style={{ color: "#688" }}>
|
|
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|