@ddd-ts/event-tree-viewer 0.0.0-eventviz.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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -0
  3. package/dist/cli.mjs +20 -0
  4. package/index.html +13 -0
  5. package/package.json +67 -0
  6. package/src/App.tsx +153 -0
  7. package/src/application/trpc-client.ts +6 -0
  8. package/src/application/use-direction.ts +18 -0
  9. package/src/application/use-domains.ts +9 -0
  10. package/src/application/use-expansion.ts +42 -0
  11. package/src/application/use-filters.ts +78 -0
  12. package/src/application/use-graph.ts +38 -0
  13. package/src/application/use-reveal.ts +26 -0
  14. package/src/application/use-selection.ts +15 -0
  15. package/src/application/use-settings.ts +84 -0
  16. package/src/application/use-view-mode.ts +14 -0
  17. package/src/assets/fonts/Monor_Regular.otf +0 -0
  18. package/src/assets/fonts/Supreme-Variable.woff2 +0 -0
  19. package/src/assets/fonts/Supreme-VariableItalic.woff2 +0 -0
  20. package/src/assets/fonts/monor-bold.otf +0 -0
  21. package/src/assets/react.svg +1 -0
  22. package/src/cli.ts +29 -0
  23. package/src/components/direction-toggle.tsx +28 -0
  24. package/src/components/domain-header.tsx +44 -0
  25. package/src/components/export-dialog.tsx +164 -0
  26. package/src/components/filter-bar.tsx +17 -0
  27. package/src/components/header.tsx +37 -0
  28. package/src/components/inspector.tsx +183 -0
  29. package/src/components/kind-filter.tsx +70 -0
  30. package/src/components/node-badge.tsx +19 -0
  31. package/src/components/node-name.tsx +66 -0
  32. package/src/components/settings-menu.tsx +147 -0
  33. package/src/components/ui/badge.tsx +52 -0
  34. package/src/components/ui/button.tsx +56 -0
  35. package/src/components/ui/card.tsx +103 -0
  36. package/src/components/ui/checkbox.tsx +28 -0
  37. package/src/components/ui/dialog.tsx +108 -0
  38. package/src/components/ui/input.tsx +20 -0
  39. package/src/components/ui/popover.tsx +88 -0
  40. package/src/components/ui/scroll-area.tsx +54 -0
  41. package/src/components/ui/select.tsx +88 -0
  42. package/src/components/ui/separator.tsx +23 -0
  43. package/src/components/ui/toggle-group.tsx +89 -0
  44. package/src/components/ui/toggle.tsx +43 -0
  45. package/src/components/view-switcher.tsx +28 -0
  46. package/src/components/views/graph-view.tsx +1203 -0
  47. package/src/components/views/list-view.tsx +109 -0
  48. package/src/components/views/tree-view.tsx +485 -0
  49. package/src/domain/cypher-export.ts +66 -0
  50. package/src/domain/direction.ts +1 -0
  51. package/src/domain/domain-grouping.ts +217 -0
  52. package/src/domain/edge.ts +37 -0
  53. package/src/domain/filter.ts +21 -0
  54. package/src/domain/flatten-tree.ts +167 -0
  55. package/src/domain/graph.ts +42 -0
  56. package/src/domain/node.ts +28 -0
  57. package/src/domain/roots.ts +18 -0
  58. package/src/domain/traversal.ts +60 -0
  59. package/src/index.css +205 -0
  60. package/src/lib/utils.ts +6 -0
  61. package/src/main.tsx +16 -0
  62. package/src/server/router.ts +87 -0
  63. package/src/server/vite-plugin.ts +99 -0
  64. package/vite.config.ts +36 -0
