@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.
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/cli.mjs +20 -0
- package/index.html +13 -0
- package/package.json +67 -0
- package/src/App.tsx +153 -0
- package/src/application/trpc-client.ts +6 -0
- package/src/application/use-direction.ts +18 -0
- package/src/application/use-domains.ts +9 -0
- package/src/application/use-expansion.ts +42 -0
- package/src/application/use-filters.ts +78 -0
- package/src/application/use-graph.ts +38 -0
- package/src/application/use-reveal.ts +26 -0
- package/src/application/use-selection.ts +15 -0
- package/src/application/use-settings.ts +84 -0
- package/src/application/use-view-mode.ts +14 -0
- package/src/assets/fonts/Monor_Regular.otf +0 -0
- package/src/assets/fonts/Supreme-Variable.woff2 +0 -0
- package/src/assets/fonts/Supreme-VariableItalic.woff2 +0 -0
- package/src/assets/fonts/monor-bold.otf +0 -0
- package/src/assets/react.svg +1 -0
- package/src/cli.ts +29 -0
- package/src/components/direction-toggle.tsx +28 -0
- package/src/components/domain-header.tsx +44 -0
- package/src/components/export-dialog.tsx +164 -0
- package/src/components/filter-bar.tsx +17 -0
- package/src/components/header.tsx +37 -0
- package/src/components/inspector.tsx +183 -0
- package/src/components/kind-filter.tsx +70 -0
- package/src/components/node-badge.tsx +19 -0
- package/src/components/node-name.tsx +66 -0
- package/src/components/settings-menu.tsx +147 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/card.tsx +103 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/dialog.tsx +108 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/popover.tsx +88 -0
- package/src/components/ui/scroll-area.tsx +54 -0
- package/src/components/ui/select.tsx +88 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/view-switcher.tsx +28 -0
- package/src/components/views/graph-view.tsx +1203 -0
- package/src/components/views/list-view.tsx +109 -0
- package/src/components/views/tree-view.tsx +485 -0
- package/src/domain/cypher-export.ts +66 -0
- package/src/domain/direction.ts +1 -0
- package/src/domain/domain-grouping.ts +217 -0
- package/src/domain/edge.ts +37 -0
- package/src/domain/filter.ts +21 -0
- package/src/domain/flatten-tree.ts +167 -0
- package/src/domain/graph.ts +42 -0
- package/src/domain/node.ts +28 -0
- package/src/domain/roots.ts +18 -0
- package/src/domain/traversal.ts +60 -0
- package/src/index.css +205 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +16 -0
- package/src/server/router.ts +87 -0
- package/src/server/vite-plugin.ts +99 -0
- 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
|
+
}
|