@neat.is/web 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,607 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useCallback } from 'react'
4
+ import type { GraphNode, GraphEdge } from '@neat.is/types'
5
+ import type { GraphData } from './AppShell'
6
+
7
+ // Map NEAT node types to design visual types
8
+ function visualType(node: GraphNode): string {
9
+ if (node.type === 'ServiceNode') return 'service'
10
+ if (node.type === 'DatabaseNode') return 'db'
11
+ if (node.type === 'ConfigNode') return 'storage'
12
+ if (node.type === 'FrontierNode') return 'external'
13
+ if (node.type === 'InfraNode') {
14
+ const kind = (node as { kind?: string }).kind?.toLowerCase() ?? ''
15
+ if (kind === 'cluster') return 'cluster'
16
+ if (kind === 'namespace') return 'namespace'
17
+ if (kind === 'vpc' || kind === 'network') return 'vpc'
18
+ if (kind === 'storage' || kind === 's3' || kind === 'blob') return 'storage'
19
+ return 'compute'
20
+ }
21
+ return 'service'
22
+ }
23
+
24
+ // Map NEAT provenance to design display values
25
+ function visualProv(provenance: string): 'STATIC' | 'OBSERVED' | 'INFERRED' {
26
+ if (provenance === 'OBSERVED') return 'OBSERVED'
27
+ if (provenance === 'INFERRED') return 'INFERRED'
28
+ return 'STATIC' // EXTRACTED, STALE, FRONTIER
29
+ }
30
+
31
+ // Map NEAT edge type enum to lowercase display verb
32
+ function edgeVerb(type: string): string {
33
+ return type.toLowerCase().replace(/_/g, ' ')
34
+ }
35
+
36
+ const COMPOUND_TYPES = new Set(['cloud', 'env', 'vpc', 'cluster', 'namespace'])
37
+
38
+ interface GraphCanvasProps {
39
+ project: string
40
+ selectedNodeId: string | null
41
+ onNodeSelect: (id: string) => void
42
+ onGraphLoaded: (data: GraphData) => void
43
+ onCyReady?: (cy: unknown) => void
44
+ }
45
+
46
+ export function GraphCanvas({ project, selectedNodeId, onNodeSelect, onGraphLoaded, onCyReady }: GraphCanvasProps) {
47
+ const containerRef = useRef<HTMLDivElement>(null)
48
+ const minimapCanvasRef = useRef<HTMLCanvasElement>(null)
49
+ const minimapFrameRef = useRef<HTMLDivElement>(null)
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ const cyRef = useRef<any>(null)
52
+ const provFilterRef = useRef<Set<string>>(new Set())
53
+ const metaRef = useRef({ nodeCount: 0, edgeCount: 0 })
54
+ const sseRef = useRef<EventSource | null>(null)
55
+
56
+ const drawMinimap = useCallback(() => {
57
+ const cy = cyRef.current
58
+ const mmCanvas = minimapCanvasRef.current
59
+ const mmFrame = minimapFrameRef.current
60
+ if (!cy || !mmCanvas || !mmFrame) return
61
+
62
+ const dpr = window.devicePixelRatio || 1
63
+ const rect = mmCanvas.getBoundingClientRect()
64
+ if (rect.width === 0) return
65
+ mmCanvas.width = rect.width * dpr
66
+ mmCanvas.height = rect.height * dpr
67
+ const ctx = mmCanvas.getContext('2d')
68
+ if (!ctx) return
69
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
70
+ ctx.clearRect(0, 0, rect.width, rect.height)
71
+
72
+ const bb = cy.elements().boundingBox()
73
+ if (!isFinite(bb.x1)) return
74
+ const pad = 8
75
+ const sx = (rect.width - pad * 2) / (bb.w || 1)
76
+ const sy = (rect.height - pad * 2) / (bb.h || 1)
77
+ const s = Math.min(sx, sy)
78
+ const ox = pad - bb.x1 * s + (rect.width - pad * 2 - bb.w * s) / 2
79
+ const oy = pad - bb.y1 * s + (rect.height - pad * 2 - bb.h * s) / 2
80
+
81
+ ctx.lineWidth = 0.5
82
+ cy.edges().forEach((e: { source: () => { position: () => { x: number; y: number } }; target: () => { position: () => { x: number; y: number } }; data: (k: string) => string }) => {
83
+ const a = e.source().position()
84
+ const b = e.target().position()
85
+ ctx.strokeStyle = e.data('_color') + '55'
86
+ ctx.beginPath()
87
+ ctx.moveTo(a.x * s + ox, a.y * s + oy)
88
+ ctx.lineTo(b.x * s + ox, b.y * s + oy)
89
+ ctx.stroke()
90
+ })
91
+ cy.nodes().forEach((n: { data: (k: string) => string | boolean; position: () => { x: number; y: number } }) => {
92
+ if (n.data('_isCompound')) return
93
+ const p = n.position()
94
+ ctx.fillStyle = (n.data('_color') as string) || '#888'
95
+ ctx.beginPath()
96
+ ctx.arc(p.x * s + ox, p.y * s + oy, 1.4, 0, Math.PI * 2)
97
+ ctx.fill()
98
+ })
99
+
100
+ const ext = cy.extent()
101
+ const fx = ext.x1 * s + ox
102
+ const fy = ext.y1 * s + oy
103
+ const fw = (ext.x2 - ext.x1) * s
104
+ const fh = (ext.y2 - ext.y1) * s
105
+ mmFrame.style.left = Math.max(0, fx) + 'px'
106
+ mmFrame.style.top = Math.max(0, fy) + 'px'
107
+ mmFrame.style.width = Math.min(rect.width - Math.max(0, fx), fw) + 'px'
108
+ mmFrame.style.height = Math.min(rect.height - Math.max(0, fy), fh) + 'px'
109
+ }, [])
110
+
111
+ // Pan + select node when selectedNodeId is set from outside (search, URL, incidents link)
112
+ useEffect(() => {
113
+ const cy = cyRef.current
114
+ if (!cy || !selectedNodeId) return
115
+ const el = cy.getElementById(selectedNodeId)
116
+ if (el && el.length) {
117
+ cy.animate({ center: { eles: el }, zoom: 1.4 }, { duration: 300 })
118
+ cy.$(':selected').unselect()
119
+ el.select()
120
+ }
121
+ }, [selectedNodeId])
122
+
123
+ useEffect(() => {
124
+ let destroyed = false
125
+
126
+ async function init() {
127
+ // Dynamic import avoids SSR issues
128
+ const cytoscape = (await import('cytoscape')).default
129
+
130
+ // ADR-057 #5 — every API call carries the active project.
131
+ const res = await fetch(`/api/graph?project=${encodeURIComponent(project)}`).catch(() => null)
132
+ if (!res || !res.ok || destroyed) return
133
+ const data: GraphData = await res.json()
134
+ if (destroyed) return
135
+
136
+ onGraphLoaded(data)
137
+ metaRef.current = { nodeCount: data.nodes.length, edgeCount: data.edges.length }
138
+
139
+ const cssVar = (name: string) =>
140
+ getComputedStyle(document.documentElement).getPropertyValue(name).trim()
141
+
142
+ const TYPE_STYLE: Record<string, { color: string; shape: string; size?: number }> = {
143
+ service: { color: cssVar('--n-service'), shape: 'round-rectangle', size: 32 },
144
+ db: { color: cssVar('--n-db'), shape: 'barrel', size: 34 },
145
+ cache: { color: cssVar('--n-cache'), shape: 'barrel', size: 28 },
146
+ stream: { color: cssVar('--n-stream'), shape: 'cut-rectangle', size: 32 },
147
+ queue: { color: cssVar('--n-queue'), shape: 'cut-rectangle', size: 28 },
148
+ lambda: { color: cssVar('--n-lambda'), shape: 'diamond', size: 30 },
149
+ cron: { color: cssVar('--n-cron'), shape: 'tag', size: 26 },
150
+ api: { color: cssVar('--n-api'), shape: 'round-rectangle', size: 22 },
151
+ apigw: { color: cssVar('--n-apigw'), shape: 'round-rectangle', size: 36 },
152
+ compute: { color: cssVar('--n-compute'), shape: 'round-rectangle', size: 32 },
153
+ storage: { color: cssVar('--n-storage'), shape: 'round-tag', size: 28 },
154
+ external: { color: cssVar('--n-external'), shape: 'round-octagon', size: 30 },
155
+ search: { color: cssVar('--n-search'), shape: 'barrel', size: 28 },
156
+ cluster: { color: cssVar('--n-cluster'), shape: 'round-rectangle' },
157
+ namespace: { color: cssVar('--n-namespace'), shape: 'round-rectangle' },
158
+ vpc: { color: cssVar('--n-vpc'), shape: 'round-rectangle' },
159
+ env: { color: cssVar('--n-env'), shape: 'round-rectangle' },
160
+ cloud: { color: cssVar('--ink-3'), shape: 'round-rectangle' },
161
+ }
162
+
163
+ const provColor: Record<string, string> = {
164
+ STATIC: cssVar('--prov-static'),
165
+ OBSERVED: cssVar('--prov-observed'),
166
+ INFERRED: cssVar('--prov-inferred'),
167
+ }
168
+
169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
+ const elements: any[] = []
171
+ data.nodes.forEach((n: GraphNode) => {
172
+ const vt = visualType(n)
173
+ const ts = TYPE_STYLE[vt] ?? { color: '#888', shape: 'ellipse', size: 24 }
174
+ elements.push({
175
+ data: {
176
+ id: n.id,
177
+ label: (n as { name?: string }).name ?? n.id,
178
+ type: vt,
179
+ _color: ts.color,
180
+ _shape: ts.shape,
181
+ _size: ts.size ?? 28,
182
+ _isCompound: COMPOUND_TYPES.has(vt),
183
+ _nodeType: n.type,
184
+ _raw: n,
185
+ },
186
+ classes: `t-${vt} ${COMPOUND_TYPES.has(vt) ? 'compound' : 'leaf'}`,
187
+ })
188
+ })
189
+
190
+ data.edges.forEach((e: GraphEdge) => {
191
+ const vp = visualProv(e.provenance)
192
+ const color = provColor[vp] ?? '#888'
193
+ elements.push({
194
+ data: {
195
+ id: e.id,
196
+ source: e.source,
197
+ target: e.target,
198
+ type: edgeVerb(e.type),
199
+ provenance: vp,
200
+ confidence: e.confidence,
201
+ _color: color,
202
+ _width: vp === 'INFERRED' ? 1 : vp === 'OBSERVED' ? 1.4 : 1.2,
203
+ _style: vp === 'INFERRED' ? 'dotted' : vp === 'OBSERVED' ? 'dashed' : 'solid',
204
+ _opacity: vp === 'INFERRED' ? 0.55 : vp === 'OBSERVED' ? 0.85 : 0.75,
205
+ },
206
+ })
207
+ })
208
+
209
+ const cy = cytoscape({
210
+ container: containerRef.current,
211
+ elements,
212
+ minZoom: 0.001,
213
+ maxZoom: 50,
214
+ wheelSensitivity: 0.25,
215
+ autoungrabify: true,
216
+ autounselectify: false,
217
+ boxSelectionEnabled: false,
218
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
219
+ style: ([
220
+ {
221
+ selector: 'node.compound',
222
+ style: {
223
+ 'background-color': 'data(_color)',
224
+ 'background-opacity': 0.35,
225
+ 'border-width': 1,
226
+ 'border-color': '#2a2a30',
227
+ 'border-style': 'solid',
228
+ shape: 'round-rectangle',
229
+ 'corner-radius': '4',
230
+ label: 'data(label)',
231
+ 'text-valign': 'top',
232
+ 'text-halign': 'left',
233
+ 'text-margin-x': 8,
234
+ 'text-margin-y': 4,
235
+ 'font-family': 'JetBrains Mono, monospace',
236
+ 'font-size': 10.5,
237
+ color: '#9b968c',
238
+ padding: '24px',
239
+ 'text-transform': 'lowercase',
240
+ },
241
+ },
242
+ { selector: 'node.t-cloud', style: { 'background-opacity': 0.18, padding: '32px', 'font-size': 11.5, color: '#d8d3c9' } },
243
+ { selector: 'node.t-env', style: { 'background-opacity': 0.3, padding: '28px', 'font-size': 11, color: '#d8d3c9' } },
244
+ { selector: 'node.t-vpc', style: { 'background-opacity': 0.4, padding: '22px', 'font-size': 10.5 } },
245
+ { selector: 'node.t-cluster', style: { 'background-opacity': 0.55, padding: '20px' } },
246
+ { selector: 'node.t-namespace', style: { 'background-opacity': 0.65, padding: '16px' } },
247
+ {
248
+ selector: 'node.leaf',
249
+ style: {
250
+ 'background-color': 'data(_color)',
251
+ 'background-opacity': 0.92,
252
+ shape: 'data(_shape)',
253
+ width: 'data(_size)',
254
+ height: 'data(_size)',
255
+ label: 'data(label)',
256
+ 'text-valign': 'bottom',
257
+ 'text-halign': 'center',
258
+ 'text-margin-y': 5,
259
+ 'font-family': 'JetBrains Mono, monospace',
260
+ 'font-size': 9.5,
261
+ color: '#d8d3c9',
262
+ 'text-outline-width': 2,
263
+ 'text-outline-color': '#0a0a0b',
264
+ 'border-width': 1,
265
+ 'border-color': '#0a0a0b',
266
+ 'min-zoomed-font-size': 7,
267
+ },
268
+ },
269
+ { selector: 'node.t-api', style: { width: 8, height: 8, 'font-size': 8.5, color: '#9b968c' } },
270
+ { selector: 'node.t-cron', style: { width: 18, height: 14 } },
271
+ { selector: 'node.t-external', style: { 'background-opacity': 0.7, 'border-color': '#46443f', 'border-width': 1, 'border-style': 'dashed', color: '#9b968c' } },
272
+ { selector: 'node.t-lambda', style: { width: 22, height: 22 } },
273
+ { selector: 'node.t-queue', style: { width: 20, height: 20 } },
274
+ {
275
+ selector: 'node:selected',
276
+ style: {
277
+ 'border-color': cssVar('--accent'),
278
+ 'border-width': 2,
279
+ 'background-opacity': 1,
280
+ color: '#f4efe6',
281
+ 'font-weight': 600,
282
+ 'z-index': 999,
283
+ },
284
+ },
285
+ { selector: '.dim', style: { opacity: 0.18 } },
286
+ { selector: 'edge.dim', style: { opacity: 0.08 } },
287
+ { selector: 'node.hl, edge.hl', style: { opacity: 1 } },
288
+ { selector: 'edge.hl', style: { width: 'mapData(_width, 0, 2, 1.6, 2.4)', opacity: 1 } },
289
+ {
290
+ selector: 'edge',
291
+ style: {
292
+ 'curve-style': 'bezier',
293
+ 'control-point-step-size': 30,
294
+ 'line-color': 'data(_color)',
295
+ 'line-style': 'data(_style)',
296
+ width: 'data(_width)',
297
+ opacity: 'data(_opacity)',
298
+ 'target-arrow-shape': 'triangle-backcurve',
299
+ 'target-arrow-color': 'data(_color)',
300
+ 'arrow-scale': 0.85,
301
+ 'font-family': 'JetBrains Mono, monospace',
302
+ 'font-size': 8,
303
+ color: '#6a675f',
304
+ 'text-rotation': 'autorotate',
305
+ 'text-background-color': '#0a0a0b',
306
+ 'text-background-opacity': 1,
307
+ 'text-background-padding': 2,
308
+ },
309
+ },
310
+ { selector: 'edge[type]', style: { label: 'data(type)', 'min-zoomed-font-size': 11 } },
311
+ { selector: 'node.t-apigw', style: { shape: 'round-rectangle', width: 36, height: 22, 'font-size': 9 } },
312
+ ] as any),
313
+ layout: {
314
+ name: 'cose',
315
+ animate: false,
316
+ randomize: true,
317
+ idealEdgeLength: 90,
318
+ nodeRepulsion: 9000,
319
+ edgeElasticity: 80,
320
+ gravity: 0.4,
321
+ numIter: 2200,
322
+ nestingFactor: 1.4,
323
+ componentSpacing: 100,
324
+ padding: 30,
325
+ fit: true,
326
+ },
327
+ })
328
+
329
+ cyRef.current = cy
330
+ onCyReady?.(cy)
331
+
332
+ // Update legend counts
333
+ const counts: Record<string, number> = { STATIC: 0, OBSERVED: 0, INFERRED: 0 }
334
+ data.edges.forEach((e) => {
335
+ const vp = visualProv(e.provenance)
336
+ counts[vp] = (counts[vp] ?? 0) + 1
337
+ })
338
+ const ctStatic = document.getElementById('ct-static')
339
+ const ctObserved = document.getElementById('ct-observed')
340
+ const ctInferred = document.getElementById('ct-inferred')
341
+ if (ctStatic) ctStatic.textContent = String(counts.STATIC)
342
+ if (ctObserved) ctObserved.textContent = String(counts.OBSERVED)
343
+ if (ctInferred) ctInferred.textContent = String(counts.INFERRED)
344
+
345
+ // Update canvas tag
346
+ const metaEl = document.querySelector('.canvas-tag .meta')
347
+ if (metaEl) metaEl.textContent = `live · ${data.nodes.length} nodes · ${data.edges.length} edges · cose layout`
348
+
349
+ // Selection handler
350
+ function focusNode(id: string) {
351
+ cy.elements().removeClass('hl dim')
352
+ const n = cy.getElementById(id)
353
+ if (!n || n.length === 0) return
354
+ const neigh = n.neighborhood().add(n)
355
+ cy.elements().not(neigh).addClass('dim')
356
+ neigh.addClass('hl')
357
+ onNodeSelect(id)
358
+ }
359
+
360
+ cy.on('tap', 'node', (evt: { target: { id: () => string; select: () => void } }) => {
361
+ cy.$(':selected').unselect()
362
+ evt.target.select()
363
+ focusNode(evt.target.id())
364
+ })
365
+ cy.on('tap', (evt: { target: unknown }) => {
366
+ if (evt.target === cy) {
367
+ cy.elements().removeClass('hl dim')
368
+ cy.$(':selected').unselect()
369
+ }
370
+ })
371
+
372
+ // Initial layout + selection
373
+ cy.ready(() => {
374
+ setTimeout(() => {
375
+ cy.fit(undefined, 40)
376
+ if (cy.zoom() < 0.25) {
377
+ cy.zoom({ level: 0.45, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } })
378
+ }
379
+ // Select first service node if available
380
+ const first = cy.nodes('.leaf').first()
381
+ if (first && first.length) {
382
+ first.select()
383
+ focusNode(first.id())
384
+ }
385
+ drawMinimap()
386
+ }, 80)
387
+ })
388
+
389
+ // Trackpad pan + pinch-zoom
390
+ const cyEl = containerRef.current
391
+ if (cyEl) {
392
+ const wheelHandler = (e: WheelEvent) => {
393
+ if (e.ctrlKey) {
394
+ e.preventDefault()
395
+ e.stopPropagation()
396
+ const factor = Math.exp(-e.deltaY * 0.015)
397
+ const newZoom = Math.max(cy.minZoom(), Math.min(cy.maxZoom(), cy.zoom() * factor))
398
+ const rect = cyEl.getBoundingClientRect()
399
+ cy.zoom({ level: newZoom, renderedPosition: { x: e.clientX - rect.left, y: e.clientY - rect.top } })
400
+ } else {
401
+ e.preventDefault()
402
+ e.stopPropagation()
403
+ cy.panBy({ x: -e.deltaX, y: -e.deltaY })
404
+ }
405
+ }
406
+ cyEl.addEventListener('wheel', wheelHandler, { passive: false, capture: true })
407
+ }
408
+
409
+ // Minimap updates
410
+ cy.on('viewport zoom pan render', () => requestAnimationFrame(drawMinimap))
411
+ window.addEventListener('resize', () => requestAnimationFrame(drawMinimap))
412
+
413
+ // Legend provenance filter
414
+ document.querySelectorAll<HTMLElement>('.legend-row[data-prov]').forEach((row) => {
415
+ row.addEventListener('click', () => {
416
+ const p = row.dataset.prov!
417
+ if (provFilterRef.current.has(p)) {
418
+ provFilterRef.current.delete(p)
419
+ row.style.opacity = '1'
420
+ } else {
421
+ provFilterRef.current.add(p)
422
+ row.style.opacity = '0.4'
423
+ }
424
+ cy.edges().forEach((e: { data: (k: string) => string; style: (k: string, v: string) => void }) => {
425
+ e.style('display', provFilterRef.current.has(e.data('provenance')) ? 'none' : 'element')
426
+ })
427
+ })
428
+ })
429
+
430
+ // Zoom controls
431
+ const zIn = document.getElementById('z-in')
432
+ const zOut = document.getElementById('z-out')
433
+ const zFit = document.getElementById('z-fit')
434
+ if (zIn) zIn.onclick = () => cy.zoom({ level: cy.zoom() * 1.2, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } })
435
+ if (zOut) zOut.onclick = () => cy.zoom({ level: cy.zoom() / 1.2, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } })
436
+ if (zFit) zFit.onclick = () => cy.fit(undefined, 60)
437
+
438
+ // SSE live updates (graceful — pre-v0.2.8 will get unavailable error and stop)
439
+ const sse = new EventSource('/api/events')
440
+ sseRef.current = sse
441
+
442
+ function pushGraphUpdate() {
443
+ onGraphLoaded({
444
+ nodes: cy.nodes(':visible').map((n: { data: (k: string) => unknown }) => n.data('_raw') as GraphNode),
445
+ edges: cy.edges(':visible').map((e: { data: (k: string) => unknown }) => ({ id: e.data('id'), source: e.data('source'), target: e.data('target'), type: e.data('type'), provenance: e.data('provenance'), confidence: e.data('confidence') }) as GraphEdge),
446
+ })
447
+ }
448
+
449
+ sse.addEventListener('node-added', (e) => {
450
+ const { node } = JSON.parse(e.data) as { node: GraphNode }
451
+ const vt = visualType(node)
452
+ const ts = TYPE_STYLE[vt] ?? { color: '#888', shape: 'ellipse', size: 24 }
453
+ cy.add({
454
+ data: {
455
+ id: node.id,
456
+ label: (node as { name?: string }).name ?? node.id,
457
+ type: vt,
458
+ _color: ts.color,
459
+ _shape: ts.shape,
460
+ _size: ts.size ?? 28,
461
+ _isCompound: COMPOUND_TYPES.has(vt),
462
+ _raw: node,
463
+ },
464
+ classes: `t-${vt} ${COMPOUND_TYPES.has(vt) ? 'compound' : 'leaf'}`,
465
+ })
466
+ pushGraphUpdate()
467
+ })
468
+ sse.addEventListener('edge-added', (e) => {
469
+ const { edge } = JSON.parse(e.data) as { edge: GraphEdge }
470
+ const vp = visualProv(edge.provenance)
471
+ const color = provColor[vp] ?? '#888'
472
+ cy.add({
473
+ data: {
474
+ id: edge.id,
475
+ source: edge.source,
476
+ target: edge.target,
477
+ type: edgeVerb(edge.type),
478
+ provenance: vp,
479
+ _color: color,
480
+ _width: vp === 'INFERRED' ? 1 : vp === 'OBSERVED' ? 1.4 : 1.2,
481
+ _style: vp === 'INFERRED' ? 'dotted' : vp === 'OBSERVED' ? 'dashed' : 'solid',
482
+ _opacity: vp === 'INFERRED' ? 0.55 : vp === 'OBSERVED' ? 0.85 : 0.75,
483
+ },
484
+ })
485
+ pushGraphUpdate()
486
+ })
487
+ sse.addEventListener('node-removed', (e) => {
488
+ const { id } = JSON.parse(e.data) as { id: string }
489
+ cy.getElementById(id).remove()
490
+ pushGraphUpdate()
491
+ })
492
+ sse.addEventListener('edge-removed', (e) => {
493
+ const { id } = JSON.parse(e.data) as { id: string }
494
+ cy.getElementById(id).remove()
495
+ pushGraphUpdate()
496
+ })
497
+ sse.addEventListener('error', () => {
498
+ // pre-v0.2.8 or connection drop — ignore silently
499
+ })
500
+
501
+ window.__cy = cy
502
+ }
503
+
504
+ init().catch(console.error)
505
+
506
+ return () => {
507
+ destroyed = true
508
+ if (sseRef.current) {
509
+ sseRef.current.close()
510
+ sseRef.current = null
511
+ }
512
+ if (cyRef.current) {
513
+ cyRef.current.destroy()
514
+ cyRef.current = null
515
+ }
516
+ }
517
+ }, [project])
518
+
519
+ return (
520
+ <main className="canvas-wrap">
521
+ <div id="cy" ref={containerRef} aria-label="Service dependency graph" role="img" />
522
+
523
+ <div className="canvas-tag">
524
+ <span className="title">NEAT</span>
525
+ <span className="meta">loading…</span>
526
+ </div>
527
+
528
+ <div className="canvas-toolbar">
529
+ <button
530
+ className="on"
531
+ title="Toggle node dragging"
532
+ onClick={() => {
533
+ const cy = cyRef.current
534
+ if (cy) cy.autoungrabify(!cy.autoungrabify())
535
+ }}
536
+ >
537
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
538
+ <rect x="5" y="11" width="14" height="9" rx="1.5" /><path d="M8 11V8a4 4 0 0 1 8 0v3" />
539
+ </svg>
540
+ Locked
541
+ </button>
542
+ <span className="div" />
543
+ <button onClick={() => cyRef.current?.fit(undefined, 40)}>Fit</button>
544
+ <button onClick={() => cyRef.current?.center()}>Center</button>
545
+ <span className="div" />
546
+ <button onClick={() => cyRef.current?.layout({ name: 'cose', animate: true, randomize: false, idealEdgeLength: 90, nodeRepulsion: 9000, edgeElasticity: 80, gravity: 0.4, numIter: 1200 }).run()}>
547
+ Layout: <span className="mono">cose</span>
548
+ </button>
549
+ </div>
550
+
551
+ <div className="zoomctl">
552
+ <button id="z-in" title="Zoom in">+</button>
553
+ <button id="z-out" title="Zoom out">−</button>
554
+ <button id="z-fit" title="Fit">⌖</button>
555
+ </div>
556
+
557
+ <aside className="legend" id="legend">
558
+ <h4>Edge provenance</h4>
559
+ <div className="legend-row" data-prov="STATIC">
560
+ <span className="swatch" style={{ background: 'var(--prov-static)' }} />
561
+ <span className="name">Static</span>
562
+ <span className="ct mono" id="ct-static">—</span>
563
+ </div>
564
+ <div className="legend-row" data-prov="OBSERVED">
565
+ <span className="swatch dashed" />
566
+ <span className="name">Observed</span>
567
+ <span className="ct mono" id="ct-observed">—</span>
568
+ </div>
569
+ <div className="legend-row" data-prov="INFERRED">
570
+ <span className="swatch dotted" />
571
+ <span className="name">Inferred</span>
572
+ <span className="ct mono" id="ct-inferred">—</span>
573
+ </div>
574
+
575
+ <div className="legend-rule" />
576
+
577
+ <h4 style={{ marginTop: 0 }}>Node kind</h4>
578
+ <div className="nodes-grid">
579
+ {[
580
+ ['service', '--n-service'], ['db', '--n-db'], ['cache', '--n-cache'],
581
+ ['stream', '--n-stream'], ['lambda', '--n-lambda'], ['cron', '--n-cron'],
582
+ ['api', '--n-api'], ['compute', '--n-compute'], ['storage', '--n-storage'],
583
+ ['external', '--n-external'],
584
+ ].map(([label, v]) => (
585
+ <div key={label} className="nrow">
586
+ <span className="nsq" style={{ background: `var(${v})` }} />
587
+ {label}
588
+ </div>
589
+ ))}
590
+ </div>
591
+ </aside>
592
+
593
+ <div className="minimap" id="minimap">
594
+ <span className="minimap-label">overview</span>
595
+ <canvas id="minimap-canvas" ref={minimapCanvasRef} />
596
+ <div className="frame" id="minimap-frame" ref={minimapFrameRef} />
597
+ </div>
598
+ </main>
599
+ )
600
+ }
601
+
602
+ declare global {
603
+ interface Window {
604
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
605
+ __cy: any
606
+ }
607
+ }