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>
  )
}