@@ -0,0 +1,1203 @@
1
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import ELK from "elkjs/lib/elk.bundled.js"
3
+ import type { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk-api"
4
+ import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
5
+ import { Button } from "@/components/ui/button"
6
+ import { NodeBadge } from "@/components/node-badge"
7
+ import { DomainHeader } from "@/components/domain-header"
8
+ import { NodeName } from "@/components/node-name"
9
+ import { nodeId, type GraphIndex, type NodeId } from "@/domain/graph"
10
+ import type { Node } from "@/domain/node"
11
+ import { edgeKind, verbFor, type Edge } from "@/domain/edge"
12
+ import type { Direction } from "@/domain/direction"
13
+ import {
14
+ type NodeDomain,
15
+ domainPrefixFromLabel,
16
+ groupByDomain,
17
+ } from "@/domain/domain-grouping"
18
+ import { effectiveRoots } from "@/domain/roots"
19
+ import type { DomainMap } from "@/application/use-domains"
20
+ import type { ExpansionApi } from "@/application/use-expansion"
21
+ import type { Settings } from "@/application/use-settings"
22
+
23
+ const elk = new ELK()
24
+
25
+ const NODE_W = 220
26
+ const NODE_H = 44
27
+ const LABEL_W = 80
28
+ const LABEL_H = 14
29
+
30
+ interface GraphViewProps {
31
+ index: GraphIndex
32
+ visibleNodes: Node[]
33
+ domains: DomainMap
34
+ direction: Direction
35
+ settings: Settings
36
+ expansion: ExpansionApi
37
+ selectedId: NodeId | null
38
+ onSelect: (id: NodeId) => void
39
+ }
40
+
41
+ interface LaidOutNode {
42
+ id: NodeId
43
+ node: Node
44
+ x: number
45
+ y: number
46
+ width: number
47
+ height: number
48
+ }
49
+
50
+ interface LaidOutEdge {
51
+ id: string
52
+ fromId: NodeId
53
+ toId: NodeId
54
+ path: string
55
+ label: { text: string; x: number; y: number } | null
56
+ }
57
+
58
+ interface LaidOutGraph {
59
+ nodes: LaidOutNode[]
60
+ edges: LaidOutEdge[]
61
+ width: number
62
+ height: number
63
+ }
64
+
65
+ interface ComponentSection {
66
+ key: string
67
+ domain: NodeDomain
68
+ laid: LaidOutGraph
69
+ }
70
+
71
+ interface DomainPanel {
72
+ key: string
73
+ domain: NodeDomain
74
+ totals: { nodes: number; edges: number }
75
+ components: ComponentSection[]
76
+ }
77
+
78
+ export function GraphView({
79
+ index,
80
+ visibleNodes,
81
+ domains,
82
+ direction,
83
+ settings,
84
+ expansion,
85
+ selectedId,
86
+ onSelect,
87
+ }: GraphViewProps) {
88
+ const groups = useMemo(
89
+ () => buildComponentGroups(index, visibleNodes, domains, direction),
90
+ [index, visibleNodes, domains, direction]
91
+ )
92
+ const [sections, setSections] = useState<ComponentSection[] | null>(null)
93
+ const [status, setStatus] = useState<"idle" | "laying" | "error">("idle")
94
+ const [error, setError] = useState<string | null>(null)
95
+
96
+ useEffect(() => {
97
+ if (!groups.length) {
98
+ setSections([])
99
+ setStatus("idle")
100
+ return
101
+ }
102
+ setStatus("laying")
103
+ let cancelled = false
104
+ Promise.all(
105
+ groups.map(async (g) => ({
106
+ key: g.key,
107
+ domain: g.domain,
108
+ laid: await layoutGraph(g.nodes, g.edges, direction),
109
+ }))
110
+ )
111
+ .then((next) => {
112
+ if (cancelled) return
113
+ setSections(next)
114
+ setStatus("idle")
115
+ })
116
+ .catch((err: unknown) => {
117
+ if (cancelled) return
118
+ setError(err instanceof Error ? err.message : String(err))
119
+ setStatus("error")
120
+ })
121
+ return () => {
122
+ cancelled = true
123
+ }
124
+ }, [groups, direction])
125
+
126
+ if (!visibleNodes.length) {
127
+ return (
128
+ <p className="px-6 py-4 text-sm text-muted-foreground">
129
+ No matching nodes.
130
+ </p>
131
+ )
132
+ }
133
+
134
+ if (!groups.length) {
135
+ return (
136
+ <p className="px-6 py-4 text-sm text-muted-foreground">
137
+ No connected nodes — every matching node is isolated.
138
+ </p>
139
+ )
140
+ }
141
+
142
+ if (status === "error") {
143
+ return (
144
+ <p className="px-6 py-4 text-sm text-destructive">
145
+ Layout failed: {error}
146
+ </p>
147
+ )
148
+ }
149
+
150
+ if (!sections) {
151
+ return (
152
+ <p className="px-6 py-4 text-sm text-muted-foreground">
153
+ Computing layout…
154
+ </p>
155
+ )
156
+ }
157
+
158
+ return (
159
+ <SyncedHorizontalSectionsMemoed
160
+ sections={sections}
161
+ index={index}
162
+ expansion={expansion}
163
+ settings={settings}
164
+ selectedId={selectedId}
165
+ onSelect={onSelect}
166
+ />
167
+ )
168
+ }
169
+
170
+ function SyncedHorizontalSectionsMemoed({
171
+ sections,
172
+ ...rest
173
+ }: {
174
+ sections: ComponentSection[]
175
+ index: GraphIndex
176
+ expansion: ExpansionApi
177
+ settings: Settings
178
+ selectedId: NodeId | null
179
+ onSelect: (id: NodeId) => void
180
+ }) {
181
+ const panels = useMemo(() => groupByDomainPanel(sections), [sections])
182
+ return <SyncedHorizontalSections panels={panels} {...rest} />
183
+ }
184
+
185
+ function groupByDomainPanel(sections: ComponentSection[]): DomainPanel[] {
186
+ const panels = new Map<string, DomainPanel>()
187
+ for (const s of sections) {
188
+ const existing = panels.get(s.domain.key)
189
+ if (existing) {
190
+ existing.components.push(s)
191
+ existing.totals.nodes += s.laid.nodes.length
192
+ existing.totals.edges += s.laid.edges.length
193
+ } else {
194
+ panels.set(s.domain.key, {
195
+ key: s.domain.key,
196
+ domain: s.domain,
197
+ totals: { nodes: s.laid.nodes.length, edges: s.laid.edges.length },
198
+ components: [s],
199
+ })
200
+ }
201
+ }
202
+ return [...panels.values()].sort((a, b) =>
203
+ a.domain.label.localeCompare(b.domain.label)
204
+ )
205
+ }
206
+
207
+ const STICKY_HEADER_H = 36
208
+
209
+ function SyncedHorizontalSections({
210
+ panels,
211
+ index,
212
+ expansion,
213
+ settings,
214
+ selectedId,
215
+ onSelect,
216
+ }: {
217
+ panels: DomainPanel[]
218
+ index: GraphIndex
219
+ expansion: ExpansionApi
220
+ settings: Settings
221
+ selectedId: NodeId | null
222
+ onSelect: (id: NodeId) => void
223
+ }) {
224
+ const hostRef = useRef<HTMLDivElement>(null)
225
+ const transformRef = useRef<HTMLDivElement>(null)
226
+ const viewportRef = useRef<HTMLDivElement>(null)
227
+ const stickyHeaderRef = useRef<HTMLDivElement>(null)
228
+ const panelEls = useRef(new Map<string, HTMLDivElement>())
229
+ const panelOffsets = useRef<
230
+ { key: string; top: number; bottom: number }[]
231
+ >([])
232
+ const plotEls = useRef(new Map<string, { el: HTMLDivElement; width: number }>())
233
+ const hoveredIdRef = useRef<NodeId | null>(null)
234
+ const [contentSize, setContentSize] = useState<{ w: number; h: number }>({
235
+ w: 0,
236
+ h: 0,
237
+ })
238
+ const [activePanelKey, setActivePanelKey] = useState<string | null>(null)
239
+ const activePanelKeyRef = useRef<string | null>(null)
240
+ const viewportWRef = useRef(0)
241
+
242
+ const applyDimming = useCallback(() => {
243
+ const root = transformRef.current
244
+ if (!root) return
245
+ const focused = selectedId ?? hoveredIdRef.current
246
+ const related = focused ? computeRelatedIds(index, focused) : null
247
+ for (const el of root.querySelectorAll<HTMLElement>("[data-node-id]")) {
248
+ const id = el.getAttribute("data-node-id")!
249
+ const dimmed = related !== null && !related.has(id)
250
+ el.classList.toggle("opacity-25", dimmed)
251
+ const selected = id === selectedId
252
+ el.classList.toggle("ring-2", selected)
253
+ el.classList.toggle("ring-ring", selected)
254
+ el.setAttribute("aria-pressed", selected ? "true" : "false")
255
+ }
256
+ const sortedEdges = new Map<
257
+ Element,
258
+ { dimmed: SVGPathElement[]; normal: SVGPathElement[] }
259
+ >()
260
+ for (const el of root.querySelectorAll<SVGPathElement>("[data-edge]")) {
261
+ const from = el.getAttribute("data-from")!
262
+ const to = el.getAttribute("data-to")!
263
+ const dimmed =
264
+ related !== null && !(related.has(from) && related.has(to))
265
+ el.classList.toggle("stroke-graph-dim", dimmed)
266
+ el.classList.toggle("stroke-graph-edge", !dimmed)
267
+ const parent = el.parentElement
268
+ if (!parent) continue
269
+ let group = sortedEdges.get(parent)
270
+ if (!group) {
271
+ group = { dimmed: [], normal: [] }
272
+ sortedEdges.set(parent, group)
273
+ }
274
+ ;(dimmed ? group.dimmed : group.normal).push(el)
275
+ }
276
+ if (related !== null) {
277
+ for (const [parent, group] of sortedEdges) {
278
+ for (const el of group.dimmed) parent.appendChild(el)
279
+ for (const el of group.normal) parent.appendChild(el)
280
+ }
281
+ }
282
+ const sortedLabels = new Map<
283
+ Element,
284
+ { dimmed: HTMLDivElement[]; normal: HTMLDivElement[] }
285
+ >()
286
+ for (const el of root.querySelectorAll<HTMLDivElement>(
287
+ "[data-edge-label]"
288
+ )) {
289
+ const from = el.getAttribute("data-from")!
290
+ const to = el.getAttribute("data-to")!
291
+ const dimmed =
292
+ related !== null && !(related.has(from) && related.has(to))
293
+ el.classList.toggle("text-graph-dim", dimmed)
294
+ el.classList.toggle("text-muted-foreground", !dimmed)
295
+ const parent = el.parentElement
296
+ if (!parent) continue
297
+ let group = sortedLabels.get(parent)
298
+ if (!group) {
299
+ group = { dimmed: [], normal: [] }
300
+ sortedLabels.set(parent, group)
301
+ }
302
+ ;(dimmed ? group.dimmed : group.normal).push(el)
303
+ }
304
+ if (related !== null) {
305
+ for (const [parent, group] of sortedLabels) {
306
+ for (const el of group.dimmed) parent.appendChild(el)
307
+ for (const el of group.normal) parent.appendChild(el)
308
+ }
309
+ }
310
+ }, [index, selectedId])
311
+
312
+ const handleHover = useCallback(
313
+ (id: NodeId | null) => {
314
+ hoveredIdRef.current = id
315
+ applyDimming()
316
+ },
317
+ [applyDimming]
318
+ )
319
+
320
+ const registerPanelEl = useCallback(
321
+ (key: string, el: HTMLDivElement | null) => {
322
+ if (el) panelEls.current.set(key, el)
323
+ else panelEls.current.delete(key)
324
+ },
325
+ []
326
+ )
327
+
328
+ const registerPlotEl = useCallback(
329
+ (key: string, el: HTMLDivElement | null, width: number) => {
330
+ if (el) plotEls.current.set(key, { el, width })
331
+ else plotEls.current.delete(key)
332
+ },
333
+ []
334
+ )
335
+
336
+ const rebuildPanelOffsetsCache = useCallback(() => {
337
+ const offsets: { key: string; top: number; bottom: number }[] = []
338
+ for (const panel of panels) {
339
+ const el = panelEls.current.get(panel.key)
340
+ if (!el) continue
341
+ const top = el.offsetTop
342
+ offsets.push({ key: panel.key, top, bottom: top + el.offsetHeight })
343
+ }
344
+ panelOffsets.current = offsets
345
+ }, [panels])
346
+
347
+ const applyStickyTransform = useCallback(() => {
348
+ const v = viewportRef.current
349
+ if (!v) return
350
+ const ty = v.scrollTop
351
+ const offsets = panelOffsets.current
352
+ let overlayY = 0
353
+ for (let i = 0; i < offsets.length; i++) {
354
+ const o = offsets[i]
355
+ if (o.top <= ty && ty < o.bottom) {
356
+ const nextTop = offsets[i + 1]?.top ?? null
357
+ if (nextTop !== null) {
358
+ const nextTopInViewport = nextTop - ty
359
+ if (nextTopInViewport < STICKY_HEADER_H) {
360
+ overlayY = nextTopInViewport - STICKY_HEADER_H
361
+ }
362
+ }
363
+ break
364
+ }
365
+ }
366
+ const overlay = stickyHeaderRef.current
367
+ if (overlay) {
368
+ overlay.style.transform = `translate3d(0, ${overlayY}px, 0)`
369
+ }
370
+ }, [])
371
+
372
+ const updateSticky = useCallback(
373
+ (ty: number) => {
374
+ const offsets = panelOffsets.current
375
+ let activeKey: string | null = null
376
+ for (let i = 0; i < offsets.length; i++) {
377
+ const o = offsets[i]
378
+ if (o.top <= ty && ty < o.bottom) {
379
+ activeKey = o.key
380
+ break
381
+ }
382
+ }
383
+ if (activeKey !== activePanelKeyRef.current) {
384
+ activePanelKeyRef.current = activeKey
385
+ setActivePanelKey(activeKey)
386
+ // Transform will be re-applied by the keyed inner element's ref
387
+ // callback once React commits the new label.
388
+ } else {
389
+ applyStickyTransform()
390
+ }
391
+ },
392
+ [applyStickyTransform]
393
+ )
394
+
395
+ const applyPlotTransforms = useCallback((left: number) => {
396
+ const vw = viewportWRef.current
397
+ for (const { el, width } of plotEls.current.values()) {
398
+ const max = width > vw ? width - vw : 0
399
+ const clamped = left < max ? left : max
400
+ el.style.transform = `translate3d(${-clamped}px, 0, 0)`
401
+ }
402
+ }, [])
403
+
404
+ const applyTransform = useCallback(
405
+ (left: number, top: number) => {
406
+ const t = transformRef.current
407
+ if (t) t.style.transform = `translate3d(0, ${-top}px, 0)`
408
+ applyPlotTransforms(left)
409
+ updateSticky(top)
410
+ },
411
+ [applyPlotTransforms, updateSticky]
412
+ )
413
+
414
+ const handleViewportScroll = useCallback(
415
+ (e: React.UIEvent<HTMLDivElement>) => {
416
+ const v = e.currentTarget
417
+ applyTransform(v.scrollLeft, v.scrollTop)
418
+ },
419
+ [applyTransform]
420
+ )
421
+
422
+ const nodeToPanelKey = useMemo(() => {
423
+ const map = new Map<string, string>()
424
+ for (const panel of panels) {
425
+ for (const component of panel.components) {
426
+ for (const node of component.laid.nodes) {
427
+ if (!map.has(node.id)) map.set(node.id, panel.key)
428
+ }
429
+ }
430
+ }
431
+ return map
432
+ }, [panels])
433
+
434
+ useEffect(() => {
435
+ const el = transformRef.current
436
+ if (!el) return
437
+ const ro = new ResizeObserver(([entry]) => {
438
+ const r = entry.contentRect
439
+ setContentSize({ w: Math.ceil(r.width), h: Math.ceil(r.height) })
440
+ rebuildPanelOffsetsCache()
441
+ })
442
+ ro.observe(el)
443
+ return () => ro.disconnect()
444
+ }, [rebuildPanelOffsetsCache])
445
+
446
+ useEffect(() => {
447
+ const host = hostRef.current
448
+ if (!host) return
449
+ const ro = new ResizeObserver(([entry]) => {
450
+ viewportWRef.current = entry.contentRect.width
451
+ const v = viewportRef.current
452
+ if (v) applyPlotTransforms(v.scrollLeft)
453
+ })
454
+ ro.observe(host)
455
+ return () => ro.disconnect()
456
+ }, [applyPlotTransforms])
457
+
458
+ useEffect(() => {
459
+ const v = viewportRef.current
460
+ if (!v) return
461
+ applyTransform(v.scrollLeft, v.scrollTop)
462
+ }, [contentSize, applyTransform])
463
+
464
+ useEffect(() => {
465
+ const host = hostRef.current
466
+ if (!host) return
467
+ const onWheel = (e: WheelEvent) => {
468
+ const v = viewportRef.current
469
+ if (!v) return
470
+ const hMax = Math.max(0, v.scrollWidth - v.clientWidth)
471
+ const vMax = Math.max(0, v.scrollHeight - v.clientHeight)
472
+ const newLeft = Math.max(0, Math.min(hMax, v.scrollLeft + e.deltaX))
473
+ const newTop = Math.max(0, Math.min(vMax, v.scrollTop + e.deltaY))
474
+ const changed =
475
+ Math.abs(newLeft - v.scrollLeft) > 0.5 ||
476
+ Math.abs(newTop - v.scrollTop) > 0.5
477
+ if (!changed) return
478
+ e.preventDefault()
479
+ v.scrollLeft = newLeft
480
+ v.scrollTop = newTop
481
+ }
482
+ host.addEventListener("wheel", onWheel, { passive: false })
483
+ return () => host.removeEventListener("wheel", onWheel)
484
+ }, [])
485
+
486
+ useEffect(() => {
487
+ const host = hostRef.current
488
+ if (!host) return
489
+ let pan:
490
+ | {
491
+ startX: number
492
+ startY: number
493
+ startLeft: number
494
+ startTop: number
495
+ pointerId: number
496
+ moved: boolean
497
+ }
498
+ | null = null
499
+ const onPointerDown = (e: PointerEvent) => {
500
+ if (e.pointerType !== "touch") return
501
+ const v = viewportRef.current
502
+ if (!v) return
503
+ pan = {
504
+ startX: e.clientX,
505
+ startY: e.clientY,
506
+ startLeft: v.scrollLeft,
507
+ startTop: v.scrollTop,
508
+ pointerId: e.pointerId,
509
+ moved: false,
510
+ }
511
+ }
512
+ const onPointerMove = (e: PointerEvent) => {
513
+ if (!pan || e.pointerId !== pan.pointerId) return
514
+ const v = viewportRef.current
515
+ if (!v) return
516
+ const dx = pan.startX - e.clientX
517
+ const dy = pan.startY - e.clientY
518
+ if (!pan.moved && Math.hypot(dx, dy) < 5) return
519
+ pan.moved = true
520
+ e.preventDefault()
521
+ const hMax = Math.max(0, v.scrollWidth - v.clientWidth)
522
+ const vMax = Math.max(0, v.scrollHeight - v.clientHeight)
523
+ v.scrollLeft = Math.max(0, Math.min(hMax, pan.startLeft + dx))
524
+ v.scrollTop = Math.max(0, Math.min(vMax, pan.startTop + dy))
525
+ }
526
+ const endPan = (e: PointerEvent) => {
527
+ if (pan?.pointerId === e.pointerId) pan = null
528
+ }
529
+ host.addEventListener("pointerdown", onPointerDown)
530
+ host.addEventListener("pointermove", onPointerMove, { passive: false })
531
+ host.addEventListener("pointerup", endPan)
532
+ host.addEventListener("pointercancel", endPan)
533
+ return () => {
534
+ host.removeEventListener("pointerdown", onPointerDown)
535
+ host.removeEventListener("pointermove", onPointerMove)
536
+ host.removeEventListener("pointerup", endPan)
537
+ host.removeEventListener("pointercancel", endPan)
538
+ }
539
+ }, [])
540
+
541
+ useEffect(() => {
542
+ applyDimming()
543
+ }, [applyDimming, panels])
544
+
545
+ useEffect(() => {
546
+ if (!selectedId) return
547
+ const panelKey = nodeToPanelKey.get(selectedId)
548
+ if (!panelKey) return
549
+ if (!expansion.isExpanded(panelKey)) expansion.toggle(panelKey)
550
+ const raf = requestAnimationFrame(() => {
551
+ const host = hostRef.current
552
+ const transform = transformRef.current
553
+ const viewport = viewportRef.current
554
+ if (!host || !transform || !viewport) return
555
+ const escaped =
556
+ typeof CSS !== "undefined" && CSS.escape
557
+ ? CSS.escape(selectedId)
558
+ : selectedId.replace(/(["\\])/g, "\\$1")
559
+ const el = transform.querySelector<HTMLElement>(
560
+ `[data-node-id="${escaped}"]`
561
+ )
562
+ if (!el) return
563
+ const tRect = transform.getBoundingClientRect()
564
+ const eRect = el.getBoundingClientRect()
565
+ const naturalLeft = eRect.left - tRect.left
566
+ const naturalTop = eRect.top - tRect.top
567
+ const viewW = host.clientWidth
568
+ const viewH = host.clientHeight
569
+ const hMax = Math.max(0, viewport.scrollWidth - viewport.clientWidth)
570
+ const vMax = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
571
+ const targetLeft = Math.max(
572
+ 0,
573
+ Math.min(hMax, naturalLeft + eRect.width / 2 - viewW / 2)
574
+ )
575
+ const targetTop = Math.max(
576
+ 0,
577
+ Math.min(vMax, naturalTop + eRect.height / 2 - viewH / 2)
578
+ )
579
+ viewport.scrollLeft = targetLeft
580
+ viewport.scrollTop = targetTop
581
+ })
582
+ return () => cancelAnimationFrame(raf)
583
+ // Re-running only on selectedId change is intentional: rerunning on
584
+ // nodeToPanelKey / expansion changes would yank the viewport back to the
585
+ // selected node every time the user hovers or expands a panel.
586
+ // eslint-disable-next-line react-hooks/exhaustive-deps
587
+ }, [selectedId])
588
+
589
+ const activePanel =
590
+ activePanelKey != null
591
+ ? panels.find((p) => p.key === activePanelKey)
592
+ : null
593
+
594
+ return (
595
+ <div
596
+ ref={hostRef}
597
+ className="relative h-full min-h-0 flex-1 touch-none overflow-hidden"
598
+ >
599
+ <svg width="0" height="0" className="pointer-events-none absolute">
600
+ <defs>
601
+ <marker
602
+ id="graph-arrow"
603
+ viewBox="0 0 10 10"
604
+ refX="9"
605
+ refY="5"
606
+ markerWidth="6"
607
+ markerHeight="6"
608
+ orient="auto-start-reverse"
609
+ >
610
+ <path
611
+ d="M 0 0 L 10 5 L 0 10 z"
612
+ className="fill-muted-foreground"
613
+ />
614
+ </marker>
615
+ </defs>
616
+ </svg>
617
+ <div className="absolute inset-0 overflow-hidden">
618
+ <div
619
+ ref={transformRef}
620
+ className="absolute top-0 left-0 flex w-max flex-col will-change-transform"
621
+ >
622
+ {panels.map((panel) => (
623
+ <DomainPanelView
624
+ key={panel.key}
625
+ panel={panel}
626
+ expanded={expansion.isExpanded(panel.key)}
627
+ onToggle={() => expansion.toggle(panel.key)}
628
+ settings={settings}
629
+ onSelect={onSelect}
630
+ onHover={handleHover}
631
+ registerPanelEl={registerPanelEl}
632
+ registerPlotEl={registerPlotEl}
633
+ />
634
+ ))}
635
+ </div>
636
+ </div>
637
+ <div
638
+ ref={stickyHeaderRef}
639
+ className="pointer-events-none absolute top-0 right-0 left-0 z-10 will-change-transform"
640
+ style={{ visibility: activePanel ? "visible" : "hidden" }}
641
+ >
642
+ {activePanel && (
643
+ <div
644
+ key={activePanel.key}
645
+ ref={(el) => {
646
+ if (el) applyStickyTransform()
647
+ }}
648
+ className="pointer-events-auto bg-background px-6"
649
+ >
650
+ <DomainHeader
651
+ label={activePanel.domain.label}
652
+ expanded={expansion.isExpanded(activePanel.key)}
653
+ onToggle={() => expansion.toggle(activePanel.key)}
654
+ meta={panelMeta(activePanel)}
655
+ />
656
+ </div>
657
+ )}
658
+ </div>
659
+ <div className="pointer-events-none absolute inset-0 z-20">
660
+ <ScrollAreaPrimitive.Root className="pointer-events-none size-full">
661
+ <ScrollAreaPrimitive.Viewport
662
+ ref={viewportRef}
663
+ onScroll={handleViewportScroll}
664
+ className="pointer-events-none size-full"
665
+ >
666
+ <div
667
+ style={{
668
+ width: contentSize.w,
669
+ height: contentSize.h,
670
+ pointerEvents: "none",
671
+ }}
672
+ />
673
+ </ScrollAreaPrimitive.Viewport>
674
+ <ScrollAreaPrimitive.Scrollbar
675
+ orientation="horizontal"
676
+ className="pointer-events-auto flex h-3 touch-none border-t border-t-transparent bg-muted/40 p-px transition-colors select-none"
677
+ >
678
+ <ScrollAreaPrimitive.Thumb className="h-full rounded-full bg-border hover:bg-muted-foreground/60" />
679
+ </ScrollAreaPrimitive.Scrollbar>
680
+ <ScrollAreaPrimitive.Scrollbar
681
+ orientation="vertical"
682
+ className="pointer-events-auto flex w-3 touch-none border-l border-l-transparent bg-muted/40 p-px transition-colors select-none"
683
+ >
684
+ <ScrollAreaPrimitive.Thumb className="w-full rounded-full bg-border hover:bg-muted-foreground/60" />
685
+ </ScrollAreaPrimitive.Scrollbar>
686
+ <ScrollAreaPrimitive.Corner className="bg-muted/40" />
687
+ </ScrollAreaPrimitive.Root>
688
+ </div>
689
+ </div>
690
+ )
691
+ }
692
+
693
+ function panelMeta(panel: DomainPanel): string {
694
+ const { totals, components } = panel
695
+ return `${totals.nodes} node${totals.nodes === 1 ? "" : "s"} · ${totals.edges} edge${totals.edges === 1 ? "" : "s"}${
696
+ components.length > 1 ? ` · ${components.length} graphs` : ""
697
+ }`
698
+ }
699
+
700
+ const DomainPanelView = memo(function DomainPanelView({
701
+ panel,
702
+ expanded,
703
+ onToggle,
704
+ settings,
705
+ onSelect,
706
+ onHover,
707
+ registerPanelEl,
708
+ registerPlotEl,
709
+ }: {
710
+ panel: DomainPanel
711
+ expanded: boolean
712
+ onToggle: () => void
713
+ settings: Settings
714
+ onSelect: (id: NodeId) => void
715
+ onHover: (id: NodeId | null) => void
716
+ registerPanelEl: (key: string, el: HTMLDivElement | null) => void
717
+ registerPlotEl: (
718
+ key: string,
719
+ el: HTMLDivElement | null,
720
+ width: number
721
+ ) => void
722
+ }) {
723
+ const { key, domain, components } = panel
724
+ const domainPrefix = domainPrefixFromLabel(domain.label)
725
+ const meta = panelMeta(panel)
726
+ const setRef = useCallback(
727
+ (el: HTMLDivElement | null) => registerPanelEl(key, el),
728
+ [registerPanelEl, key]
729
+ )
730
+ return (
731
+ <section ref={setRef} className="flex flex-col">
732
+ <div className="bg-background px-6">
733
+ <DomainHeader
734
+ label={domain.label}
735
+ expanded={expanded}
736
+ onToggle={onToggle}
737
+ meta={meta}
738
+ />
739
+ </div>
740
+ {expanded && (
741
+ <div className="flex flex-col gap-4 pt-2 pb-4">
742
+ {components.map((component) => (
743
+ <ComponentPlot
744
+ key={component.key}
745
+ section={component}
746
+ settings={settings}
747
+ domainPrefix={domainPrefix}
748
+ onSelect={onSelect}
749
+ onHover={onHover}
750
+ registerPlotEl={registerPlotEl}
751
+ />
752
+ ))}
753
+ </div>
754
+ )}
755
+ </section>
756
+ )
757
+ })
758
+
759
+ const ComponentPlot = memo(function ComponentPlot({
760
+ section,
761
+ settings,
762
+ domainPrefix,
763
+ onSelect,
764
+ onHover,
765
+ registerPlotEl,
766
+ }: {
767
+ section: ComponentSection
768
+ settings: Settings
769
+ domainPrefix: string
770
+ onSelect: (id: NodeId) => void
771
+ onHover: (id: NodeId | null) => void
772
+ registerPlotEl: (
773
+ key: string,
774
+ el: HTMLDivElement | null,
775
+ width: number
776
+ ) => void
777
+ }) {
778
+ const { key, laid } = section
779
+ const contentWidth = laid.width + 48
780
+ const setRef = useCallback(
781
+ (el: HTMLDivElement | null) => registerPlotEl(key, el, contentWidth),
782
+ [registerPlotEl, key, contentWidth]
783
+ )
784
+ return (
785
+ <div
786
+ ref={setRef}
787
+ className="px-6 will-change-transform"
788
+ style={{
789
+ contentVisibility: "auto",
790
+ containIntrinsicSize: `${contentWidth}px ${laid.height}px`,
791
+ }}
792
+ >
793
+ <div
794
+ className="relative"
795
+ style={{ width: laid.width, height: laid.height }}
796
+ >
797
+ <svg
798
+ width={laid.width}
799
+ height={laid.height}
800
+ className="pointer-events-none absolute inset-0"
801
+ >
802
+ {laid.edges.map((edge) => (
803
+ <path
804
+ key={edge.id}
805
+ data-edge
806
+ data-from={edge.fromId}
807
+ data-to={edge.toId}
808
+ d={edge.path}
809
+ fill="none"
810
+ className="stroke-graph-edge transition-colors"
811
+ strokeWidth={1.25}
812
+ markerEnd="url(#graph-arrow)"
813
+ />
814
+ ))}
815
+ </svg>
816
+ {laid.edges.map((edge) =>
817
+ edge.label ? (
818
+ <div
819
+ key={`${edge.id}-label`}
820
+ data-edge-label
821
+ data-from={edge.fromId}
822
+ data-to={edge.toId}
823
+ className="pointer-events-none absolute rounded bg-background px-1 font-mono text-[10px] tracking-wide text-muted-foreground uppercase transition-colors"
824
+ style={{
825
+ left: edge.label.x,
826
+ top: edge.label.y,
827
+ width: LABEL_W,
828
+ height: LABEL_H,
829
+ lineHeight: `${LABEL_H}px`,
830
+ textAlign: "center",
831
+ }}
832
+ >
833
+ {edge.label.text}
834
+ </div>
835
+ ) : null
836
+ )}
837
+ {laid.nodes.map((n) => (
838
+ <NodeBox
839
+ key={n.id}
840
+ node={n}
841
+ hideDomainPrefix={settings.hideDomainPrefix}
842
+ domainPrefix={domainPrefix}
843
+ onSelect={onSelect}
844
+ onHover={onHover}
845
+ />
846
+ ))}
847
+ </div>
848
+ </div>
849
+ )
850
+ })
851
+
852
+ const NodeBox = memo(function NodeBox({
853
+ node,
854
+ hideDomainPrefix,
855
+ domainPrefix,
856
+ onSelect,
857
+ onHover,
858
+ }: {
859
+ node: LaidOutNode
860
+ hideDomainPrefix: boolean
861
+ domainPrefix: string
862
+ onSelect: (id: NodeId) => void
863
+ onHover: (id: NodeId | null) => void
864
+ }) {
865
+ return (
866
+ <Button
867
+ variant="outline"
868
+ size="sm"
869
+ onClick={() => onSelect(node.id)}
870
+ onMouseEnter={() => onHover(node.id)}
871
+ onMouseLeave={() => onHover(null)}
872
+ aria-pressed="false"
873
+ data-node-id={node.id}
874
+ className="group/nodebox absolute justify-start gap-2 overflow-hidden bg-background px-3 py-2 text-sm font-normal transition-opacity hover:z-10 hover:overflow-visible"
875
+ style={{
876
+ left: node.x,
877
+ top: node.y,
878
+ width: node.width,
879
+ height: node.height,
880
+ }}
881
+ >
882
+ <NodeBadge kind={node.node.type} />
883
+ <span className="truncate group-hover/nodebox:overflow-visible">
884
+ <NodeName
885
+ name={node.node.name}
886
+ kind={node.node.type}
887
+ domainPrefix={domainPrefix}
888
+ hide={hideDomainPrefix}
889
+ />
890
+ </span>
891
+ </Button>
892
+ )
893
+ })
894
+
895
+ async function layoutGraph(
896
+ nodes: Node[],
897
+ edges: Edge[],
898
+ direction: Direction
899
+ ): Promise<LaidOutGraph> {
900
+ const elkGraph: ElkNode = {
901
+ id: "root",
902
+ layoutOptions: {
903
+ "elk.algorithm": "layered",
904
+ "elk.direction": direction === "forward" ? "RIGHT" : "LEFT",
905
+ "elk.layered.spacing.nodeNodeBetweenLayers": "90",
906
+ "elk.spacing.nodeNode": "32",
907
+ "elk.spacing.edgeNode": "24",
908
+ "elk.spacing.edgeEdge": "14",
909
+ "elk.spacing.componentComponent": "120",
910
+ "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
911
+ "elk.layered.crossingMinimization.semiInteractive": "true",
912
+ "elk.edgeRouting": "ORTHOGONAL",
913
+ "elk.separateConnectedComponents": "true",
914
+ },
915
+ children: nodes.map((n) => {
916
+ const inPos = portPosition("in", direction)
917
+ const outPos = portPosition("out", direction)
918
+ return {
919
+ id: nodeId(n.type, n.name),
920
+ width: NODE_W,
921
+ height: NODE_H,
922
+ layoutOptions: {
923
+ "elk.portConstraints": "FIXED_POS",
924
+ },
925
+ ports: [
926
+ {
927
+ id: portIdFor(n, "in"),
928
+ x: inPos.x,
929
+ y: inPos.y,
930
+ width: 0,
931
+ height: 0,
932
+ layoutOptions: { "port.side": portSide("in", direction) },
933
+ },
934
+ {
935
+ id: portIdFor(n, "out"),
936
+ x: outPos.x,
937
+ y: outPos.y,
938
+ width: 0,
939
+ height: 0,
940
+ layoutOptions: { "port.side": portSide("out", direction) },
941
+ },
942
+ ],
943
+ }
944
+ }),
945
+ edges: edges.map<ElkExtendedEdge>((e, i) => ({
946
+ id: `e${i}`,
947
+ sources: [portIdFor(e.from, "out")],
948
+ targets: [portIdFor(e.to, "in")],
949
+ labels: [
950
+ {
951
+ text: edgeLabel(e, direction),
952
+ width: LABEL_W,
953
+ height: LABEL_H,
954
+ },
955
+ ],
956
+ })),
957
+ }
958
+
959
+ const edgeRefs = new Map(
960
+ edges.map((e, i) => [
961
+ `e${i}`,
962
+ {
963
+ fromId: nodeId(e.from.type, e.from.name),
964
+ toId: nodeId(e.to.type, e.to.name),
965
+ },
966
+ ])
967
+ )
968
+
969
+ const result = await elk.layout(elkGraph)
970
+
971
+ const laidNodes: LaidOutNode[] = (result.children ?? []).flatMap((child) => {
972
+ const id = child.id as NodeId
973
+ const source = nodes.find((n) => nodeId(n.type, n.name) === id)
974
+ if (!source) return []
975
+ return [
976
+ {
977
+ id,
978
+ node: source,
979
+ x: child.x ?? 0,
980
+ y: child.y ?? 0,
981
+ width: child.width ?? NODE_W,
982
+ height: child.height ?? NODE_H,
983
+ },
984
+ ]
985
+ })
986
+
987
+ const laidEdges: LaidOutEdge[] = (result.edges ?? []).flatMap((edge) => {
988
+ const section = edge.sections?.[0]
989
+ if (!section) return []
990
+ const ref = edgeRefs.get(edge.id)
991
+ if (!ref) return []
992
+ const points = [
993
+ section.startPoint,
994
+ ...(section.bendPoints ?? []),
995
+ section.endPoint,
996
+ ]
997
+ const path = points
998
+ .map((p, idx) => `${idx === 0 ? "M" : "L"} ${p.x} ${p.y}`)
999
+ .join(" ")
1000
+ const labelShape = edge.labels?.[0]
1001
+ const label = labelShape?.text
1002
+ ? {
1003
+ text: labelShape.text,
1004
+ x: labelShape.x ?? 0,
1005
+ y: labelShape.y ?? 0,
1006
+ }
1007
+ : null
1008
+ return [
1009
+ {
1010
+ id: edge.id,
1011
+ fromId: ref.fromId,
1012
+ toId: ref.toId,
1013
+ path,
1014
+ label,
1015
+ },
1016
+ ]
1017
+ })
1018
+
1019
+ return {
1020
+ nodes: laidNodes,
1021
+ edges: laidEdges,
1022
+ width: result.width ?? 0,
1023
+ height: result.height ?? 0,
1024
+ }
1025
+ }
1026
+
1027
+ function edgeLabel(edge: Edge, direction: Direction): string {
1028
+ return verbFor(direction, edgeKind(edge))
1029
+ }
1030
+
1031
+ function portIdFor(
1032
+ ref: { type: string; name: string },
1033
+ side: "in" | "out"
1034
+ ): string {
1035
+ return `${ref.type}:${ref.name}:${side}`
1036
+ }
1037
+
1038
+ function portSide(side: "in" | "out", direction: Direction): string {
1039
+ const forwardIn = direction === "forward" ? "WEST" : "EAST"
1040
+ const forwardOut = direction === "forward" ? "EAST" : "WEST"
1041
+ return side === "in" ? forwardIn : forwardOut
1042
+ }
1043
+
1044
+ function portPosition(
1045
+ side: "in" | "out",
1046
+ direction: Direction
1047
+ ): { x: number; y: number } {
1048
+ const y = NODE_H / 2
1049
+ const onWest = portSide(side, direction) === "WEST"
1050
+ return { x: onWest ? 0 : NODE_W, y }
1051
+ }
1052
+
1053
+ interface ComponentGroupInput {
1054
+ key: string
1055
+ domain: NodeDomain
1056
+ nodes: Node[]
1057
+ edges: Edge[]
1058
+ }
1059
+
1060
+ function buildComponentGroups(
1061
+ index: GraphIndex,
1062
+ visibleNodes: Node[],
1063
+ domains: DomainMap,
1064
+ direction: Direction
1065
+ ): ComponentGroupInput[] {
1066
+ const visibleIds = new Set(
1067
+ visibleNodes.map((n) => nodeId(n.type, n.name) as string)
1068
+ )
1069
+ const visibleRoots = effectiveRoots(index, direction).filter((id) =>
1070
+ visibleIds.has(id)
1071
+ )
1072
+ const domainGroups = groupByDomain(visibleRoots, domains)
1073
+ const groups: ComponentGroupInput[] = []
1074
+ for (const { domain, rootIds } of domainGroups) {
1075
+ const reached = reachableFrom(index, rootIds, direction, visibleIds)
1076
+ if (reached.size === 0) continue
1077
+ const subgraphNodes = visibleNodes.filter((n) =>
1078
+ reached.has(nodeId(n.type, n.name))
1079
+ )
1080
+ const subgraphEdges = index.graph.edges.filter(
1081
+ (e) =>
1082
+ reached.has(nodeId(e.from.type, e.from.name)) &&
1083
+ reached.has(nodeId(e.to.type, e.to.name))
1084
+ )
1085
+ const components = splitConnectedComponents(subgraphNodes, subgraphEdges)
1086
+ components.forEach((comp, idx) => {
1087
+ groups.push({
1088
+ key: `${domain.key}::${componentSignature(comp.nodes, idx)}`,
1089
+ domain,
1090
+ nodes: comp.nodes,
1091
+ edges: comp.edges,
1092
+ })
1093
+ })
1094
+ }
1095
+ return groups
1096
+ }
1097
+
1098
+ function computeRelatedIds(
1099
+ index: GraphIndex,
1100
+ selectedId: NodeId | null
1101
+ ): Set<string> | null {
1102
+ if (!selectedId) return null
1103
+ const related = new Set<string>([selectedId])
1104
+ const upQueue: string[] = [selectedId]
1105
+ while (upQueue.length) {
1106
+ const id = upQueue.shift()!
1107
+ for (const e of index.incoming.get(id as NodeId) ?? []) {
1108
+ const parentId = nodeId(e.from.type, e.from.name) as string
1109
+ if (!related.has(parentId)) {
1110
+ related.add(parentId)
1111
+ upQueue.push(parentId)
1112
+ }
1113
+ }
1114
+ }
1115
+ const downQueue: string[] = [selectedId]
1116
+ while (downQueue.length) {
1117
+ const id = downQueue.shift()!
1118
+ for (const e of index.outgoing.get(id as NodeId) ?? []) {
1119
+ const childId = nodeId(e.to.type, e.to.name) as string
1120
+ if (!related.has(childId)) {
1121
+ related.add(childId)
1122
+ downQueue.push(childId)
1123
+ }
1124
+ }
1125
+ }
1126
+ return related
1127
+ }
1128
+
1129
+ function reachableFrom(
1130
+ index: GraphIndex,
1131
+ rootIds: NodeId[],
1132
+ direction: Direction,
1133
+ visibleIds: ReadonlySet<string>
1134
+ ): Set<string> {
1135
+ const reached = new Set<string>()
1136
+ const queue: string[] = rootIds.filter((id) => visibleIds.has(id))
1137
+ while (queue.length) {
1138
+ const id = queue.shift()!
1139
+ if (reached.has(id)) continue
1140
+ reached.add(id)
1141
+ const edges =
1142
+ direction === "forward"
1143
+ ? (index.outgoing.get(id as NodeId) ?? [])
1144
+ : (index.incoming.get(id as NodeId) ?? [])
1145
+ for (const edge of edges) {
1146
+ const peer = direction === "forward" ? edge.to : edge.from
1147
+ const peerId = nodeId(peer.type, peer.name) as string
1148
+ if (!visibleIds.has(peerId)) continue
1149
+ if (!reached.has(peerId)) queue.push(peerId)
1150
+ }
1151
+ }
1152
+ return reached
1153
+ }
1154
+
1155
+ function splitConnectedComponents(
1156
+ nodes: Node[],
1157
+ edges: Edge[]
1158
+ ): { nodes: Node[]; edges: Edge[] }[] {
1159
+ const adj = new Map<string, Set<string>>()
1160
+ for (const e of edges) {
1161
+ const a = nodeId(e.from.type, e.from.name)
1162
+ const b = nodeId(e.to.type, e.to.name)
1163
+ if (!adj.has(a)) adj.set(a, new Set())
1164
+ if (!adj.has(b)) adj.set(b, new Set())
1165
+ adj.get(a)!.add(b)
1166
+ adj.get(b)!.add(a)
1167
+ }
1168
+ const visited = new Set<string>()
1169
+ const components: { nodes: Node[]; edges: Edge[] }[] = []
1170
+ for (const root of nodes) {
1171
+ const rootId = nodeId(root.type, root.name) as string
1172
+ if (visited.has(rootId)) continue
1173
+ const ids = new Set<string>()
1174
+ const queue: string[] = [rootId]
1175
+ while (queue.length) {
1176
+ const id = queue.shift()!
1177
+ if (visited.has(id)) continue
1178
+ visited.add(id)
1179
+ ids.add(id)
1180
+ for (const nb of adj.get(id) ?? []) {
1181
+ if (!visited.has(nb)) queue.push(nb)
1182
+ }
1183
+ }
1184
+ const compNodes = nodes.filter((n) => ids.has(nodeId(n.type, n.name)))
1185
+ const compEdges = edges.filter(
1186
+ (e) =>
1187
+ ids.has(nodeId(e.from.type, e.from.name)) &&
1188
+ ids.has(nodeId(e.to.type, e.to.name))
1189
+ )
1190
+ if (compNodes.length > 0) components.push({ nodes: compNodes, edges: compEdges })
1191
+ }
1192
+ return components
1193
+ }
1194
+
1195
+ function componentSignature(nodes: Node[], fallbackIndex: number): string {
1196
+ if (!nodes.length) return `c${fallbackIndex}`
1197
+ const sorted = nodes
1198
+ .map((n) => nodeId(n.type, n.name))
1199
+ .sort()
1200
+ .slice(0, 3)
1201
+ .join(",")
1202
+ return sorted
1203
+ }