Add filter, add READMES
This commit is contained in:
@@ -1,21 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Renderer } from "./renderer";
|
||||
import { fetchGraphQueries } from "./graph_queries";
|
||||
import type { GraphQueryMeta } from "./graph_queries";
|
||||
import { fetchSelectionQueries, runSelectionQuery } from "./selection_queries";
|
||||
import type { GraphMeta, SelectionQueryMeta } from "./selection_queries";
|
||||
|
||||
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);
|
||||
@@ -31,14 +24,84 @@ export default function App() {
|
||||
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 [graphQueries, setGraphQueries] = useState<GraphQueryMeta[]>([]);
|
||||
const [activeGraphQueryId, setActiveGraphQueryId] = useState<string>("default");
|
||||
const [selectionQueries, setSelectionQueries] = useState<SelectionQueryMeta[]>([]);
|
||||
const [activeSelectionQueryId, setActiveSelectionQueryId] = useState<string>("neighbors");
|
||||
const [backendStats, setBackendStats] = useState<{ nodes: number; edges: number; backend?: string } | null>(null);
|
||||
const graphMetaRef = useRef<GraphMeta | null>(null);
|
||||
const neighborsReqIdRef = useRef(0);
|
||||
const selectionReqIdRef = useRef(0);
|
||||
const graphInitializedRef = useRef(false);
|
||||
|
||||
// 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[]>([]);
|
||||
|
||||
async function loadGraph(graphQueryId: string, signal: AbortSignal): Promise<void> {
|
||||
const renderer = rendererRef.current;
|
||||
if (!renderer) return;
|
||||
|
||||
setStatus("Fetching graph…");
|
||||
const graphRes = await fetch(`/api/graph?graph_query_id=${encodeURIComponent(graphQueryId)}`, { signal });
|
||||
if (!graphRes.ok) throw new Error(`Failed to fetch graph: ${graphRes.status}`);
|
||||
const graph = await graphRes.json();
|
||||
if (signal.aborted) 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));
|
||||
if (signal.aborted) return;
|
||||
|
||||
const buildMs = renderer.init(xs, ys, vertexIds, edgeData);
|
||||
setNodeCount(renderer.getNodeCount());
|
||||
setSelectedNodes(new Set());
|
||||
setStatus("");
|
||||
console.log(`Init complete: ${count.toLocaleString()} nodes, ${edges.length.toLocaleString()} edges in ${buildMs.toFixed(0)}ms`);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
@@ -53,6 +116,8 @@ export default function App() {
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const initCtrl = new AbortController();
|
||||
graphInitializedRef.current = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@@ -73,63 +138,36 @@ export default function App() {
|
||||
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;
|
||||
let graphQueryToLoad = activeGraphQueryId;
|
||||
try {
|
||||
setStatus("Fetching graph modes…");
|
||||
const gqs = await fetchGraphQueries(initCtrl.signal);
|
||||
if (cancelled || initCtrl.signal.aborted) return;
|
||||
setGraphQueries(gqs);
|
||||
graphQueryToLoad = gqs.some((q) => q.id === graphQueryToLoad) ? graphQueryToLoad : (gqs[0]?.id ?? "default");
|
||||
setActiveGraphQueryId(graphQueryToLoad);
|
||||
} catch {
|
||||
if (cancelled || initCtrl.signal.aborted) return;
|
||||
setGraphQueries([{ id: "default", label: "Default" }]);
|
||||
graphQueryToLoad = "default";
|
||||
setActiveGraphQueryId("default");
|
||||
}
|
||||
|
||||
// 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;
|
||||
await loadGraph(graphQueryToLoad, initCtrl.signal);
|
||||
if (cancelled || initCtrl.signal.aborted) return;
|
||||
|
||||
try {
|
||||
const qs = await fetchSelectionQueries(initCtrl.signal);
|
||||
if (cancelled) return;
|
||||
setSelectionQueries(qs);
|
||||
setActiveSelectionQueryId((prev) => (qs.length > 0 && !qs.some((q) => q.id === prev) ? qs[0].id : prev));
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setSelectionQueries([{ id: "neighbors", label: "Neighbors" }]);
|
||||
setActiveSelectionQueryId((prev) => (prev ? prev : "neighbors"));
|
||||
}
|
||||
|
||||
// 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`);
|
||||
graphInitializedRef.current = true;
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -249,6 +287,7 @@ export default function App() {
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
initCtrl.abort();
|
||||
cancelAnimationFrame(raf);
|
||||
canvas.removeEventListener("mousedown", onDown);
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
@@ -258,62 +297,68 @@ export default function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reload graph when the graph query mode changes (after initial load)
|
||||
useEffect(() => {
|
||||
if (!graphInitializedRef.current) return;
|
||||
const renderer = rendererRef.current;
|
||||
if (!renderer) return;
|
||||
if (!activeGraphQueryId) return;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
await loadGraph(activeGraphQueryId, ctrl.signal);
|
||||
} catch (e) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => ctrl.abort();
|
||||
}, [activeGraphQueryId]);
|
||||
|
||||
// Sync selection state to renderer
|
||||
useEffect(() => {
|
||||
const renderer = rendererRef.current;
|
||||
if (!renderer) return;
|
||||
|
||||
// Optimistically reflect selection immediately; neighbors will be filled in by backend.
|
||||
// Optimistically reflect selection immediately; highlights 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;
|
||||
// Invalidate any in-flight request for the previous selection/mode.
|
||||
const reqId = ++selectionReqIdRef.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);
|
||||
const n = nodesRef.current?.[origIdx];
|
||||
const nodeId = n?.id;
|
||||
if (typeof nodeId !== "number") continue;
|
||||
selectedIds.push(nodeId);
|
||||
}
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always send the full current selection list; backend returns the merged neighbor set.
|
||||
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
||||
|
||||
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();
|
||||
const neighborIds = await runSelectionQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
|
||||
if (ctrl.signal.aborted) return;
|
||||
if (reqId !== neighborsReqIdRef.current) return;
|
||||
if (reqId !== selectionReqIdRef.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);
|
||||
}
|
||||
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);
|
||||
@@ -326,7 +371,7 @@ export default function App() {
|
||||
})();
|
||||
|
||||
return () => ctrl.abort();
|
||||
}, [selectedNodes]);
|
||||
}, [selectedNodes, activeSelectionQueryId]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
|
||||
@@ -420,6 +465,92 @@ export default function App() {
|
||||
Drag to pan · Scroll to zoom · Click to select
|
||||
</div>
|
||||
|
||||
{/* Selection query buttons */}
|
||||
{selectionQueries.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{selectionQueries.map((q) => {
|
||||
const active = q.id === activeSelectionQueryId;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setActiveSelectionQueryId(q.id)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: active ? "1px solid rgba(0,255,255,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
||||
background: active ? "rgba(0,255,255,0.12)" : "rgba(255,255,255,0.04)",
|
||||
color: active ? "#0ff" : "#bbb",
|
||||
textAlign: "left",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph query buttons */}
|
||||
{graphQueries.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{graphQueries.map((q) => {
|
||||
const active = q.id === activeGraphQueryId;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setActiveGraphQueryId(q.id)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: active ? "1px solid rgba(0,255,0,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
||||
background: active ? "rgba(0,255,0,0.12)" : "rgba(255,255,255,0.04)",
|
||||
color: active ? "#8f8" : "#bbb",
|
||||
textAlign: "left",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover tooltip */}
|
||||
{hoveredNode && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user