@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,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"
|