radial sugiyama positioning integration

This commit is contained in:
Oxy8
2026-03-23 11:13:27 -03:00
parent 6b9115e43b
commit 696844f341
51 changed files with 10089 additions and 364 deletions

View File

@@ -2,13 +2,96 @@ 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";
import { fetchSelectionQueries, runSelectionQuery, runSelectionTripleQuery } from "./selection_queries";
import { cosmosRuntimeConfig } from "./cosmos_config";
import type { GraphMeta, GraphRoutePoint, GraphRouteSegment, SelectionQueryMeta, SelectionTriple } from "./selection_queries";
import { TripleGraphView } from "./TripleGraphView";
import { buildTripleGraphModel, type TripleGraphModel } from "./triple_graph";
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
type GraphNodeMeta = {
id?: number;
iri?: string;
label?: string;
x?: number;
y?: number;
};
function graphRoutePoint(value: unknown): GraphRoutePoint | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (typeof record.x !== "number" || typeof record.y !== "number") return null;
return {
x: record.x,
y: record.y,
};
}
function graphRouteSegmentArray(value: unknown): GraphRouteSegment[] {
if (!Array.isArray(value)) return [];
const out: GraphRouteSegment[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
if (typeof record.edge_index !== "number" || typeof record.kind !== "string") continue;
if (!Array.isArray(record.points)) continue;
const points: GraphRoutePoint[] = [];
for (const point of record.points) {
const parsed = graphRoutePoint(point);
if (!parsed) continue;
points.push(parsed);
}
if (points.length < 2) continue;
out.push({
edge_index: record.edge_index,
kind: record.kind,
points,
});
}
return out;
}
function buildRouteLineVertices(routeSegments: GraphRouteSegment[]): Float32Array {
let lineCount = 0;
for (const route of routeSegments) {
lineCount += Math.max(0, route.points.length - 1);
}
const out = new Float32Array(lineCount * 4);
let offset = 0;
for (const route of routeSegments) {
for (let i = 1; i < route.points.length; i++) {
const previous = route.points[i - 1];
const current = route.points[i];
out[offset++] = previous.x;
out[offset++] = previous.y;
out[offset++] = current.x;
out[offset++] = current.y;
}
}
return out;
}
type TripleResultState = {
status: "idle" | "loading" | "ready" | "error";
queryId: string;
selectedIds: number[];
triples: SelectionTriple[];
errorMessage?: string;
};
function idleTripleResult(queryId: string): TripleResultState {
return {
status: "idle",
queryId,
selectedIds: [],
triples: [],
};
}
export default function App() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<Renderer | null>(null);
@@ -28,14 +111,17 @@ export default function App() {
const [activeGraphQueryId, setActiveGraphQueryId] = useState<string>("default");
const [selectionQueries, setSelectionQueries] = useState<SelectionQueryMeta[]>([]);
const [activeSelectionQueryId, setActiveSelectionQueryId] = useState<string>("neighbors");
const [tripleResult, setTripleResult] = useState<TripleResultState>(() => idleTripleResult("neighbors"));
const [tripleGraphModel, setTripleGraphModel] = useState<TripleGraphModel | null>(null);
const [backendStats, setBackendStats] = useState<{ nodes: number; edges: number; backend?: string } | null>(null);
const graphMetaRef = useRef<GraphMeta | null>(null);
const selectionReqIdRef = useRef(0);
const tripleReqIdRef = 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[]>([]);
const nodesRef = useRef<GraphNodeMeta[]>([]);
async function loadGraph(graphQueryId: string, signal: AbortSignal): Promise<void> {
const renderer = rendererRef.current;
@@ -60,11 +146,13 @@ export default function App() {
const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
const edges = Array.isArray(graph.edges) ? graph.edges : [];
const routeSegments = graphRouteSegmentArray(graph.route_segments);
const meta = graph.meta || null;
const count = nodes.length;
nodesRef.current = nodes;
graphMetaRef.current = meta && typeof meta === "object" ? (meta as GraphMeta) : null;
setTripleResult(idleTripleResult(activeSelectionQueryId));
// Build positions from backend-provided node coordinates.
setStatus("Preparing buffers…");
@@ -90,6 +178,7 @@ export default function App() {
edgeData[i * 2] = typeof s === "number" ? s >>> 0 : 0;
edgeData[i * 2 + 1] = typeof t === "number" ? t >>> 0 : 0;
}
const routeLineVertices = buildRouteLineVertices(routeSegments);
// Use /api/graph meta; don't do a second expensive backend call.
if (meta && typeof meta.nodes === "number" && typeof meta.edges === "number") {
@@ -106,13 +195,32 @@ export default function App() {
await new Promise((r) => setTimeout(r, 0));
if (signal.aborted) return;
const buildMs = renderer.init(xs, ys, vertexIds, edgeData);
const buildMs = renderer.init(
xs,
ys,
vertexIds,
edgeData,
routeLineVertices.length > 0 ? routeLineVertices : null
);
setNodeCount(renderer.getNodeCount());
setSelectedNodes(new Set());
setStatus("");
console.log(`Init complete: ${count.toLocaleString()} nodes, ${edges.length.toLocaleString()} edges in ${buildMs.toFixed(0)}ms`);
}
function getSelectedIds(renderer: Renderer, selected: Set<number>): number[] {
const selectedIds: number[] = [];
for (const sortedIdx of selected) {
const origIdx = renderer.sortedIndexToOriginalIndex(sortedIdx);
if (origIdx === null) continue;
const node = nodesRef.current?.[origIdx];
const nodeId = node?.id;
if (typeof nodeId !== "number") continue;
selectedIds.push(nodeId);
}
return selectedIds;
}
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -186,14 +294,14 @@ export default function App() {
}
})();
// ── Input handling ──
// Input handling
let dragging = false;
let didDrag = false; // true if mouse moved significantly during drag
let didDrag = false;
let downX = 0;
let downY = 0;
let lastX = 0;
let lastY = 0;
const DRAG_THRESHOLD = 5; // pixels
const DRAG_THRESHOLD = 5;
const onDown = (e: MouseEvent) => {
dragging = true;
@@ -207,7 +315,6 @@ export default function App() {
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) {
@@ -220,15 +327,14 @@ export default function App() {
};
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
next.delete(node.index);
} else {
next.add(node.index); // Select
next.add(node.index);
}
return next;
});
@@ -252,7 +358,7 @@ export default function App() {
canvas.addEventListener("wheel", onWheel, { passive: false });
canvas.addEventListener("mouseleave", onMouseLeave);
// ── Render loop ──
// Render loop
let frameCount = 0;
let lastTime = performance.now();
let raf = 0;
@@ -261,7 +367,6 @@ export default function App() {
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);
@@ -328,44 +433,30 @@ export default function App() {
return () => ctrl.abort();
}, [activeGraphQueryId]);
// Sync selection state to renderer
// Left-side selection highlighting path
useEffect(() => {
const renderer = rendererRef.current;
if (!renderer) return;
// Optimistically reflect selection immediately; highlights will be filled in by backend.
renderer.updateSelection(selectedNodes, new Set());
// 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 n = nodesRef.current?.[origIdx];
const nodeId = n?.id;
if (typeof nodeId !== "number") continue;
selectedIds.push(nodeId);
}
const selectedIds = getSelectedIds(renderer, selectedNodes);
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
if (selectedIds.length === 0) {
return;
}
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
const ctrl = new AbortController();
(async () => {
try {
const neighborIds = await runSelectionQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
const result = await runSelectionQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
if (ctrl.signal.aborted) return;
if (reqId !== selectionReqIdRef.current) return;
const neighborSorted = new Set<number>();
for (const id of neighborIds) {
for (const id of result.neighborIds) {
if (typeof id !== "number") continue;
const sorted = renderer.vertexIdToSortedIndexOrNull(id);
if (sorted === null) continue;
@@ -375,8 +466,8 @@ export default function App() {
renderer.updateSelection(selectedNodes, neighborSorted);
} catch (e) {
if (ctrl.signal.aborted) return;
if (reqId !== selectionReqIdRef.current) return;
console.warn(e);
// Keep the UI usable even if neighbors fail to load.
renderer.updateSelection(selectedNodes, new Set());
}
})();
@@ -384,213 +475,369 @@ export default function App() {
return () => ctrl.abort();
}, [selectedNodes, activeSelectionQueryId]);
// Right-side triple graph path
useEffect(() => {
const renderer = rendererRef.current;
if (!renderer) return;
const reqId = ++tripleReqIdRef.current;
const selectedIds = getSelectedIds(renderer, selectedNodes);
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
if (selectedIds.length === 0) {
setTripleResult(idleTripleResult(queryId));
return;
}
const ctrl = new AbortController();
setTripleResult({
status: "loading",
queryId,
selectedIds,
triples: [],
});
(async () => {
try {
const result = await runSelectionTripleQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
if (ctrl.signal.aborted) return;
if (reqId !== tripleReqIdRef.current) return;
setTripleResult({
status: "ready",
queryId: result.queryId,
selectedIds: result.selectedIds,
triples: result.triples,
});
} catch (e) {
if (ctrl.signal.aborted) return;
if (reqId !== tripleReqIdRef.current) return;
console.warn(e);
setTripleResult({
status: "error",
queryId,
selectedIds,
triples: [],
errorMessage: e instanceof Error ? e.message : String(e),
});
}
})();
return () => ctrl.abort();
}, [selectedNodes, activeSelectionQueryId]);
useEffect(() => {
if (tripleResult.status !== "ready") {
setTripleGraphModel(null);
return;
}
setTripleGraphModel(buildTripleGraphModel(tripleResult.triples, tripleResult.selectedIds));
}, [tripleResult]);
const resultQueryId = (tripleResult.queryId || activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
const resultQueryLabel = selectionQueries.find((q) => q.id === resultQueryId)?.label ?? resultQueryId;
return (
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
<canvas
ref={canvasRef}
style={{ display: "block", width: "100%", height: "100%" }}
/>
<div style={{ width: "100vw", height: "100vh", display: "flex", overflow: "hidden", background: "#000" }}>
<div style={{ position: "relative", flex: "1 1 50%", minWidth: 0, 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 && (
<>
{status && (
<div
style={{
position: "absolute",
top: 10,
left: 10,
background: "rgba(0,0,0,0.75)",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.9)",
color: "#0f0",
fontFamily: "monospace",
padding: "8px 12px",
fontSize: "12px",
lineHeight: "1.6",
borderRadius: "4px",
pointerEvents: "none",
fontSize: "16px",
}}
>
<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>
)}
{status}
</div>
)}
{error && (
<div
style={{
position: "absolute",
bottom: 10,
left: 10,
background: "rgba(0,0,0,0.75)",
color: "#888",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.9)",
color: "#f44",
fontFamily: "monospace",
padding: "6px 10px",
fontSize: "11px",
borderRadius: "4px",
pointerEvents: "none",
fontSize: "16px",
}}
>
Drag to pan · Scroll to zoom · Click to select
Error: {error}
</div>
)}
{/* Selection query buttons */}
{selectionQueries.length > 0 && (
{!status && !error && (
<>
<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",
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",
}}
>
{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>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>
)}
{/* 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",
left: 10,
background: "rgba(0,0,0,0.75)",
color: "#888",
fontFamily: "monospace",
padding: "6px 10px",
fontSize: "11px",
borderRadius: "4px",
pointerEvents: "none",
}}
>
{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>
);
})}
Drag to pan · Scroll to zoom · Click to select
</div>
{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>
)}
{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>
)}
{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>
<div
style={{
flex: "1 1 50%",
minWidth: 0,
display: "flex",
flexDirection: "column",
background: "#050505",
borderLeft: "1px solid rgba(255,255,255,0.08)",
}}
>
<div
style={{
padding: "18px 20px 14px",
borderBottom: "1px solid rgba(255,255,255,0.08)",
background: "rgba(255,255,255,0.02)",
fontFamily: "monospace",
}}
>
<div style={{ color: "#688", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
{resultQueryLabel}
</div>
<div style={{ marginTop: "6px", color: "#eee", fontSize: "16px" }}>Selection Graph</div>
<div style={{ marginTop: "8px", color: "#8ab", fontSize: "12px" }}>
Nodes: {(tripleGraphModel?.nodeCount ?? 0).toLocaleString()} · Edges: {(tripleGraphModel?.edgeCount ?? 0).toLocaleString()}
</div>
<div style={{ marginTop: "4px", color: "#6f8b98", fontSize: "11px" }}>
Layout: {cosmosRuntimeConfig.enableSimulation ? "force-directed" : "static"} · Camera: static · Center force: {cosmosRuntimeConfig.simulationCenter} · Repulsion: {cosmosRuntimeConfig.simulationRepulsion} · Link spring: {cosmosRuntimeConfig.simulationLinkSpring} · Friction: {cosmosRuntimeConfig.simulationFriction}
</div>
</div>
<div
style={{
flex: 1,
minHeight: 0,
fontFamily: "monospace",
position: "relative",
display: "flex",
flexDirection: "column",
}}
>
{tripleResult.status === "idle" && (
<div
style={{
color: "#8a8a8a",
fontSize: "13px",
lineHeight: 1.6,
padding: "14px",
}}
>
Select nodes on the left to view returned triples
</div>
)}
{/* Hover tooltip */}
{hoveredNode && (
{tripleResult.status === "loading" && (
<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)",
color: "#8ab",
fontSize: "13px",
lineHeight: 1.6,
padding: "14px",
}}
>
<div style={{ color: "#0ff" }}>
{hoveredNode.label || hoveredNode.iri || "(unknown)"}
</div>
<div style={{ color: "#688" }}>
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
</div>
Running triple query
</div>
)}
</>
)}
{tripleResult.status === "error" && (
<div
style={{
color: "#f88",
fontSize: "13px",
lineHeight: 1.6,
padding: "14px",
}}
>
{tripleResult.errorMessage || "Triple query failed"}
</div>
)}
{tripleResult.status === "ready" && (!tripleGraphModel || tripleGraphModel.edgeCount === 0) && (
<div
style={{
color: "#8a8a8a",
fontSize: "13px",
lineHeight: 1.6,
padding: "14px",
}}
>
No returned graph
</div>
)}
{tripleResult.status === "ready" && tripleGraphModel && tripleGraphModel.edgeCount > 0 && (
<TripleGraphView model={tripleGraphModel} />
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,484 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Graph, type GraphConfig } from "@cosmos.gl/graph";
import { cosmosRuntimeConfig } from "./cosmos_config";
import {
computeLayoutMetrics,
type GraphLayoutMetrics,
type TripleGraphLink,
type TripleGraphModel,
type TripleGraphNode,
} from "./triple_graph";
type TripleGraphViewProps = {
model: TripleGraphModel;
};
type InspectState =
| { kind: "node"; node: TripleGraphNode }
| { kind: "link"; link: TripleGraphLink }
| null;
type LayoutDebugState = {
phase: "idle" | "running" | "ended";
alpha: number | null;
progress: number;
currentMetrics: GraphLayoutMetrics;
lastEvent: string;
zoomLevel: number;
screenCenter: { x: number; y: number };
screenOrigin: { x: number; y: number };
screenCentroid: { x: number; y: number };
originDelta: { x: number; y: number };
centroidDelta: { x: number; y: number };
nearSpaceBoundary: boolean;
};
export const TripleGraphView = memo(function TripleGraphView({ model }: TripleGraphViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph | null>(null);
const modelRef = useRef(model);
const debugLogTimeRef = useRef(0);
const [hovered, setHovered] = useState<InspectState>(null);
const [pinned, setPinned] = useState<InspectState>(null);
const [layoutDebug, setLayoutDebug] = useState<LayoutDebugState>({
phase: "idle",
alpha: null,
progress: 0,
currentMetrics: model.seedMetrics,
lastEvent: "seed",
zoomLevel: 0,
screenCenter: { x: 0, y: 0 },
screenOrigin: { x: 0, y: 0 },
screenCentroid: { x: 0, y: 0 },
originDelta: { x: 0, y: 0 },
centroidDelta: { x: 0, y: 0 },
nearSpaceBoundary: false,
});
const activeDetail = useMemo(() => pinned ?? hovered, [pinned, hovered]);
useEffect(() => {
modelRef.current = model;
}, [model]);
useEffect(() => {
setLayoutDebug({
phase: "idle",
alpha: null,
progress: 0,
currentMetrics: model.seedMetrics,
lastEvent: "seed",
zoomLevel: 0,
screenCenter: { x: 0, y: 0 },
screenOrigin: { x: 0, y: 0 },
screenCentroid: { x: 0, y: 0 },
originDelta: { x: 0, y: 0 },
centroidDelta: { x: 0, y: 0 },
nearSpaceBoundary: false,
});
if (cosmosRuntimeConfig.debugLayout) {
console.debug("[cosmos-layout]", {
event: "seed-applied",
seedCentroid: {
x: Number(model.seedMetrics.centroidX.toFixed(3)),
y: Number(model.seedMetrics.centroidY.toFixed(3)),
},
bounds: {
width: Number(model.seedMetrics.width.toFixed(3)),
height: Number(model.seedMetrics.height.toFixed(3)),
maxRadius: Number(model.seedMetrics.maxRadius.toFixed(3)),
},
});
}
}, [model]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const reheatSimulation = () => {
if (!cosmosRuntimeConfig.enableSimulation) return;
graphRef.current?.start(0.25);
};
const reportLayout = (event: string, phase: LayoutDebugState["phase"], alpha?: number) => {
const graph = graphRef.current;
if (!graph || !cosmosRuntimeConfig.debugLayout) return;
const currentMetrics = computeLayoutMetrics(graph.getPointPositions());
const containerRect = container.getBoundingClientRect();
const screenCenter = {
x: containerRect.width / 2,
y: containerRect.height / 2,
};
const screenOriginTuple = graph.spaceToScreenPosition([0, 0]);
const screenCentroidTuple = graph.spaceToScreenPosition([
currentMetrics.centroidX,
currentMetrics.centroidY,
]);
const screenOrigin = { x: screenOriginTuple[0], y: screenOriginTuple[1] };
const screenCentroid = { x: screenCentroidTuple[0], y: screenCentroidTuple[1] };
const originDelta = {
x: screenOrigin.x - screenCenter.x,
y: screenOrigin.y - screenCenter.y,
};
const centroidDelta = {
x: screenCentroid.x - screenCenter.x,
y: screenCentroid.y - screenCenter.y,
};
const boundaryMargin = cosmosRuntimeConfig.spaceSize * 0.02;
const nearSpaceBoundary =
currentMetrics.minX <= boundaryMargin ||
currentMetrics.maxX >= cosmosRuntimeConfig.spaceSize - boundaryMargin ||
currentMetrics.minY <= boundaryMargin ||
currentMetrics.maxY >= cosmosRuntimeConfig.spaceSize - boundaryMargin;
const now = performance.now();
const shouldPublish = event !== "tick" || now - debugLogTimeRef.current >= 250;
const next: LayoutDebugState = {
phase,
alpha: typeof alpha === "number" ? alpha : null,
progress: graph.progress,
currentMetrics,
lastEvent: event,
zoomLevel: graph.getZoomLevel(),
screenCenter,
screenOrigin,
screenCentroid,
originDelta,
centroidDelta,
nearSpaceBoundary,
};
if (!shouldPublish) return;
debugLogTimeRef.current = now;
setLayoutDebug(next);
console.debug("[cosmos-layout]", {
event,
phase,
alpha: next.alpha,
progress: Number(next.progress.toFixed(4)),
seedCentroid: {
x: Number(modelRef.current.seedMetrics.centroidX.toFixed(3)),
y: Number(modelRef.current.seedMetrics.centroidY.toFixed(3)),
},
currentCentroid: {
x: Number(currentMetrics.centroidX.toFixed(3)),
y: Number(currentMetrics.centroidY.toFixed(3)),
},
screenCenter: {
x: Number(screenCenter.x.toFixed(2)),
y: Number(screenCenter.y.toFixed(2)),
},
screenOrigin: {
x: Number(screenOrigin.x.toFixed(2)),
y: Number(screenOrigin.y.toFixed(2)),
},
screenCentroid: {
x: Number(screenCentroid.x.toFixed(2)),
y: Number(screenCentroid.y.toFixed(2)),
},
originDelta: {
x: Number(originDelta.x.toFixed(2)),
y: Number(originDelta.y.toFixed(2)),
},
centroidDelta: {
x: Number(centroidDelta.x.toFixed(2)),
y: Number(centroidDelta.y.toFixed(2)),
},
zoomLevel: Number(next.zoomLevel.toFixed(4)),
nearSpaceBoundary,
bounds: {
width: Number(currentMetrics.width.toFixed(3)),
height: Number(currentMetrics.height.toFixed(3)),
maxRadius: Number(currentMetrics.maxRadius.toFixed(3)),
},
});
};
const config: GraphConfig = {
backgroundColor: "#05070a",
spaceSize: cosmosRuntimeConfig.spaceSize,
enableSimulation: cosmosRuntimeConfig.enableSimulation,
enableDrag: true,
enableZoom: true,
fitViewOnInit: false,
fitViewPadding: cosmosRuntimeConfig.fitViewPadding,
rescalePositions: false,
curvedLinks: cosmosRuntimeConfig.curvedLinks,
simulationDecay: cosmosRuntimeConfig.simulationDecay,
simulationGravity: cosmosRuntimeConfig.simulationGravity,
simulationCenter: cosmosRuntimeConfig.simulationCenter,
simulationRepulsion: cosmosRuntimeConfig.simulationRepulsion,
simulationLinkSpring: cosmosRuntimeConfig.simulationLinkSpring,
simulationLinkDistance: cosmosRuntimeConfig.simulationLinkDistance,
simulationFriction: cosmosRuntimeConfig.simulationFriction,
renderHoveredPointRing: true,
hoveredPointRingColor: "#35d6ff",
hoveredPointCursor: "pointer",
hoveredLinkCursor: "pointer",
hoveredLinkColor: "#ffd166",
hoveredLinkWidthIncrease: 2.5,
onSimulationStart: () => {
reportLayout("simulation-start", "running", 1);
},
onSimulationTick: (alpha) => {
reportLayout("tick", "running", alpha);
},
onSimulationEnd: () => {
reportLayout("simulation-end", "ended", 0);
},
onPointMouseOver: (index) => {
const node = modelRef.current.nodes[index];
if (!node) return;
setHovered({ kind: "node", node });
},
onPointMouseOut: () => {
setHovered((prev) => (prev?.kind === "node" ? null : prev));
},
onLinkMouseOver: (linkIndex) => {
const link = modelRef.current.linksMeta[linkIndex];
if (!link) return;
setHovered({ kind: "link", link });
},
onLinkMouseOut: () => {
setHovered((prev) => (prev?.kind === "link" ? null : prev));
},
onPointClick: (index) => {
const node = modelRef.current.nodes[index];
if (!node) return;
setPinned({ kind: "node", node });
},
onLinkClick: (linkIndex) => {
const link = modelRef.current.linksMeta[linkIndex];
if (!link) return;
setPinned({ kind: "link", link });
},
onClick: (index) => {
if (typeof index === "number") return;
setPinned(null);
},
onDragStart: () => {
reportLayout("drag-start", "running");
reheatSimulation();
},
onDragEnd: () => {
reportLayout("drag-end", "running");
reheatSimulation();
},
};
const graph = new Graph(container, config);
graphRef.current = graph;
if (cosmosRuntimeConfig.debugLayout) {
console.debug("[cosmos-layout]", {
event: "graph-created",
seedCentroid: {
x: Number(modelRef.current.seedMetrics.centroidX.toFixed(3)),
y: Number(modelRef.current.seedMetrics.centroidY.toFixed(3)),
},
seedRadius: Number(modelRef.current.seedMetrics.maxRadius.toFixed(3)),
});
}
return () => {
setHovered(null);
setPinned(null);
graphRef.current = null;
graph.destroy();
};
}, []);
useEffect(() => {
const graph = graphRef.current;
if (!graph) return;
setHovered(null);
setPinned(null);
applyGraphModel(graph, model);
if (cosmosRuntimeConfig.debugLayout) {
requestAnimationFrame(() => {
const positionedGraph = graphRef.current;
if (!positionedGraph) return;
const currentMetrics = computeLayoutMetrics(positionedGraph.getPointPositions());
const origin = positionedGraph.spaceToScreenPosition([0, 0]);
const centroid = positionedGraph.spaceToScreenPosition([
currentMetrics.centroidX,
currentMetrics.centroidY,
]);
console.debug("[cosmos-layout]", {
event: "after-fit-requested",
screenOrigin: { x: Number(origin[0].toFixed(2)), y: Number(origin[1].toFixed(2)) },
screenCentroid: { x: Number(centroid[0].toFixed(2)), y: Number(centroid[1].toFixed(2)) },
});
});
}
}, [model]);
useEffect(() => {
const graph = graphRef.current;
if (!graph) return;
graph.setConfig({
focusedPointIndex: activeDetail?.kind === "node" ? activeDetail.node.index : undefined,
});
}, [activeDetail]);
return (
<div style={{ position: "relative", flex: 1, minHeight: 0, background: "#05070a" }}>
<div
ref={containerRef}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}
/>
{cosmosRuntimeConfig.debugLayout && (
<div
style={{
position: "absolute",
top: 12,
left: 12,
maxWidth: "min(340px, calc(100% - 24px))",
background: "rgba(4, 7, 11, 0.94)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "10px",
padding: "10px 12px",
color: "#d7e2ea",
fontFamily: "monospace",
fontSize: "11px",
lineHeight: 1.5,
boxShadow: "0 10px 28px rgba(0,0,0,0.35)",
pointerEvents: "none",
}}
>
<div style={{ color: "#88a9b9", textTransform: "uppercase", letterSpacing: "0.08em" }}>
Layout Debug
</div>
<div style={{ marginTop: "6px" }}>
phase: {layoutDebug.phase} · event: {layoutDebug.lastEvent}
</div>
<div>
alpha: {formatMaybeNumber(layoutDebug.alpha)} · progress: {formatNumber(layoutDebug.progress)}
</div>
<div>zoom: {formatNumber(layoutDebug.zoomLevel)}</div>
<div style={{ marginTop: "8px", color: "#9ac7d8" }}>seed centroid</div>
<div>
({formatNumber(model.seedMetrics.centroidX)}, {formatNumber(model.seedMetrics.centroidY)})
</div>
<div>
bounds: {formatNumber(model.seedMetrics.width)} × {formatNumber(model.seedMetrics.height)} · r={formatNumber(model.seedMetrics.maxRadius)}
</div>
<div style={{ marginTop: "8px", color: "#f0c674" }}>current centroid</div>
<div>
({formatNumber(layoutDebug.currentMetrics.centroidX)}, {formatNumber(layoutDebug.currentMetrics.centroidY)})
</div>
<div>
bounds: {formatNumber(layoutDebug.currentMetrics.width)} × {formatNumber(layoutDebug.currentMetrics.height)} · r={formatNumber(layoutDebug.currentMetrics.maxRadius)}
</div>
<div style={{ marginTop: "8px", color: "#9ac7d8" }}>screen center</div>
<div>
({formatNumber(layoutDebug.screenCenter.x)}, {formatNumber(layoutDebug.screenCenter.y)})
</div>
<div style={{ marginTop: "8px", color: "#f0c674" }}>screen origin</div>
<div>
({formatNumber(layoutDebug.screenOrigin.x)}, {formatNumber(layoutDebug.screenOrigin.y)}) d=({formatNumber(layoutDebug.originDelta.x)}, {formatNumber(layoutDebug.originDelta.y)})
</div>
<div style={{ marginTop: "8px", color: "#f0c674" }}>screen centroid</div>
<div>
({formatNumber(layoutDebug.screenCentroid.x)}, {formatNumber(layoutDebug.screenCentroid.y)}) d=({formatNumber(layoutDebug.centroidDelta.x)}, {formatNumber(layoutDebug.centroidDelta.y)})
</div>
<div style={{ marginTop: "8px", color: layoutDebug.nearSpaceBoundary ? "#ff8b8b" : "#8fd2a8" }}>
near space boundary: {layoutDebug.nearSpaceBoundary ? "yes" : "no"}
</div>
</div>
)}
<div
style={{
position: "absolute",
right: 12,
bottom: 12,
width: "min(420px, calc(100% - 24px))",
maxHeight: "calc(100% - 24px)",
overflowY: "auto",
background: "rgba(4, 7, 11, 0.94)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: "10px",
padding: "12px 14px",
color: "#d7e2ea",
fontFamily: "monospace",
fontSize: "12px",
lineHeight: 1.5,
boxShadow: "0 10px 28px rgba(0,0,0,0.35)",
wordBreak: "break-all",
pointerEvents: "none",
}}
>
<div style={{ color: "#88a9b9", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
{pinned ? "Pinned details" : activeDetail ? "Hovered details" : "Inspector"}
</div>
{!activeDetail && (
<div style={{ marginTop: "8px", color: "#a7b7c2" }}>
Hover a node or edge to inspect it. Click a node or edge to pin its details.
</div>
)}
{activeDetail?.kind === "node" && (
<>
<div style={{ marginTop: "8px", color: activeDetail.node.isSelectedSource ? "#35d6ff" : "#9ac7d8", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
Node
</div>
<div style={{ marginTop: "4px" }}>{activeDetail.node.text}</div>
{typeof activeDetail.node.backendId === "number" && (
<div style={{ marginTop: "6px", color: "#88a9b9" }}>
backend id: {activeDetail.node.backendId}
</div>
)}
{activeDetail.node.isSelectedSource && (
<div style={{ marginTop: "4px", color: "#35d6ff" }}>
selected source node
</div>
)}
</>
)}
{activeDetail?.kind === "link" && (
<>
<div style={{ marginTop: "8px", color: "#f0c674", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
Edge
</div>
<div style={{ marginTop: "4px", color: "#f0c674" }}>{activeDetail.link.predicateText}</div>
<div style={{ marginTop: "8px", color: "#88a9b9" }}>from</div>
<div>{activeDetail.link.sourceText}</div>
<div style={{ marginTop: "8px", color: "#88a9b9" }}>to</div>
<div>{activeDetail.link.targetText}</div>
{typeof activeDetail.link.predicateId === "number" && (
<div style={{ marginTop: "6px", color: "#88a9b9" }}>
predicate id: {activeDetail.link.predicateId}
</div>
)}
</>
)}
</div>
</div>
);
});
function applyGraphModel(graph: Graph, model: TripleGraphModel): void {
graph.setPointPositions(model.pointPositions);
graph.setLinks(model.links);
graph.setPointColors(model.pointColors);
graph.setPointSizes(model.pointSizes);
graph.setLinkColors(model.linkColors);
graph.setLinkWidths(model.linkWidths);
graph.render(0);
requestAnimationFrame(() => {
graph.fitViewByPointPositions(Array.from(model.pointPositions), 0, cosmosRuntimeConfig.fitViewPadding);
if (cosmosRuntimeConfig.enableSimulation) {
graph.start(1);
}
});
}
function formatNumber(value: number): string {
return value.toFixed(2);
}
function formatMaybeNumber(value: number | null): string {
return value === null ? "-" : value.toFixed(3);
}

View File

@@ -0,0 +1,28 @@
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return fallback;
}
function parseNumber(value: string | undefined, fallback: number): number {
if (value === undefined) return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export const cosmosRuntimeConfig = {
enableSimulation: parseBoolean(import.meta.env.VITE_COSMOS_ENABLE_SIMULATION, true),
debugLayout: parseBoolean(import.meta.env.VITE_COSMOS_DEBUG_LAYOUT, false),
spaceSize: parseNumber(import.meta.env.VITE_COSMOS_SPACE_SIZE, 4096),
curvedLinks: parseBoolean(import.meta.env.VITE_COSMOS_CURVED_LINKS, true),
fitViewPadding: parseNumber(import.meta.env.VITE_COSMOS_FIT_VIEW_PADDING, 0.12),
simulationDecay: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_DECAY, 5000),
simulationGravity: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_GRAVITY, 0),
simulationCenter: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_CENTER, 0.05),
simulationRepulsion: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_REPULSION, 0.5),
simulationLinkSpring: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_LINK_SPRING, 1),
simulationLinkDistance: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_LINK_DISTANCE, 10),
simulationFriction: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_FRICTION, 0.1),
} as const;

View File

@@ -76,6 +76,9 @@ export class Renderer {
private selectedProgram: WebGLProgram;
private neighborProgram: WebGLProgram;
private vao: WebGLVertexArrayObject;
private nodeVbo: WebGLBuffer;
private lineVao: WebGLVertexArrayObject;
private lineVbo: WebGLBuffer;
// Data
private leaves: Leaf[] = [];
@@ -88,6 +91,8 @@ export class Renderer {
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
private maxPtSize = 256;
private useRawLineSegments = false;
private rawLineVertexCount = 0;
// Multi-draw extension
private multiDrawExt: any = null;
@@ -163,15 +168,23 @@ export class Renderer {
// Create VAO + VBO (empty for now)
this.vao = gl.createVertexArray()!;
this.nodeVbo = gl.createBuffer()!;
gl.bindVertexArray(this.vao);
const vbo = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeVbo);
// We forced a_pos to location 0 in compileProgram
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
this.lineVao = gl.createVertexArray()!;
this.lineVbo = gl.createBuffer()!;
gl.bindVertexArray(this.lineVao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineVbo);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
this.linesIbo = gl.createBuffer()!;
this.selectionIbo = gl.createBuffer()!;
this.neighborIbo = gl.createBuffer()!;
@@ -192,7 +205,8 @@ export class Renderer {
xs: Float32Array,
ys: Float32Array,
vertexIds: Uint32Array,
edges: Uint32Array
edges: Uint32Array,
routeLineVertices: Float32Array | null = null
): number {
const t0 = performance.now();
const gl = this.gl;
@@ -213,6 +227,7 @@ export class Renderer {
// Upload sorted particles to GPU as STATIC VBO (never changes)
gl.bindVertexArray(this.vao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeVbo);
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
gl.bindVertexArray(null);
@@ -236,6 +251,19 @@ export class Renderer {
}
this.vertexIdToSortedIndex = vertexIdToSortedIndex;
this.useRawLineSegments = routeLineVertices !== null && routeLineVertices.length > 0;
this.rawLineVertexCount = this.useRawLineSegments && routeLineVertices ? routeLineVertices.length / 2 : 0;
if (this.useRawLineSegments && routeLineVertices) {
this.edgeCount = edgeCount;
this.leafEdgeStarts = new Uint32Array(0);
this.leafEdgeCounts = new Uint32Array(0);
gl.bindVertexArray(this.lineVao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineVbo);
gl.bufferData(gl.ARRAY_BUFFER, routeLineVertices, gl.STATIC_DRAW);
gl.bindVertexArray(null);
return performance.now() - t0;
}
// Remap edges from vertex IDs to sorted indices
const lineIndices = new Uint32Array(edgeCount * 2);
let validEdges = 0;
@@ -572,24 +600,30 @@ export class Renderer {
}
// 5. Draw Lines if deeply zoomed in (< 20k total visible particles)
if (totalVisibleParticles < 20000 && visibleCount > 0) {
if (totalVisibleParticles < 20000) {
gl.useProgram(this.lineProgram);
gl.uniform2f(this.uCenterLine, this.cx, this.cy);
gl.uniform2f(this.uScaleLine, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
if (this.useRawLineSegments) {
gl.bindVertexArray(this.lineVao);
gl.drawArrays(gl.LINES, 0, this.rawLineVertexCount);
gl.bindVertexArray(this.vao);
} else if (visibleCount > 0) {
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
for (let i = 0; i < visibleCount; i++) {
const leafIdx = this.visibleLeafIndices[i];
const edgeCount = this.leafEdgeCounts[leafIdx];
if (edgeCount === 0) continue;
// Each edge is 2 indices (1 line segment)
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
const edgeStart = this.leafEdgeStarts[leafIdx];
gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
for (let i = 0; i < visibleCount; i++) {
const leafIdx = this.visibleLeafIndices[i];
const edgeCount = this.leafEdgeCounts[leafIdx];
if (edgeCount === 0) continue;
// Each edge is 2 indices (1 line segment)
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
const edgeStart = this.leafEdgeStarts[leafIdx];
gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}
// 6. Draw Neighbor Nodes (yellow) - drawn before selected so selected appears on top

View File

@@ -1,4 +1,53 @@
import type { GraphMeta, SelectionQueryMeta } from "./types";
import type {
GraphMeta,
SelectionQueryMeta,
SelectionQueryResult,
SelectionTriple,
SelectionTripleResult,
SelectionTripleTerm,
} from "./types";
function numberArray(value: unknown): number[] {
if (!Array.isArray(value)) return [];
const out: number[] = [];
for (const item of value) {
if (typeof item === "number") out.push(item);
}
return out;
}
function tripleTerm(value: unknown): SelectionTripleTerm | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (typeof record.type !== "string" || typeof record.value !== "string") return null;
return {
type: record.type,
value: record.value,
lang: typeof record.lang === "string" ? record.lang : undefined,
};
}
function tripleArray(value: unknown): SelectionTriple[] {
if (!Array.isArray(value)) return [];
const out: SelectionTriple[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
const s = tripleTerm(record.s);
const p = tripleTerm(record.p);
const o = tripleTerm(record.o);
if (!s || !p || !o) continue;
out.push({
s,
p,
o,
subject_id: typeof record.subject_id === "number" ? record.subject_id : undefined,
predicate_id: typeof record.predicate_id === "number" ? record.predicate_id : undefined,
object_id: typeof record.object_id === "number" ? record.object_id : undefined,
});
}
return out;
}
export async function fetchSelectionQueries(signal?: AbortSignal): Promise<SelectionQueryMeta[]> {
const res = await fetch("/api/selection_queries", { signal });
@@ -12,7 +61,7 @@ export async function runSelectionQuery(
selectedIds: number[],
graphMeta: GraphMeta | null,
signal: AbortSignal
): Promise<number[]> {
): Promise<SelectionQueryResult> {
const body = {
query_id: queryId,
selected_ids: selectedIds,
@@ -29,9 +78,40 @@ export async function runSelectionQuery(
});
if (!res.ok) throw new Error(`POST /api/selection_query failed: ${res.status}`);
const data = await res.json();
const ids: unknown = data?.neighbor_ids;
if (!Array.isArray(ids)) return [];
const out: number[] = [];
for (const id of ids) if (typeof id === "number") out.push(id);
return out;
return {
queryId: typeof data?.query_id === "string" ? data.query_id : queryId,
selectedIds: numberArray(data?.selected_ids),
neighborIds: numberArray(data?.neighbor_ids),
};
}
export async function runSelectionTripleQuery(
queryId: string,
selectedIds: number[],
graphMeta: GraphMeta | null,
signal: AbortSignal
): Promise<SelectionTripleResult> {
const body = {
query_id: queryId,
selected_ids: selectedIds,
node_limit: typeof graphMeta?.node_limit === "number" ? graphMeta.node_limit : undefined,
edge_limit: typeof graphMeta?.edge_limit === "number" ? graphMeta.edge_limit : undefined,
graph_query_id: typeof graphMeta?.graph_query_id === "string" ? graphMeta.graph_query_id : undefined,
};
const res = await fetch("/api/selection_triples", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal,
});
if (!res.ok) throw new Error(`POST /api/selection_triples failed: ${res.status}`);
const data = await res.json();
return {
queryId: typeof data?.query_id === "string" ? data.query_id : queryId,
selectedIds: numberArray(data?.selected_ids),
triples: tripleArray(data?.triples),
};
}

View File

@@ -1,3 +1,9 @@
export { fetchSelectionQueries, runSelectionQuery } from "./api";
export type { GraphMeta, SelectionQueryMeta } from "./types";
export { fetchSelectionQueries, runSelectionQuery, runSelectionTripleQuery } from "./api";
export type {
GraphMeta,
GraphRoutePoint,
GraphRouteSegment,
SelectionQueryMeta,
SelectionTriple,
SelectionTripleResult,
} from "./types";

View File

@@ -8,9 +8,49 @@ export type GraphMeta = {
edge_limit?: number;
nodes?: number;
edges?: number;
layout_engine?: string;
layout_root_iri?: string | null;
};
export type GraphRoutePoint = {
x: number;
y: number;
};
export type GraphRouteSegment = {
edge_index: number;
kind: string;
points: GraphRoutePoint[];
};
export type SelectionQueryMeta = {
id: string;
label: string;
};
export type SelectionQueryResult = {
queryId: string;
selectedIds: number[];
neighborIds: number[];
};
export type SelectionTripleTerm = {
type: string;
value: string;
lang?: string;
};
export type SelectionTriple = {
s: SelectionTripleTerm;
p: SelectionTripleTerm;
o: SelectionTripleTerm;
subject_id?: number;
predicate_id?: number;
object_id?: number;
};
export type SelectionTripleResult = {
queryId: string;
selectedIds: number[];
triples: SelectionTriple[];
};

View File

@@ -0,0 +1,363 @@
import { cosmosRuntimeConfig } from "./cosmos_config";
import type { SelectionTriple } from "./selection_queries";
export type TripleGraphTerm = SelectionTriple["s"];
export type TripleGraphNode = {
key: string;
index: number;
term: TripleGraphTerm;
text: string;
backendId?: number;
isSelectedSource: boolean;
};
export type TripleGraphLink = {
index: number;
sourceIndex: number;
targetIndex: number;
sourceText: string;
targetText: string;
predicate: SelectionTriple["p"];
predicateText: string;
predicateId?: number;
triple: SelectionTriple;
};
export type TripleGraphModel = {
nodes: TripleGraphNode[];
linksMeta: TripleGraphLink[];
pointPositions: Float32Array;
seedMetrics: GraphLayoutMetrics;
pointColors: Float32Array;
pointSizes: Float32Array;
links: Float32Array;
linkColors: Float32Array;
linkWidths: Float32Array;
nodeCount: number;
edgeCount: number;
};
export type GraphLayoutMetrics = {
centroidX: number;
centroidY: number;
minX: number;
maxX: number;
minY: number;
maxY: number;
width: number;
height: number;
maxRadius: number;
};
type MutableNode = {
term: TripleGraphTerm;
text: string;
backendId?: number;
isSelectedSource: boolean;
};
export function buildTripleGraphModel(triples: SelectionTriple[], selectedIds: number[]): TripleGraphModel {
const selectedSet = new Set<number>(selectedIds);
const nodeMap = new Map<string, MutableNode>();
for (const triple of triples) {
addNode(nodeMap, triple.s, triple.subject_id, selectedSet);
addNode(nodeMap, triple.o, triple.object_id, selectedSet);
}
const nodes = Array.from(nodeMap.entries())
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([key, node], index) => ({
key,
index,
term: node.term,
text: node.text,
backendId: node.backendId,
isSelectedSource: node.isSelectedSource,
}));
const nodeIndexByKey = new Map<string, number>();
for (const node of nodes) {
nodeIndexByKey.set(node.key, node.index);
}
const linksMeta: TripleGraphLink[] = [];
for (const triple of triples) {
const sourceIndex = nodeIndexByKey.get(termKey(triple.s));
const targetIndex = nodeIndexByKey.get(termKey(triple.o));
if (sourceIndex === undefined || targetIndex === undefined) continue;
linksMeta.push({
index: linksMeta.length,
sourceIndex,
targetIndex,
sourceText: formatTermText(triple.s),
targetText: formatTermText(triple.o),
predicate: triple.p,
predicateText: formatTermText(triple.p),
predicateId: triple.predicate_id,
triple,
});
}
const pointPositions = buildPointPositions(nodes);
const seedMetrics = computeLayoutMetrics(pointPositions);
const pointColors = buildPointColors(nodes);
const pointSizes = buildPointSizes(nodes);
const links = buildLinks(linksMeta);
const linkColors = buildLinkColors(linksMeta);
const linkWidths = buildLinkWidths(linksMeta);
return {
nodes,
linksMeta,
pointPositions,
seedMetrics,
pointColors,
pointSizes,
links,
linkColors,
linkWidths,
nodeCount: nodes.length,
edgeCount: linksMeta.length,
};
}
function addNode(
nodeMap: Map<string, MutableNode>,
term: TripleGraphTerm,
backendId: number | undefined,
selectedSet: Set<number>
): void {
const key = termKey(term);
const existing = nodeMap.get(key);
const isSelectedSource = typeof backendId === "number" && selectedSet.has(backendId);
if (existing) {
if (existing.backendId === undefined && typeof backendId === "number") {
existing.backendId = backendId;
}
if (isSelectedSource) existing.isSelectedSource = true;
return;
}
nodeMap.set(key, {
term,
text: formatTermText(term),
backendId,
isSelectedSource,
});
}
function termKey(term: TripleGraphTerm): string {
return `${term.type}\x00${term.value}`;
}
function formatTermText(term: TripleGraphTerm): string {
if (term.type === "literal") {
if (term.lang) return `"${term.value}"@${term.lang}`;
return `"${term.value}"`;
}
return term.value;
}
function buildPointPositions(nodes: TripleGraphNode[]): Float32Array {
const out = new Float32Array(nodes.length * 2);
const simulationSpaceCenter = cosmosRuntimeConfig.spaceSize / 2;
if (nodes.length === 0) return out;
if (nodes.length === 1) {
out[0] = simulationSpaceCenter;
out[1] = simulationSpaceCenter;
return out;
}
for (const node of nodes) {
const primaryHash = hashString(node.key);
const secondaryHash = hashString(`${node.key}\x01`);
const angle = ((primaryHash % 3600) / 3600) * Math.PI * 2;
const radius = 80 + (((primaryHash >>> 12) % 1000) / 1000) * 70;
const jitterX = ((((secondaryHash >>> 4) % 200) / 200) - 0.5) * 18;
const jitterY = ((((secondaryHash >>> 12) % 200) / 200) - 0.5) * 18;
out[node.index * 2] = Math.cos(angle) * radius + jitterX;
out[node.index * 2 + 1] = Math.sin(angle) * radius + jitterY;
}
recenterPointPositions(out);
offsetPointPositionsToSimulationCenter(out, simulationSpaceCenter);
return out;
}
export function computeLayoutMetrics(pointPositions: ArrayLike<number>): GraphLayoutMetrics {
const pairCount = Math.floor(pointPositions.length / 2);
if (pairCount === 0) {
return {
centroidX: 0,
centroidY: 0,
minX: 0,
maxX: 0,
minY: 0,
maxY: 0,
width: 0,
height: 0,
maxRadius: 0,
};
}
let sumX = 0;
let sumY = 0;
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (let i = 0; i < pairCount; i++) {
const x = pointPositions[i * 2];
const y = pointPositions[i * 2 + 1];
sumX += x;
sumY += y;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
const centroidX = sumX / pairCount;
const centroidY = sumY / pairCount;
let maxRadius = 0;
for (let i = 0; i < pairCount; i++) {
const dx = pointPositions[i * 2] - centroidX;
const dy = pointPositions[i * 2 + 1] - centroidY;
const radius = Math.hypot(dx, dy);
if (radius > maxRadius) maxRadius = radius;
}
return {
centroidX,
centroidY,
minX,
maxX,
minY,
maxY,
width: maxX - minX,
height: maxY - minY,
maxRadius,
};
}
function recenterPointPositions(pointPositions: Float32Array): void {
const metrics = computeLayoutMetrics(pointPositions);
if (metrics.centroidX === 0 && metrics.centroidY === 0) return;
const pairCount = Math.floor(pointPositions.length / 2);
for (let i = 0; i < pairCount; i++) {
pointPositions[i * 2] -= metrics.centroidX;
pointPositions[i * 2 + 1] -= metrics.centroidY;
}
}
function offsetPointPositionsToSimulationCenter(pointPositions: Float32Array, center: number): void {
if (center === 0) return;
const pairCount = Math.floor(pointPositions.length / 2);
for (let i = 0; i < pairCount; i++) {
pointPositions[i * 2] += center;
pointPositions[i * 2 + 1] += center;
}
}
function buildPointColors(nodes: TripleGraphNode[]): Float32Array {
const out = new Float32Array(nodes.length * 4);
for (const node of nodes) {
const offset = node.index * 4;
const color = node.isSelectedSource ? [53, 214, 255, 1] : colorFromHash(node.key, 210, 35, 58, 18, 8);
out[offset] = color[0];
out[offset + 1] = color[1];
out[offset + 2] = color[2];
out[offset + 3] = color[3];
}
return out;
}
function buildPointSizes(nodes: TripleGraphNode[]): Float32Array {
const out = new Float32Array(nodes.length);
for (const node of nodes) {
out[node.index] = node.isSelectedSource ? 11 : 7.5;
}
return out;
}
function buildLinks(linksMeta: TripleGraphLink[]): Float32Array {
const out = new Float32Array(linksMeta.length * 2);
for (const link of linksMeta) {
const offset = link.index * 2;
out[offset] = link.sourceIndex;
out[offset + 1] = link.targetIndex;
}
return out;
}
function buildLinkColors(linksMeta: TripleGraphLink[]): Float32Array {
const out = new Float32Array(linksMeta.length * 4);
for (const link of linksMeta) {
const offset = link.index * 4;
const color = colorFromHash(link.predicateText, 28, 65, 58, 32, 10);
out[offset] = color[0];
out[offset + 1] = color[1];
out[offset + 2] = color[2];
out[offset + 3] = color[3];
}
return out;
}
function buildLinkWidths(linksMeta: TripleGraphLink[]): Float32Array {
const out = new Float32Array(linksMeta.length);
for (const link of linksMeta) {
out[link.index] = 1.8;
}
return out;
}
function colorFromHash(
value: string,
baseHue: number,
hueRange: number,
lightness: number,
saturation: number,
lightnessRange: number
): [number, number, number, number] {
const hash = hashString(value);
const hue = (baseHue + (hash % hueRange) + 360) % 360;
const sat = saturation + ((hash >>> 10) % 10);
const light = lightness + ((hash >>> 20) % lightnessRange) - lightnessRange / 2;
const [r, g, b] = hslToRgb(hue / 360, sat / 100, light / 100);
return [r, g, b, 1];
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
if (s === 0) {
const value = Math.round(l * 255);
return [value, value, value];
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const r = hueToRgb(p, q, h + 1 / 3);
const g = hueToRgb(p, q, h);
const b = hueToRgb(p, q, h - 1 / 3);
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
function hueToRgb(p: number, q: number, t: number): number {
let value = t;
if (value < 0) value += 1;
if (value > 1) value -= 1;
if (value < 1 / 6) return p + (q - p) * 6 * value;
if (value < 1 / 2) return q;
if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6;
return p;
}
function hashString(value: string): number {
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}

21
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BACKEND_URL?: string;
readonly VITE_COSMOS_ENABLE_SIMULATION?: string;
readonly VITE_COSMOS_DEBUG_LAYOUT?: string;
readonly VITE_COSMOS_SPACE_SIZE?: string;
readonly VITE_COSMOS_CURVED_LINKS?: string;
readonly VITE_COSMOS_FIT_VIEW_PADDING?: string;
readonly VITE_COSMOS_SIMULATION_DECAY?: string;
readonly VITE_COSMOS_SIMULATION_GRAVITY?: string;
readonly VITE_COSMOS_SIMULATION_CENTER?: string;
readonly VITE_COSMOS_SIMULATION_REPULSION?: string;
readonly VITE_COSMOS_SIMULATION_LINK_SPRING?: string;
readonly VITE_COSMOS_SIMULATION_LINK_DISTANCE?: string;
readonly VITE_COSMOS_SIMULATION_FRICTION?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}