import { useEffect, useRef } from 'react' import type { GraphNode, GraphEdge } from '@/types/api' // ─── Colour palette ─────────────────────────────────────────────────────────── export const NODE_COLOURS: Record = { Service: '#3b82f6', Library: '#22c55e', Incident: '#ef4444', Team: '#f97316', } // ─── Force-graph internal types ─────────────────────────────────────────────── interface FGNode { id: string label: GraphNode['label'] name: string color: string } interface FGLink { source: string target: string rel: string } // ─── Props ──────────────────────────────────────────────────────────────────── interface Props { nodes: GraphNode[] edges: GraphEdge[] streaming: boolean // true while SSE/WS is active (drives the empty state animation) onNodeClick: (node: GraphNode) => void onNodeHover: (node: GraphNode | null, x: number, y: number) => void className?: string } // ─── Component ─────────────────────────────────────────────────────────────── export function KnowledgeGraph({ nodes, edges, streaming, onNodeClick, onNodeHover, className }: Props) { const containerRef = useRef(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const graphRef = useRef(null) // Accumulates data that arrives before the async canvas is ready const pendingRef = useRef<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ nodes: [], edges: [] }) const pushData = (n: GraphNode[], e: GraphEdge[]) => { if (!graphRef.current) return graphRef.current.graphData({ nodes: n.map((node) => ({ id: node.id, label: node.label, name: node.name, color: NODE_COLOURS[node.label] ?? '#94a3b8', })), links: e.map((edge) => ({ source: edge.from, target: edge.to, rel: edge.rel } as FGLink)), }) } // Initialise the canvas once on mount useEffect(() => { if (!containerRef.current) return let ro: ResizeObserver | null = null // eslint-disable-next-line @typescript-eslint/no-explicit-any import('force-graph').then(({ default: ForceGraph2D }: any) => { if (!containerRef.current) return const el = containerRef.current // Track real mouse position since force-graph's onNodeHover doesn't provide it let mouseX = 0 let mouseY = 0 const trackMouse = (e: MouseEvent) => { mouseX = e.clientX; mouseY = e.clientY } el.addEventListener('mousemove', trackMouse) const fg = ForceGraph2D() fg(el) fg.backgroundColor('transparent') .nodeId('id') .nodeLabel('name') .nodeColor((n: FGNode) => n.color) .nodeRelSize(6) .width(el.clientWidth || 380) .height(el.clientHeight || 400) .linkColor(() => '#94a3b8') .linkLabel('rel') .linkDirectionalArrowLength(4) .linkDirectionalArrowRelPos(1) .onNodeClick((n: FGNode) => { onNodeClick({ id: n.id, label: n.label, name: n.name }) }) .onNodeHover((n: FGNode | null) => { onNodeHover(n ? { id: n.id, label: n.label, name: n.name } : null, mouseX, mouseY) }) graphRef.current = fg // Auto-resize the canvas when the container is resized (e.g. maximize/collapse) ro = new ResizeObserver((entries) => { const entry = entries[0] if (!entry) return const { width, height } = entry.contentRect if (width > 0 && height > 0) { fg.width(width).height(height) } }) ro.observe(el) // Flush any nodes/edges that arrived while the canvas was initialising pushData(pendingRef.current.nodes, pendingRef.current.edges) }) return () => { ro?.disconnect() graphRef.current?._destructor?.() graphRef.current = null // trackMouse listener is on el which is removed from DOM — GC handles it } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Push updated data whenever nodes or edges change useEffect(() => { pendingRef.current = { nodes, edges } pushData(nodes, edges) }, [nodes, edges]) const isEmpty = nodes.length === 0 return (
{/* Empty state overlay — removed once first node arrives */} {isEmpty && (
{streaming ? ( <>

Building knowledge graph…

) : (

No graph data

)}
)} {/* Canvas — always present so force-graph can attach */}
) }