@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,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
+ }