Embeddable hybrid graph-vector database in OCaml
4.5 kB
167 lines
1import { useCallback, useEffect, useMemo, useRef } from "react";
2import ForceGraph2D from "react-force-graph-2d";
3import type { GraphData } from "@/api/types";
4
5interface GraphVisualisationProps {
6 data: GraphData;
7 width?: number;
8 height?: number;
9 selectedNodeId?: number;
10 onNodeClick: (nodeId: number, nodeType: string) => void;
11}
12
13interface FGNode {
14 id: number;
15 type: string;
16 label: string;
17 metadata: Record<string, string | number>;
18 x?: number;
19 y?: number;
20}
21
22interface FGLink {
23 source: number | FGNode;
24 target: number | FGNode;
25 type: string;
26}
27
28const NODE_COLOURS: Record<string, string> = {
29 paper: "#3b82f6",
30 author: "#22c55e",
31};
32
33const EDGE_COLOURS: Record<string, string> = {
34 cites: "#64748b",
35 authored: "#f97316",
36};
37
38export default function GraphVisualisation({
39 data,
40 width,
41 height,
42 selectedNodeId,
43 onNodeClick,
44}: GraphVisualisationProps) {
45 const fgRef = useRef<{
46 d3ReheatSimulation?: () => void;
47 zoomToFit?: (ms?: number, padding?: number) => void;
48 centerAt?: (x?: number, y?: number, ms?: number) => void;
49 zoom?: (zoom: number, ms?: number) => void;
50 }>(undefined);
51 const initialFitDone = useRef(false);
52 const pendingCentreRef = useRef<number | undefined>(undefined);
53
54 const graphData = useMemo(
55 () => ({
56 nodes: data.nodes.map((n) => ({ ...n })) as FGNode[],
57 links: data.edges.map((e) => ({
58 source: e.source,
59 target: e.target,
60 type: e.type,
61 })) as FGLink[],
62 }),
63 [data],
64 );
65
66 useEffect(() => {
67 if (selectedNodeId != null) {
68 pendingCentreRef.current = selectedNodeId;
69 }
70 }, [selectedNodeId]);
71
72 const handleEngineStop = useCallback(() => {
73 if (!initialFitDone.current && fgRef.current?.zoomToFit && data.nodes.length > 0) {
74 fgRef.current.zoomToFit(300, 40);
75 initialFitDone.current = true;
76 return;
77 }
78
79 const targetId = pendingCentreRef.current;
80 if (targetId != null && fgRef.current?.centerAt) {
81 const node = graphData.nodes.find((n) => n.id === targetId) as FGNode | undefined;
82 if (node?.x != null && node?.y != null) {
83 fgRef.current.centerAt(node.x, node.y, 400);
84 }
85 pendingCentreRef.current = undefined;
86 }
87 }, [data.nodes.length, graphData.nodes]);
88
89 const handleNodeClick = useCallback(
90 (node: FGNode) => {
91 onNodeClick(node.id, node.type);
92 },
93 [onNodeClick],
94 );
95
96 const paintNode = useCallback(
97 (node: FGNode, ctx: CanvasRenderingContext2D) => {
98 const r = node.type === "paper" ? 5 : 4;
99 const colour = NODE_COLOURS[node.type] ?? "#94a3b8";
100 const selected = node.id === selectedNodeId;
101
102 if (selected) {
103 ctx.beginPath();
104 ctx.arc(node.x ?? 0, node.y ?? 0, r + 3, 0, 2 * Math.PI);
105 ctx.strokeStyle = "#ffffff";
106 ctx.lineWidth = 1.5;
107 ctx.stroke();
108 }
109
110 ctx.beginPath();
111 ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI);
112 ctx.fillStyle = colour;
113 ctx.fill();
114 if (!selected) {
115 ctx.strokeStyle = "rgba(255,255,255,0.1)";
116 ctx.lineWidth = 0.5;
117 ctx.stroke();
118 }
119
120 ctx.font = "3px 'JetBrains Mono Variable', monospace";
121 ctx.textAlign = "center";
122 ctx.fillStyle = selected ? "#ffffff" : "rgba(255,255,255,0.6)";
123 const label =
124 node.label.length > 30
125 ? `${node.label.slice(0, 28)}...`
126 : node.label;
127 ctx.fillText(label, node.x ?? 0, (node.y ?? 0) + r + 4);
128 },
129 [selectedNodeId],
130 );
131
132 const paintLink = useCallback(
133 (link: FGLink, ctx: CanvasRenderingContext2D) => {
134 const src = link.source as FGNode;
135 const tgt = link.target as FGNode;
136 ctx.beginPath();
137 ctx.moveTo(src.x ?? 0, src.y ?? 0);
138 ctx.lineTo(tgt.x ?? 0, tgt.y ?? 0);
139 ctx.strokeStyle = EDGE_COLOURS[link.type] ?? "#475569";
140 ctx.lineWidth = 0.5;
141 ctx.stroke();
142 },
143 [],
144 );
145
146 const getNodeLabel = useCallback((node: FGNode) => node.label, []);
147
148 return (
149 <ForceGraph2D
150 ref={fgRef}
151 graphData={graphData}
152 width={width}
153 height={height}
154 nodeCanvasObject={paintNode}
155 linkCanvasObject={paintLink}
156 onNodeClick={handleNodeClick}
157 onEngineStop={handleEngineStop}
158 nodeLabel={getNodeLabel}
159 cooldownTicks={40}
160 d3AlphaDecay={0.05}
161 d3VelocityDecay={0.3}
162 enableZoomInteraction
163 enablePanInteraction
164 backgroundColor="transparent"
165 />
166 );
167}