@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,109 @@
1
+ import { useEffect, useMemo, useRef } from "react"
2
+ import { ScrollArea } from "@/components/ui/scroll-area"
3
+ import { Button } from "@/components/ui/button"
4
+ import { NodeBadge } from "@/components/node-badge"
5
+ import { NODE_KINDS, type Node, type NodeKind } from "@/domain/node"
6
+ import { nodeId, type NodeId } from "@/domain/graph"
7
+
8
+ interface ListViewProps {
9
+ nodes: Node[]
10
+ selectedId: NodeId | null
11
+ onSelect: (id: NodeId) => void
12
+ }
13
+
14
+ export function ListView({ nodes, selectedId, onSelect }: ListViewProps) {
15
+ const grouped = useMemo(() => groupByKind(nodes), [nodes])
16
+ const rootRef = useRef<HTMLDivElement>(null)
17
+ useEffect(() => {
18
+ if (!selectedId) return
19
+ const root = rootRef.current
20
+ if (!root) return
21
+ const el = root.querySelector<HTMLElement>(
22
+ `[data-node-id="${cssEscape(selectedId)}"]`
23
+ )
24
+ el?.scrollIntoView({ block: "center", behavior: "auto" })
25
+ }, [selectedId, nodes])
26
+
27
+ return (
28
+ <ScrollArea ref={rootRef} className="h-full">
29
+ <div className="flex flex-col gap-4 px-6 py-4">
30
+ {NODE_KINDS.map((kind) => {
31
+ const items = grouped.get(kind) ?? []
32
+ if (!items.length) return null
33
+ return (
34
+ <section key={kind} className="flex flex-col gap-2">
35
+ <h2 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
36
+ {kind} ({items.length})
37
+ </h2>
38
+ <ul className="flex flex-col gap-1">
39
+ {items.map((node) => (
40
+ <NodeRow
41
+ key={`${node.type}:${node.name}`}
42
+ node={node}
43
+ selected={selectedId === nodeId(node.type, node.name)}
44
+ onSelect={onSelect}
45
+ />
46
+ ))}
47
+ </ul>
48
+ </section>
49
+ )
50
+ })}
51
+ {!nodes.length && (
52
+ <p className="text-sm text-muted-foreground">No matching nodes.</p>
53
+ )}
54
+ </div>
55
+ </ScrollArea>
56
+ )
57
+ }
58
+
59
+ function NodeRow({
60
+ node,
61
+ selected,
62
+ onSelect,
63
+ }: {
64
+ node: Node
65
+ selected: boolean
66
+ onSelect: (id: NodeId) => void
67
+ }) {
68
+ const id = nodeId(node.type, node.name)
69
+ return (
70
+ <li>
71
+ <Button
72
+ variant="ghost"
73
+ size="sm"
74
+ onClick={() => onSelect(id)}
75
+ aria-pressed={selected}
76
+ data-node-id={id}
77
+ className={`h-auto w-full justify-start gap-3 px-3 py-2 text-sm font-normal ${
78
+ selected ? "bg-muted" : ""
79
+ }`}
80
+ >
81
+ <NodeBadge kind={node.type} />
82
+ <span className="font-medium">{node.name}</span>
83
+ {"meta" in node &&
84
+ "alias" in node.meta &&
85
+ node.meta.alias !== node.name && (
86
+ <span className="font-mono text-xs text-muted-foreground">
87
+ {node.meta.alias}
88
+ </span>
89
+ )}
90
+ </Button>
91
+ </li>
92
+ )
93
+ }
94
+
95
+ function cssEscape(value: string): string {
96
+ return typeof CSS !== "undefined" && CSS.escape
97
+ ? CSS.escape(value)
98
+ : value.replace(/(["\\])/g, "\\$1")
99
+ }
100
+
101
+ function groupByKind(nodes: Node[]): Map<NodeKind, Node[]> {
102
+ const out = new Map<NodeKind, Node[]>()
103
+ for (const node of nodes) {
104
+ const list = out.get(node.type) ?? []
105
+ list.push(node)
106
+ out.set(node.type, list)
107
+ }
108
+ return out
109
+ }
@@ -0,0 +1,485 @@
1
+ import { memo, useCallback, useEffect, useMemo, useState } from "react"
2
+ import { CaretRightIcon } from "@phosphor-icons/react"
3
+ import {
4
+ defaultRangeExtractor,
5
+ useVirtualizer,
6
+ type Range,
7
+ } from "@tanstack/react-virtual"
8
+ import { ScrollArea } from "@/components/ui/scroll-area"
9
+ import { Button } from "@/components/ui/button"
10
+ import { NodeBadge } from "@/components/node-badge"
11
+ import { DomainHeader } from "@/components/domain-header"
12
+ import { NodeName } from "@/components/node-name"
13
+ import { type GraphIndex, type NodeId } from "@/domain/graph"
14
+ import type { Node } from "@/domain/node"
15
+ import { edgeKind, verbFor, type Edge } from "@/domain/edge"
16
+ import { effectiveRoots } from "@/domain/roots"
17
+ import type { Direction } from "@/domain/direction"
18
+ import { groupByDomain } from "@/domain/domain-grouping"
19
+ import { flattenTree, type FlatRow } from "@/domain/flatten-tree"
20
+ import { type ExpansionApi } from "@/application/use-expansion"
21
+ import { useReveal, type RevealApi } from "@/application/use-reveal"
22
+ import type { DomainMap } from "@/application/use-domains"
23
+ import type { FontSize, Settings } from "@/application/use-settings"
24
+
25
+ const INDENT = 31
26
+ const HEADER_Z = 40
27
+ const ROW_Z_BASE = 30
28
+ const STICKY_BOTTOM_CLASSES =
29
+ "after:bg-border after:pointer-events-none after:absolute after:bottom-[-1px] after:left-[-9999px] after:right-[-9999px] after:h-px after:content-['']"
30
+
31
+ const ROW_HEIGHT_BY_FONT: Record<FontSize, number> = {
32
+ sm: 30,
33
+ md: 36,
34
+ lg: 44,
35
+ }
36
+
37
+ const ROW_TEXT_BY_FONT: Record<
38
+ FontSize,
39
+ { main: string; meta: string; padY: string }
40
+ > = {
41
+ sm: { main: "text-xs", meta: "text-[10px]", padY: "py-1" },
42
+ md: { main: "text-sm", meta: "text-xs", padY: "py-1.5" },
43
+ lg: { main: "text-base", meta: "text-sm", padY: "py-2" },
44
+ }
45
+
46
+ interface TreeViewProps {
47
+ index: GraphIndex
48
+ visibleNodes: Node[]
49
+ domains: DomainMap
50
+ direction: Direction
51
+ settings: Settings
52
+ expansion: ExpansionApi
53
+ selectedId: NodeId | null
54
+ onSelect: (id: NodeId) => void
55
+ }
56
+
57
+ export function TreeView(props: TreeViewProps) {
58
+ const reveal = useReveal()
59
+ const rowHeight = ROW_HEIGHT_BY_FONT[props.settings.fontSize]
60
+ const [scrollRoot, setScrollRoot] = useState<HTMLDivElement | null>(null)
61
+
62
+ return (
63
+ <ScrollArea ref={setScrollRoot} className="h-full">
64
+ <TreeViewBody
65
+ key={rowHeight}
66
+ {...props}
67
+ reveal={reveal}
68
+ rowHeight={rowHeight}
69
+ scrollRoot={scrollRoot}
70
+ />
71
+ </ScrollArea>
72
+ )
73
+ }
74
+
75
+ interface TreeViewBodyProps extends TreeViewProps {
76
+ reveal: RevealApi
77
+ rowHeight: number
78
+ scrollRoot: HTMLDivElement | null
79
+ }
80
+
81
+ function TreeViewBody({
82
+ index,
83
+ visibleNodes,
84
+ domains,
85
+ direction,
86
+ settings,
87
+ expansion,
88
+ selectedId,
89
+ onSelect,
90
+ reveal,
91
+ rowHeight,
92
+ scrollRoot,
93
+ }: TreeViewBodyProps) {
94
+ const visibleIds = useMemo(
95
+ () => new Set(visibleNodes.map((n) => `${n.type}:${n.name}` as NodeId)),
96
+ [visibleNodes]
97
+ )
98
+
99
+ const groups = useMemo(() => {
100
+ const visibleRoots = effectiveRoots(index, direction).filter((id) =>
101
+ visibleIds.has(id)
102
+ )
103
+ return groupByDomain(visibleRoots, domains)
104
+ }, [index, direction, visibleIds, domains])
105
+
106
+ const rows = useMemo(
107
+ () =>
108
+ flattenTree({
109
+ index,
110
+ groups,
111
+ direction,
112
+ visibleIds,
113
+ isExpanded: expansion.isExpanded,
114
+ isRevealed: reveal.isRevealed,
115
+ headerHeight: rowHeight,
116
+ rowHeight,
117
+ }),
118
+ [index, groups, direction, visibleIds, expansion, reveal, rowHeight]
119
+ )
120
+
121
+ const rangeExtractor = useCallback(
122
+ (range: Range) => {
123
+ const first = rows[range.startIndex]
124
+ const ancestors = first?.ancestors ?? []
125
+ const set = new Set<number>([
126
+ ...ancestors,
127
+ ...defaultRangeExtractor(range),
128
+ ])
129
+ return [...set].sort((a, b) => a - b)
130
+ },
131
+ [rows]
132
+ )
133
+
134
+ const virtualizer = useVirtualizer({
135
+ count: rows.length,
136
+ getScrollElement: () =>
137
+ scrollRoot?.querySelector<HTMLElement>(
138
+ '[data-slot="scroll-area-viewport"]'
139
+ ) ?? scrollRoot,
140
+ estimateSize: () => rowHeight,
141
+ overscan: 20,
142
+ rangeExtractor,
143
+ })
144
+
145
+ useEffect(() => {
146
+ if (!selectedId) return
147
+ const index = rows.findIndex(
148
+ (r) => r.kind === "trace-row" && r.trace.id === selectedId
149
+ )
150
+ if (index < 0) {
151
+ const domain = domains.get(selectedId)
152
+ if (!domain) return
153
+ const sectionPath = `domain:${domain.key}`
154
+ if (!expansion.isExpanded(sectionPath)) {
155
+ expansion.toggle(sectionPath)
156
+ }
157
+ return
158
+ }
159
+ const raf = requestAnimationFrame(() => {
160
+ const vp = scrollRoot?.querySelector<HTMLElement>(
161
+ '[data-slot="scroll-area-viewport"]'
162
+ )
163
+ if (!vp) {
164
+ virtualizer.scrollToIndex(index, { align: "center" })
165
+ return
166
+ }
167
+ const targetTop = Math.max(
168
+ 0,
169
+ index * rowHeight - vp.clientHeight / 2 + rowHeight / 2
170
+ )
171
+ vp.scrollTop = targetTop
172
+ })
173
+ return () => cancelAnimationFrame(raf)
174
+ }, [
175
+ selectedId,
176
+ rows,
177
+ virtualizer,
178
+ domains,
179
+ expansion,
180
+ rowHeight,
181
+ scrollRoot,
182
+ ])
183
+
184
+ const scrollOffset = virtualizer.scrollOffset ?? 0
185
+ const virtualItems = virtualizer.getVirtualItems()
186
+ let pinnedBottomPath: string | null = null
187
+ let deepestStickyTop = -1
188
+ for (const item of virtualItems) {
189
+ const row = rows[item.index]
190
+ if (!row.stickable) continue
191
+ const enter = item.start - row.stickyTop
192
+ const exit = item.start + row.subtreeSize * rowHeight - row.stickyTop
193
+ if (scrollOffset >= enter && scrollOffset < exit) {
194
+ if (row.stickyTop > deepestStickyTop) {
195
+ deepestStickyTop = row.stickyTop
196
+ pinnedBottomPath = row.path
197
+ }
198
+ }
199
+ }
200
+
201
+ if (rows.length === 0) {
202
+ return (
203
+ <p className="px-6 py-4 text-sm text-muted-foreground">
204
+ No matching roots.
205
+ </p>
206
+ )
207
+ }
208
+
209
+ return (
210
+ <div className="overflow-x-clip px-6 pb-4">
211
+ <div className="relative" style={{ height: virtualizer.getTotalSize() }}>
212
+ {virtualItems.map((virtualRow) => (
213
+ <FlatRowSlot
214
+ key={virtualRow.key}
215
+ row={rows[virtualRow.index]}
216
+ naturalY={virtualRow.start}
217
+ size={virtualRow.size}
218
+ rowHeight={rowHeight}
219
+ isPinnedBottom={rows[virtualRow.index].path === pinnedBottomPath}
220
+ direction={direction}
221
+ settings={settings}
222
+ selectedId={selectedId}
223
+ onSelect={onSelect}
224
+ expansion={expansion}
225
+ reveal={reveal}
226
+ />
227
+ ))}
228
+ </div>
229
+ </div>
230
+ )
231
+ }
232
+
233
+ interface FlatRowSlotProps {
234
+ row: FlatRow
235
+ naturalY: number
236
+ size: number
237
+ rowHeight: number
238
+ isPinnedBottom: boolean
239
+ direction: Direction
240
+ settings: Settings
241
+ selectedId: NodeId | null
242
+ onSelect: (id: NodeId) => void
243
+ expansion: ExpansionApi
244
+ reveal: RevealApi
245
+ }
246
+
247
+ const FlatRowSlot = memo(function FlatRowSlot({
248
+ row,
249
+ naturalY,
250
+ rowHeight,
251
+ isPinnedBottom,
252
+ direction,
253
+ settings,
254
+ selectedId,
255
+ onSelect,
256
+ expansion,
257
+ reveal,
258
+ }: FlatRowSlotProps) {
259
+ const depth = row.kind === "trace-row" ? row.depth : 0
260
+ const zIndex = row.stickable
261
+ ? row.kind === "domain-header"
262
+ ? HEADER_Z
263
+ : ROW_Z_BASE - depth
264
+ : 0
265
+
266
+ if (row.stickable) {
267
+ return (
268
+ <div
269
+ className="pointer-events-none absolute right-0 left-0"
270
+ style={{
271
+ top: naturalY,
272
+ height: row.subtreeSize * rowHeight,
273
+ zIndex,
274
+ }}
275
+ >
276
+ <div
277
+ className={`pointer-events-auto sticky flex w-full items-center bg-background ${
278
+ isPinnedBottom ? STICKY_BOTTOM_CLASSES : ""
279
+ }`}
280
+ style={{ top: row.stickyTop, minHeight: rowHeight }}
281
+ >
282
+ <RowContent
283
+ row={row}
284
+ rowHeight={rowHeight}
285
+ direction={direction}
286
+ settings={settings}
287
+ selectedId={selectedId}
288
+ onSelect={onSelect}
289
+ expansion={expansion}
290
+ reveal={reveal}
291
+ />
292
+ </div>
293
+ </div>
294
+ )
295
+ }
296
+
297
+ return (
298
+ <div
299
+ className="absolute right-0 left-0 flex items-center bg-background"
300
+ style={{
301
+ top: naturalY,
302
+ minHeight: rowHeight,
303
+ zIndex,
304
+ }}
305
+ >
306
+ <RowContent
307
+ row={row}
308
+ rowHeight={rowHeight}
309
+ direction={direction}
310
+ settings={settings}
311
+ selectedId={selectedId}
312
+ onSelect={onSelect}
313
+ expansion={expansion}
314
+ reveal={reveal}
315
+ />
316
+ </div>
317
+ )
318
+ })
319
+
320
+ function RowContent({
321
+ row,
322
+ rowHeight,
323
+ direction,
324
+ settings,
325
+ selectedId,
326
+ onSelect,
327
+ expansion,
328
+ reveal,
329
+ }: {
330
+ row: FlatRow
331
+ rowHeight: number
332
+ direction: Direction
333
+ settings: Settings
334
+ selectedId: NodeId | null
335
+ onSelect: (id: NodeId) => void
336
+ expansion: ExpansionApi
337
+ reveal: RevealApi
338
+ }) {
339
+ if (row.kind === "domain-header") {
340
+ return (
341
+ <DomainHeader
342
+ label={row.label}
343
+ expanded={row.expanded}
344
+ onToggle={() => expansion.toggle(row.path)}
345
+ meta={`${row.count} root${row.count === 1 ? "" : "s"}`}
346
+ />
347
+ )
348
+ }
349
+
350
+ if (row.kind === "hidden-indicator") {
351
+ const indentDepth = (row.stickyTop - rowHeight) / rowHeight
352
+ return (
353
+ <div
354
+ className="relative flex h-full w-full items-center"
355
+ style={{ paddingLeft: indentDepth * INDENT }}
356
+ >
357
+ <IndentGuides depth={indentDepth} />
358
+ <Button
359
+ variant="ghost"
360
+ size="sm"
361
+ onClick={() => reveal.toggle(row.togglePath)}
362
+ className="h-auto w-fit justify-start px-3 py-1 text-xs font-normal text-muted-foreground italic"
363
+ >
364
+ {row.revealed
365
+ ? `Hide ${row.count} filtered`
366
+ : `${row.count} hidden by filter`}
367
+ </Button>
368
+ </div>
369
+ )
370
+ }
371
+
372
+ return (
373
+ <TraceItem
374
+ row={row}
375
+ direction={direction}
376
+ settings={settings}
377
+ selectedId={selectedId}
378
+ onSelect={onSelect}
379
+ expansion={expansion}
380
+ />
381
+ )
382
+ }
383
+
384
+ function TraceItem({
385
+ row,
386
+ direction,
387
+ settings,
388
+ selectedId,
389
+ onSelect,
390
+ expansion,
391
+ }: {
392
+ row: Extract<FlatRow, { kind: "trace-row" }>
393
+ direction: Direction
394
+ settings: Settings
395
+ selectedId: NodeId | null
396
+ onSelect: (id: NodeId) => void
397
+ expansion: ExpansionApi
398
+ }) {
399
+ const { trace, depth, hasChildren, expanded, domainPrefix } = row
400
+ const selected = selectedId === trace.id
401
+ const text = ROW_TEXT_BY_FONT[settings.fontSize]
402
+
403
+ return (
404
+ <div
405
+ className="relative flex h-full w-full items-center gap-1"
406
+ style={{ paddingLeft: depth * INDENT }}
407
+ >
408
+ <IndentGuides depth={depth} />
409
+ <Button
410
+ variant="ghost"
411
+ size="icon-xs"
412
+ onClick={() => expansion.toggle(row.path)}
413
+ disabled={!hasChildren}
414
+ aria-expanded={expanded}
415
+ className="shrink-0 text-muted-foreground"
416
+ >
417
+ <CaretRightIcon
418
+ className={`transition-transform ${expanded ? "rotate-90" : ""} ${
419
+ hasChildren ? "" : "opacity-0"
420
+ }`}
421
+ />
422
+ </Button>
423
+ <Button
424
+ variant="ghost"
425
+ size="sm"
426
+ onClick={() => onSelect(trace.id)}
427
+ aria-pressed={selected}
428
+ className={`h-auto w-fit justify-start gap-3 px-3 ${text.padY} ${text.main} font-normal ${
429
+ selected ? "bg-muted" : ""
430
+ }`}
431
+ >
432
+ {trace.edge && (
433
+ <EdgeLabel
434
+ edge={trace.edge}
435
+ direction={direction}
436
+ metaClass={text.meta}
437
+ />
438
+ )}
439
+ <NodeBadge kind={trace.node.type} />
440
+ <span>
441
+ <NodeName
442
+ name={trace.node.name}
443
+ kind={trace.node.type}
444
+ domainPrefix={domainPrefix}
445
+ hide={settings.hideDomainPrefix}
446
+ metaClass={text.meta}
447
+ />
448
+ </span>
449
+ </Button>
450
+ </div>
451
+ )
452
+ }
453
+
454
+ function IndentGuides({ depth }: { depth: number }) {
455
+ if (depth === 0) return null
456
+ return (
457
+ <>
458
+ {Array.from({ length: depth }, (_, i) => (
459
+ <span
460
+ key={i}
461
+ className="pointer-events-none absolute top-0 bottom-0 border-l border-border/60"
462
+ style={{ left: 11.5 + i * INDENT }}
463
+ />
464
+ ))}
465
+ </>
466
+ )
467
+ }
468
+
469
+ function EdgeLabel({
470
+ edge,
471
+ direction,
472
+ metaClass,
473
+ }: {
474
+ edge: Edge
475
+ direction: Direction
476
+ metaClass: string
477
+ }) {
478
+ return (
479
+ <span
480
+ className={`font-mono text-muted-foreground ${metaClass} tracking-wide uppercase`}
481
+ >
482
+ {verbFor(direction, edgeKind(edge))}
483
+ </span>
484
+ )
485
+ }
@@ -0,0 +1,66 @@
1
+ import { edgeKind, type Edge, type EdgeKind } from "./edge"
2
+ import type { Graph } from "./graph"
3
+ import type { Node, NodeKind } from "./node"
4
+
5
+ const NODE_LABEL: Record<NodeKind, string> = {
6
+ event: "Event",
7
+ command: "Command",
8
+ saga: "Saga",
9
+ aggregate: "Aggregate",
10
+ projection: "Projection",
11
+ }
12
+
13
+ const REL_TYPE: Record<EdgeKind, string> = {
14
+ reacts: "TRIGGERS",
15
+ sends: "SENDS",
16
+ emits: "EMITS",
17
+ "handler-emits": "EMITS",
18
+ }
19
+
20
+ export function exportGraphToCypher(graph: Graph): string {
21
+ if (graph.nodes.length === 0) return "// No nodes to export\n"
22
+
23
+ const varByKey = new Map<string, string>()
24
+ graph.nodes.forEach((node, i) => {
25
+ varByKey.set(`${node.type}:${node.name}`, `n${i}`)
26
+ })
27
+
28
+ const lines: string[] = []
29
+ lines.push("CREATE")
30
+
31
+ const parts: string[] = []
32
+ for (const node of graph.nodes) {
33
+ const variable = varByKey.get(`${node.type}:${node.name}`)!
34
+ parts.push(
35
+ ` (${variable}:${NODE_LABEL[node.type]} {name: ${quote(node.name)}})`
36
+ )
37
+ }
38
+ for (const edge of graph.edges) {
39
+ const from = varByKey.get(`${edge.from.type}:${edge.from.name}`)
40
+ const to = varByKey.get(`${edge.to.type}:${edge.to.name}`)
41
+ if (!from || !to) continue
42
+ parts.push(` (${from})-[:${REL_TYPE[edgeKind(edge)]}]->(${to})`)
43
+ }
44
+
45
+ lines.push(parts.join(",\n") + ";")
46
+ return lines.join("\n") + "\n"
47
+ }
48
+
49
+ export function subgraphFromVisible(
50
+ graph: Graph,
51
+ visibleNodes: Node[]
52
+ ): Graph {
53
+ const visibleIds = new Set(visibleNodes.map((n) => `${n.type}:${n.name}`))
54
+ return {
55
+ nodes: graph.nodes.filter((n) => visibleIds.has(`${n.type}:${n.name}`)),
56
+ edges: graph.edges.filter(
57
+ (e: Edge) =>
58
+ visibleIds.has(`${e.from.type}:${e.from.name}`) &&
59
+ visibleIds.has(`${e.to.type}:${e.to.name}`)
60
+ ),
61
+ }
62
+ }
63
+
64
+ function quote(value: string): string {
65
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`
66
+ }
@@ -0,0 +1 @@
1
+ export type Direction = "forward" | "reverse"