@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,217 @@
|
|
|
1
|
+
import type { Node } from "./node"
|
|
2
|
+
import { nodeId, type NodeId } from "./graph"
|
|
3
|
+
|
|
4
|
+
export interface NodeDomain {
|
|
5
|
+
key: string
|
|
6
|
+
label: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function computeDomains(nodes: Node[]): Map<NodeId, NodeDomain> {
|
|
10
|
+
const chains = buildFolderChains(nodes)
|
|
11
|
+
const segmentCounts = countLastSegments(chains)
|
|
12
|
+
const picks = pickUniqueFolders(nodes, chains, segmentCounts)
|
|
13
|
+
const labels = labelDomains([...new Set(picks.values())])
|
|
14
|
+
return mergeIntoDomains(picks, labels)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DomainGroup {
|
|
18
|
+
domain: NodeDomain
|
|
19
|
+
rootIds: NodeId[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function domainPrefixFromLabel(label: string): string {
|
|
23
|
+
if (!label || label === "·") return ""
|
|
24
|
+
return label.replace(/\s+/g, "")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type StripPosition = "prefix" | "suffix" | null
|
|
28
|
+
|
|
29
|
+
export interface StripResult {
|
|
30
|
+
stripped: string
|
|
31
|
+
position: StripPosition
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isJustKind(text: string, kind: string): boolean {
|
|
35
|
+
return text.toLowerCase() === kind.toLowerCase()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function stripDomainAffix(
|
|
39
|
+
name: string,
|
|
40
|
+
affix: string,
|
|
41
|
+
allowEmpty = false
|
|
42
|
+
): StripResult {
|
|
43
|
+
const asPrefix = tryStripPrefix(name, affix, allowEmpty)
|
|
44
|
+
if (asPrefix !== null) return { stripped: asPrefix, position: "prefix" }
|
|
45
|
+
const asSuffix = tryStripSuffix(name, affix, allowEmpty)
|
|
46
|
+
if (asSuffix !== null) return { stripped: asSuffix, position: "suffix" }
|
|
47
|
+
return { stripped: name, position: null }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function tryStripPrefix(
|
|
51
|
+
name: string,
|
|
52
|
+
affix: string,
|
|
53
|
+
allowEmpty: boolean
|
|
54
|
+
): string | null {
|
|
55
|
+
if (!affix) return null
|
|
56
|
+
if (!name.startsWith(affix)) return null
|
|
57
|
+
if (name.length === affix.length) return allowEmpty ? "" : null
|
|
58
|
+
const next = name[affix.length]
|
|
59
|
+
if (next !== next.toUpperCase()) return null
|
|
60
|
+
return name.slice(affix.length)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tryStripSuffix(
|
|
64
|
+
name: string,
|
|
65
|
+
affix: string,
|
|
66
|
+
allowEmpty: boolean
|
|
67
|
+
): string | null {
|
|
68
|
+
if (!affix) return null
|
|
69
|
+
if (!name.endsWith(affix)) return null
|
|
70
|
+
if (name.length === affix.length) return allowEmpty ? "" : null
|
|
71
|
+
const prev = name[name.length - affix.length - 1]
|
|
72
|
+
if (prev !== prev.toLowerCase()) return null
|
|
73
|
+
return name.slice(0, name.length - affix.length)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function groupByDomain(
|
|
77
|
+
rootIds: NodeId[],
|
|
78
|
+
domains: ReadonlyMap<NodeId, NodeDomain>
|
|
79
|
+
): DomainGroup[] {
|
|
80
|
+
const groups = new Map<string, DomainGroup>()
|
|
81
|
+
for (const id of rootIds) {
|
|
82
|
+
const domain = domains.get(id) ?? { key: "·", label: "·" }
|
|
83
|
+
const existing = groups.get(domain.key)
|
|
84
|
+
if (existing) existing.rootIds.push(id)
|
|
85
|
+
else groups.set(domain.key, { domain, rootIds: [id] })
|
|
86
|
+
}
|
|
87
|
+
return [...groups.values()].sort((a, b) =>
|
|
88
|
+
a.domain.label.localeCompare(b.domain.label)
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildFolderChains(nodes: Node[]): Map<NodeId, string[]> {
|
|
93
|
+
const chains = new Map<NodeId, string[]>()
|
|
94
|
+
for (const n of nodes) {
|
|
95
|
+
chains.set(nodeId(n.type, n.name), folderChain(folderOf(n.source.file)))
|
|
96
|
+
}
|
|
97
|
+
return chains
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function countLastSegments(chains: Map<NodeId, string[]>): Map<string, number> {
|
|
101
|
+
const unique = new Set<string>()
|
|
102
|
+
for (const chain of chains.values()) for (const f of chain) unique.add(f)
|
|
103
|
+
|
|
104
|
+
const counts = new Map<string, number>()
|
|
105
|
+
for (const folder of unique) {
|
|
106
|
+
const seg = lastSegment(folder)
|
|
107
|
+
if (seg) counts.set(seg, (counts.get(seg) ?? 0) + 1)
|
|
108
|
+
}
|
|
109
|
+
return counts
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function pickUniqueFolders(
|
|
113
|
+
nodes: Node[],
|
|
114
|
+
chains: Map<NodeId, string[]>,
|
|
115
|
+
segmentCounts: Map<string, number>
|
|
116
|
+
): Map<NodeId, string> {
|
|
117
|
+
const picks = new Map<NodeId, string>()
|
|
118
|
+
for (const n of nodes) {
|
|
119
|
+
const id = nodeId(n.type, n.name)
|
|
120
|
+
const chain = chains.get(id) ?? []
|
|
121
|
+
picks.set(id, pickDeepestUniqueFolder(chain, segmentCounts))
|
|
122
|
+
}
|
|
123
|
+
return picks
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pickDeepestUniqueFolder(
|
|
127
|
+
chain: string[],
|
|
128
|
+
segmentCounts: Map<string, number>
|
|
129
|
+
): string {
|
|
130
|
+
for (const folder of chain) {
|
|
131
|
+
const seg = lastSegment(folder)
|
|
132
|
+
if (seg && (segmentCounts.get(seg) ?? 0) === 1) return folder
|
|
133
|
+
}
|
|
134
|
+
return chain[0] ?? ""
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function labelDomains(paths: string[]): Map<string, string> {
|
|
138
|
+
const segmentsByPath = new Map<string, string[]>()
|
|
139
|
+
for (const p of paths) segmentsByPath.set(p, p.split("/").filter(Boolean))
|
|
140
|
+
|
|
141
|
+
const labels = new Map<string, string>()
|
|
142
|
+
for (const path of paths) {
|
|
143
|
+
const segments = segmentsByPath.get(path) ?? []
|
|
144
|
+
labels.set(path, humanCase(disambiguatedSegment(segments, segmentsByPath)))
|
|
145
|
+
}
|
|
146
|
+
return labels
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function disambiguatedSegment(
|
|
150
|
+
segments: string[],
|
|
151
|
+
segmentsByPath: Map<string, string[]>
|
|
152
|
+
): string {
|
|
153
|
+
if (segments.length === 0) return ""
|
|
154
|
+
for (let depth = 1; depth <= segments.length; depth++) {
|
|
155
|
+
const suffix = segments.slice(segments.length - depth).join("/")
|
|
156
|
+
if (countSuffixMatches(suffix, depth, segmentsByPath) === 1) {
|
|
157
|
+
return segments[segments.length - depth]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return segments[segments.length - 1]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function countSuffixMatches(
|
|
164
|
+
suffix: string,
|
|
165
|
+
depth: number,
|
|
166
|
+
segmentsByPath: Map<string, string[]>
|
|
167
|
+
): number {
|
|
168
|
+
let count = 0
|
|
169
|
+
for (const segments of segmentsByPath.values()) {
|
|
170
|
+
if (segments.length < depth) continue
|
|
171
|
+
if (segments.slice(segments.length - depth).join("/") === suffix) count++
|
|
172
|
+
}
|
|
173
|
+
return count
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function mergeIntoDomains(
|
|
177
|
+
picks: Map<NodeId, string>,
|
|
178
|
+
labels: Map<string, string>
|
|
179
|
+
): Map<NodeId, NodeDomain> {
|
|
180
|
+
const out = new Map<NodeId, NodeDomain>()
|
|
181
|
+
for (const [id, key] of picks) {
|
|
182
|
+
out.set(id, { key, label: labels.get(key) || "·" })
|
|
183
|
+
}
|
|
184
|
+
return out
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function folderOf(file: string): string {
|
|
188
|
+
const parts = file.split(/[\\/]/).filter((s) => s && s !== ".")
|
|
189
|
+
parts.pop()
|
|
190
|
+
return parts.join("/")
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function folderChain(folder: string): string[] {
|
|
194
|
+
if (!folder) return []
|
|
195
|
+
const parts = folder.split("/")
|
|
196
|
+
const chain: string[] = []
|
|
197
|
+
for (let depth = parts.length; depth > 0; depth--) {
|
|
198
|
+
chain.push(parts.slice(0, depth).join("/"))
|
|
199
|
+
}
|
|
200
|
+
return chain
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function lastSegment(folder: string): string | undefined {
|
|
204
|
+
return folder.split("/").filter(Boolean).pop()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function humanCase(name: string): string {
|
|
208
|
+
return name
|
|
209
|
+
.replace(/[-_.]+/g, " ")
|
|
210
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
211
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
212
|
+
.replace(/\s+/g, " ")
|
|
213
|
+
.trim()
|
|
214
|
+
.split(" ")
|
|
215
|
+
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
|
|
216
|
+
.join(" ")
|
|
217
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Direction } from "./direction"
|
|
2
|
+
|
|
3
|
+
type EventRef = { type: "event"; name: string }
|
|
4
|
+
type CommandRef = { type: "command"; name: string }
|
|
5
|
+
type Reactor = {
|
|
6
|
+
type: "saga" | "aggregate" | "projection"
|
|
7
|
+
name: string
|
|
8
|
+
}
|
|
9
|
+
type Sender = { type: "saga" | "aggregate"; name: string }
|
|
10
|
+
|
|
11
|
+
type Source = { file: string; start: number }
|
|
12
|
+
|
|
13
|
+
export type Edge =
|
|
14
|
+
| { from: EventRef; to: Reactor; source: Source }
|
|
15
|
+
| { from: Sender; to: CommandRef; source: Source }
|
|
16
|
+
| { from: Sender; to: EventRef; source: Source }
|
|
17
|
+
| { from: CommandRef; to: EventRef; source: Source }
|
|
18
|
+
|
|
19
|
+
export type EdgeKind = "reacts" | "sends" | "emits" | "handler-emits"
|
|
20
|
+
|
|
21
|
+
export function edgeKind(edge: Edge): EdgeKind {
|
|
22
|
+
if (edge.from.type === "event") return "reacts"
|
|
23
|
+
if (edge.from.type === "command") return "handler-emits"
|
|
24
|
+
if (edge.to.type === "command") return "sends"
|
|
25
|
+
return "emits"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function verbFor(direction: Direction, kind: EdgeKind): string {
|
|
29
|
+
if (direction === "forward") {
|
|
30
|
+
if (kind === "reacts") return "triggers"
|
|
31
|
+
if (kind === "sends") return "sends"
|
|
32
|
+
return "emits"
|
|
33
|
+
}
|
|
34
|
+
if (kind === "reacts") return "triggered by"
|
|
35
|
+
if (kind === "sends") return "sent by"
|
|
36
|
+
return "emitted by"
|
|
37
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Node, NodeKind } from "./node"
|
|
2
|
+
|
|
3
|
+
export interface NodeFilter {
|
|
4
|
+
search: string
|
|
5
|
+
kinds: ReadonlySet<NodeKind>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function matchesFilter(node: Node, filter: NodeFilter): boolean {
|
|
9
|
+
if (!filter.kinds.has(node.type)) return false
|
|
10
|
+
if (!filter.search) return true
|
|
11
|
+
const q = filter.search.toLowerCase()
|
|
12
|
+
if (node.name.toLowerCase().includes(q)) return true
|
|
13
|
+
if (
|
|
14
|
+
"meta" in node &&
|
|
15
|
+
"alias" in node.meta &&
|
|
16
|
+
node.meta.alias.toLowerCase().includes(q)
|
|
17
|
+
) {
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { GraphIndex, NodeId } from "./graph"
|
|
2
|
+
import type { TraceNode } from "./traversal"
|
|
3
|
+
import { traceFrom } from "./traversal"
|
|
4
|
+
import type { Direction } from "./direction"
|
|
5
|
+
import { domainPrefixFromLabel, type DomainGroup } from "./domain-grouping"
|
|
6
|
+
|
|
7
|
+
export type FlatRow = DomainHeaderRow | TraceRowItem | HiddenIndicatorRow
|
|
8
|
+
|
|
9
|
+
export interface DomainHeaderRow {
|
|
10
|
+
kind: "domain-header"
|
|
11
|
+
path: string
|
|
12
|
+
ancestors: readonly number[]
|
|
13
|
+
stickable: true
|
|
14
|
+
stickyTop: number
|
|
15
|
+
subtreeSize: number
|
|
16
|
+
label: string
|
|
17
|
+
count: number
|
|
18
|
+
expanded: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TraceRowItem {
|
|
22
|
+
kind: "trace-row"
|
|
23
|
+
path: string
|
|
24
|
+
ancestors: readonly number[]
|
|
25
|
+
stickable: boolean
|
|
26
|
+
stickyTop: number
|
|
27
|
+
subtreeSize: number
|
|
28
|
+
depth: number
|
|
29
|
+
trace: TraceNode
|
|
30
|
+
domainPrefix: string
|
|
31
|
+
hasChildren: boolean
|
|
32
|
+
expanded: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HiddenIndicatorRow {
|
|
36
|
+
kind: "hidden-indicator"
|
|
37
|
+
path: string
|
|
38
|
+
ancestors: readonly number[]
|
|
39
|
+
stickable: false
|
|
40
|
+
stickyTop: number
|
|
41
|
+
count: number
|
|
42
|
+
revealed: boolean
|
|
43
|
+
togglePath: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FlattenInput {
|
|
47
|
+
index: GraphIndex
|
|
48
|
+
groups: DomainGroup[]
|
|
49
|
+
direction: Direction
|
|
50
|
+
visibleIds: ReadonlySet<NodeId>
|
|
51
|
+
isExpanded: (path: string) => boolean
|
|
52
|
+
isRevealed: (path: string) => boolean
|
|
53
|
+
headerHeight: number
|
|
54
|
+
rowHeight: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function flattenTree(input: FlattenInput): FlatRow[] {
|
|
58
|
+
const out: FlatRow[] = []
|
|
59
|
+
for (const group of input.groups) emitGroup(group, input, out)
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function emitGroup(group: DomainGroup, input: FlattenInput, out: FlatRow[]) {
|
|
64
|
+
const sectionPath = `domain:${group.domain.key}`
|
|
65
|
+
const expanded = input.isExpanded(sectionPath)
|
|
66
|
+
const traces = group.rootIds
|
|
67
|
+
.map((id) => traceFrom(input.index, id, input.direction))
|
|
68
|
+
.filter((t): t is TraceNode => t !== null)
|
|
69
|
+
|
|
70
|
+
const headerIndex = out.length
|
|
71
|
+
out.push({
|
|
72
|
+
kind: "domain-header",
|
|
73
|
+
path: sectionPath,
|
|
74
|
+
ancestors: [],
|
|
75
|
+
stickable: true,
|
|
76
|
+
stickyTop: 0,
|
|
77
|
+
subtreeSize: 1,
|
|
78
|
+
label: group.domain.label,
|
|
79
|
+
count: traces.length,
|
|
80
|
+
expanded,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (!expanded) return
|
|
84
|
+
|
|
85
|
+
const domainPrefix = domainPrefixFromLabel(group.domain.label)
|
|
86
|
+
const rootAncestors: readonly number[] = [headerIndex]
|
|
87
|
+
for (const trace of traces) {
|
|
88
|
+
emitTrace(
|
|
89
|
+
trace,
|
|
90
|
+
`${group.domain.key}/${trace.id}`,
|
|
91
|
+
0,
|
|
92
|
+
rootAncestors,
|
|
93
|
+
domainPrefix,
|
|
94
|
+
input,
|
|
95
|
+
out
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
;(out[headerIndex] as DomainHeaderRow).subtreeSize = out.length - headerIndex
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function emitTrace(
|
|
103
|
+
trace: TraceNode,
|
|
104
|
+
path: string,
|
|
105
|
+
depth: number,
|
|
106
|
+
ancestors: readonly number[],
|
|
107
|
+
domainPrefix: string,
|
|
108
|
+
input: FlattenInput,
|
|
109
|
+
out: FlatRow[]
|
|
110
|
+
) {
|
|
111
|
+
const expanded = input.isExpanded(path)
|
|
112
|
+
const hasChildren = trace.children.length > 0
|
|
113
|
+
const stickable = hasChildren && expanded
|
|
114
|
+
const stickyTop = input.headerHeight + depth * input.rowHeight
|
|
115
|
+
const rowIndex = out.length
|
|
116
|
+
|
|
117
|
+
out.push({
|
|
118
|
+
kind: "trace-row",
|
|
119
|
+
path,
|
|
120
|
+
ancestors,
|
|
121
|
+
stickable,
|
|
122
|
+
stickyTop,
|
|
123
|
+
subtreeSize: 1,
|
|
124
|
+
depth,
|
|
125
|
+
trace,
|
|
126
|
+
domainPrefix,
|
|
127
|
+
hasChildren,
|
|
128
|
+
expanded,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (!stickable) return
|
|
132
|
+
|
|
133
|
+
const visibleChildren = trace.children.filter((c) =>
|
|
134
|
+
input.visibleIds.has(c.id)
|
|
135
|
+
)
|
|
136
|
+
const revealed = input.isRevealed(path)
|
|
137
|
+
const childrenToRender = revealed ? trace.children : visibleChildren
|
|
138
|
+
const childAncestors: readonly number[] = [...ancestors, rowIndex]
|
|
139
|
+
|
|
140
|
+
childrenToRender.forEach((child, idx) => {
|
|
141
|
+
emitTrace(
|
|
142
|
+
child,
|
|
143
|
+
`${path}/${child.id}:${idx}`,
|
|
144
|
+
depth + 1,
|
|
145
|
+
childAncestors,
|
|
146
|
+
domainPrefix,
|
|
147
|
+
input,
|
|
148
|
+
out
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const hiddenCount = trace.children.length - visibleChildren.length
|
|
153
|
+
if (hiddenCount > 0) {
|
|
154
|
+
out.push({
|
|
155
|
+
kind: "hidden-indicator",
|
|
156
|
+
path: `${path}#hidden`,
|
|
157
|
+
ancestors: childAncestors,
|
|
158
|
+
stickable: false,
|
|
159
|
+
stickyTop: input.headerHeight + (depth + 1) * input.rowHeight,
|
|
160
|
+
count: hiddenCount,
|
|
161
|
+
revealed,
|
|
162
|
+
togglePath: path,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
;(out[rowIndex] as TraceRowItem).subtreeSize = out.length - rowIndex
|
|
167
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Edge } from "./edge"
|
|
2
|
+
import type { Node, NodeKind } from "./node"
|
|
3
|
+
|
|
4
|
+
export type Graph = { nodes: Node[]; edges: Edge[] }
|
|
5
|
+
|
|
6
|
+
export type NodeId = `${NodeKind}:${string}`
|
|
7
|
+
|
|
8
|
+
export function nodeId(kind: NodeKind, name: string): NodeId {
|
|
9
|
+
return `${kind}:${name}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GraphIndex {
|
|
13
|
+
graph: Graph
|
|
14
|
+
nodesById: ReadonlyMap<NodeId, Node>
|
|
15
|
+
outgoing: ReadonlyMap<NodeId, Edge[]>
|
|
16
|
+
incoming: ReadonlyMap<NodeId, Edge[]>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function indexGraph(graph: Graph): GraphIndex {
|
|
20
|
+
const nodesById = new Map<NodeId, Node>()
|
|
21
|
+
const outgoing = new Map<NodeId, Edge[]>()
|
|
22
|
+
const incoming = new Map<NodeId, Edge[]>()
|
|
23
|
+
|
|
24
|
+
for (const node of graph.nodes) {
|
|
25
|
+
nodesById.set(nodeId(node.type, node.name), node)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const edge of graph.edges) {
|
|
29
|
+
const fromId = nodeId(edge.from.type, edge.from.name)
|
|
30
|
+
const toId = nodeId(edge.to.type, edge.to.name)
|
|
31
|
+
|
|
32
|
+
const outList = outgoing.get(fromId) ?? []
|
|
33
|
+
outList.push(edge)
|
|
34
|
+
outgoing.set(fromId, outList)
|
|
35
|
+
|
|
36
|
+
const inList = incoming.get(toId) ?? []
|
|
37
|
+
inList.push(edge)
|
|
38
|
+
incoming.set(toId, inList)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { graph, nodesById, outgoing, incoming }
|
|
42
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type Source = { file: string; start: number }
|
|
2
|
+
|
|
3
|
+
export type Node =
|
|
4
|
+
| {
|
|
5
|
+
type: "event"
|
|
6
|
+
name: string
|
|
7
|
+
meta: { alias: string; base: string }
|
|
8
|
+
source: Source
|
|
9
|
+
}
|
|
10
|
+
| { type: "command"; name: string; meta: { base: string }; source: Source }
|
|
11
|
+
| { type: "saga"; name: string; source: Source }
|
|
12
|
+
| { type: "aggregate"; name: string; source: Source }
|
|
13
|
+
| {
|
|
14
|
+
type: "projection"
|
|
15
|
+
name: string
|
|
16
|
+
meta: { alias: string }
|
|
17
|
+
source: Source
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type NodeKind = Node["type"]
|
|
21
|
+
|
|
22
|
+
export const NODE_KINDS = [
|
|
23
|
+
"event",
|
|
24
|
+
"command",
|
|
25
|
+
"saga",
|
|
26
|
+
"aggregate",
|
|
27
|
+
"projection",
|
|
28
|
+
] as const satisfies readonly NodeKind[]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Direction } from "./direction"
|
|
2
|
+
import type { GraphIndex, NodeId } from "./graph"
|
|
3
|
+
import type { Node } from "./node"
|
|
4
|
+
import { nodeId } from "./graph"
|
|
5
|
+
|
|
6
|
+
export function effectiveRoots(
|
|
7
|
+
index: GraphIndex,
|
|
8
|
+
direction: Direction
|
|
9
|
+
): NodeId[] {
|
|
10
|
+
return index.graph.nodes
|
|
11
|
+
.filter((n) => isRoot(n, direction))
|
|
12
|
+
.map((n) => nodeId(n.type, n.name))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isRoot(node: Node, direction: Direction): boolean {
|
|
16
|
+
if (direction === "forward") return node.type === "event"
|
|
17
|
+
return node.type !== "event"
|
|
18
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Edge } from "./edge"
|
|
2
|
+
import { type GraphIndex, nodeId, type NodeId } from "./graph"
|
|
3
|
+
import type { Node } from "./node"
|
|
4
|
+
import type { Direction } from "./direction"
|
|
5
|
+
|
|
6
|
+
export interface TraceNode {
|
|
7
|
+
id: NodeId
|
|
8
|
+
node: Node
|
|
9
|
+
edge: Edge | null
|
|
10
|
+
children: TraceNode[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function traceFrom(
|
|
14
|
+
index: GraphIndex,
|
|
15
|
+
rootId: NodeId,
|
|
16
|
+
direction: Direction = "forward",
|
|
17
|
+
maxDepth = 100
|
|
18
|
+
): TraceNode | null {
|
|
19
|
+
const root = index.nodesById.get(rootId)
|
|
20
|
+
if (!root) return null
|
|
21
|
+
return build(index, root, null, direction, new Set(), maxDepth)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function build(
|
|
25
|
+
index: GraphIndex,
|
|
26
|
+
node: Node,
|
|
27
|
+
edge: Edge | null,
|
|
28
|
+
direction: Direction,
|
|
29
|
+
seen: Set<NodeId>,
|
|
30
|
+
remaining: number
|
|
31
|
+
): TraceNode {
|
|
32
|
+
const id = nodeId(node.type, node.name)
|
|
33
|
+
if (remaining <= 0 || seen.has(id)) {
|
|
34
|
+
return { id, node, edge, children: [] }
|
|
35
|
+
}
|
|
36
|
+
const next = new Set(seen).add(id)
|
|
37
|
+
const edges = stepEdges(index, id, direction)
|
|
38
|
+
const children: TraceNode[] = []
|
|
39
|
+
for (const e of edges) {
|
|
40
|
+
const peer = peerOf(e, direction)
|
|
41
|
+
const peerId = nodeId(peer.type, peer.name)
|
|
42
|
+
const target = index.nodesById.get(peerId)
|
|
43
|
+
if (!target) continue
|
|
44
|
+
children.push(build(index, target, e, direction, next, remaining - 1))
|
|
45
|
+
}
|
|
46
|
+
return { id, node, edge, children }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stepEdges(
|
|
50
|
+
index: GraphIndex,
|
|
51
|
+
id: NodeId,
|
|
52
|
+
direction: Direction
|
|
53
|
+
): Edge[] {
|
|
54
|
+
if (direction === "forward") return index.outgoing.get(id) ?? []
|
|
55
|
+
return index.incoming.get(id) ?? []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function peerOf(edge: Edge, direction: Direction) {
|
|
59
|
+
return direction === "forward" ? edge.to : edge.from
|
|
60
|
+
}
|