Import Solver + neighbors via sparql query

This commit is contained in:
Oxy8
2026-03-04 13:49:14 -03:00
parent d4bfa5f064
commit a75b5b93da
15 changed files with 747 additions and 463 deletions

View File

@@ -5,6 +5,17 @@ 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);
@@ -18,12 +29,15 @@ export default function App() {
ptSize: 0,
});
const [error, setError] = useState("");
const [hoveredNode, setHoveredNode] = useState<{ x: number; y: number; screenX: number; screenY: number } | null>(null);
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;
@@ -70,6 +84,9 @@ export default function App() {
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);
@@ -196,9 +213,18 @@ export default function App() {
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 });
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);
}
@@ -234,9 +260,72 @@ export default function App() {
// Sync selection state to renderer
useEffect(() => {
if (rendererRef.current) {
rendererRef.current.updateSelection(selectedNodes);
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 (
@@ -350,7 +439,12 @@ export default function App() {
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
}}
>
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
<div style={{ color: "#0ff" }}>
{hoveredNode.label || hoveredNode.iri || "(unknown)"}
</div>
<div style={{ color: "#688" }}>
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
</div>
</div>
)}
</>

View File

@@ -80,9 +80,11 @@ export class Renderer {
// Data
private leaves: Leaf[] = [];
private sorted: Float32Array = new Float32Array(0);
// order[sortedIdx] = originalIdx (original ordering matches input arrays)
private sortedToOriginal: Uint32Array = new Uint32Array(0);
private vertexIdToSortedIndex: Map<number, number> = new Map();
private nodeCount = 0;
private edgeCount = 0;
private neighborMap: Map<number, number[]> = new Map();
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
private maxPtSize = 256;
@@ -202,6 +204,7 @@ export class Renderer {
const { sorted, leaves, order } = buildSpatialIndex(xs, ys);
this.leaves = leaves;
this.sorted = sorted;
this.sortedToOriginal = order;
// Pre-allocate arrays for render loop (zero-allocation rendering)
this.visibleLeafIndices = new Uint32Array(leaves.length);
@@ -226,6 +229,13 @@ export class Renderer {
originalToSorted[order[i]] = i;
}
// Build vertex ID → sorted index mapping (used by backend-driven neighbor highlighting)
const vertexIdToSortedIndex = new Map<number, number>();
for (let i = 0; i < count; i++) {
vertexIdToSortedIndex.set(vertexIds[i], originalToSorted[i]);
}
this.vertexIdToSortedIndex = vertexIdToSortedIndex;
// Remap edges from vertex IDs to sorted indices
const lineIndices = new Uint32Array(edgeCount * 2);
let validEdges = 0;
@@ -241,18 +251,6 @@ export class Renderer {
}
this.edgeCount = validEdges;
// Build per-node neighbor list from edges for selection queries
const neighborMap = new Map<number, number[]>();
for (let i = 0; i < validEdges; i++) {
const src = lineIndices[i * 2];
const dst = lineIndices[i * 2 + 1];
if (!neighborMap.has(src)) neighborMap.set(src, []);
neighborMap.get(src)!.push(dst);
if (!neighborMap.has(dst)) neighborMap.set(dst, []);
neighborMap.get(dst)!.push(src);
}
this.neighborMap = neighborMap;
// Build per-leaf edge index for efficient visible-only edge drawing
// Find which leaf each sorted index belongs to
const nodeToLeaf = new Uint32Array(count);
@@ -331,6 +329,28 @@ export class Renderer {
return this.nodeCount;
}
/**
* Map a sorted buffer index (what findNodeIndexAt returns) back to the original
* index in the input arrays used to initialize the renderer.
*/
sortedIndexToOriginalIndex(sortedIndex: number): number | null {
if (
sortedIndex < 0 ||
sortedIndex >= this.sortedToOriginal.length
) {
return null;
}
return this.sortedToOriginal[sortedIndex];
}
/**
* Convert a backend node ID (node.id from /api/graph) to a sorted index used by the renderer.
*/
vertexIdToSortedIndexOrNull(vertexId: number): number | null {
const idx = this.vertexIdToSortedIndex.get(vertexId);
return typeof idx === "number" ? idx : null;
}
/**
* Convert screen coordinates (CSS pixels) to world coordinates.
*/
@@ -412,10 +432,10 @@ export class Renderer {
/**
* Update the selection buffer with the given set of node indices.
* Also computes neighbors of selected nodes.
* Call this whenever React's selection state changes.
* Neighbor indices are provided by the backend (SPARQL query) and uploaded separately.
* Call this whenever selection or backend neighbor results change.
*/
updateSelection(selectedIndices: Set<number>): void {
updateSelection(selectedIndices: Set<number>, neighborIndices: Set<number> = new Set()): void {
const gl = this.gl;
// Upload selected indices
@@ -425,23 +445,11 @@ export class Renderer {
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
// Compute neighbors of selected nodes (excluding already selected)
const neighborSet = new Set<number>();
for (const nodeIdx of selectedIndices) {
const nodeNeighbors = this.neighborMap.get(nodeIdx);
if (!nodeNeighbors) continue;
for (const n of nodeNeighbors) {
if (!selectedIndices.has(n)) {
neighborSet.add(n);
}
}
}
// Upload neighbor indices
const neighborIndices = new Uint32Array(neighborSet);
this.neighborCount = neighborIndices.length;
const neighborIndexArray = new Uint32Array(neighborIndices);
this.neighborCount = neighborIndexArray.length;
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.neighborIbo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, neighborIndices, gl.DYNAMIC_DRAW);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, neighborIndexArray, gl.DYNAMIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}