Spaces:
Sleeping
Sleeping
File size: 5,935 Bytes
9dfccd9 c0b5012 9bb34f8 9dfccd9 9bb34f8 9dfccd9 c0b5012 017116e c0b5012 017116e 9dfccd9 9bb34f8 9dfccd9 e2766a5 9dfccd9 9bb34f8 68af3c5 57fa65c 9bb34f8 57fa65c 9dfccd9 9bb34f8 9dfccd9 57fa65c 68af3c5 9dfccd9 57fa65c 017116e 9bb34f8 e2766a5 9bb34f8 c0b5012 017116e 9dfccd9 9bb34f8 9dfccd9 68af3c5 9dfccd9 017116e 9dfccd9 c0b5012 9dfccd9 9bb34f8 c0b5012 9dfccd9 c0b5012 9dfccd9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | import { useEffect, useRef } from 'react'
import type { GraphNode, GraphEdge } from '@/types/api'
// βββ Colour palette βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export const NODE_COLOURS: Record<GraphNode['label'], string> = {
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<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const graphRef = useRef<any>(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 (
<div className={`relative h-full w-full overflow-hidden${className ? ` ${className}` : ''}`}>
{/* Empty state overlay β removed once first node arrives */}
{isEmpty && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none">
{streaming ? (
<>
<div className="flex gap-1.5">
<span className="h-2 w-2 animate-bounce rounded-full bg-stone-300 [animation-delay:-0.3s] dark:bg-stone-600" />
<span className="h-2 w-2 animate-bounce rounded-full bg-stone-300 [animation-delay:-0.15s] dark:bg-stone-600" />
<span className="h-2 w-2 animate-bounce rounded-full bg-stone-300 dark:bg-stone-600" />
</div>
<p className="text-xs text-stone-400">Building knowledge graphβ¦</p>
</>
) : (
<p className="text-sm text-stone-400">No graph data</p>
)}
</div>
)}
{/* Canvas β always present so force-graph can attach */}
<div
ref={containerRef}
className="h-full w-full"
aria-label="Knowledge graph visualisation"
/>
</div>
)
}
|