Embeddable hybrid graph-vector database in OCaml
0

Configure Feed

Select the types of activity you want to include in your feed.

gvecdb-ocaml / demo / frontend / src / components / GraphVisualization.tsx
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}