dsh-model-everything 0.5.0 → 0.5.1

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 (62) hide show
  1. package/README.en-US.md +12 -0
  2. package/README.md +4 -0
  3. package/lib/client.js +2437 -216
  4. package/lib/render/channels.js +243 -0
  5. package/lib/render/collapse.js +12 -20
  6. package/lib/render/components.js +99 -0
  7. package/lib/render/coords.js +288 -0
  8. package/lib/render/cycle-break.js +147 -0
  9. package/lib/render/dummy.js +43 -0
  10. package/lib/render/from-mermaid.js +34 -10
  11. package/lib/render/from-plantuml.js +318 -46
  12. package/lib/render/layer.js +91 -0
  13. package/lib/render/layout.js +27 -11
  14. package/lib/render/network-simplex.js +272 -0
  15. package/lib/render/order.js +122 -0
  16. package/lib/render/paint.js +764 -67
  17. package/lib/render/place.js +393 -121
  18. package/lib/render/route.js +225 -0
  19. package/lib/render/svg.js +143 -21
  20. package/lib/render/symbol.js +108 -0
  21. package/lib/render/text.js +74 -0
  22. package/lib/render/wrap.js +87 -0
  23. package/lib/types/render/channels.d.ts +63 -0
  24. package/lib/types/render/components.d.ts +57 -0
  25. package/lib/types/render/coords.d.ts +64 -0
  26. package/lib/types/render/cycle-break.d.ts +45 -0
  27. package/lib/types/render/dummy.d.ts +32 -0
  28. package/lib/types/render/from-plantuml.d.ts +12 -6
  29. package/lib/types/render/layer.d.ts +28 -0
  30. package/lib/types/render/layout.d.ts +43 -11
  31. package/lib/types/render/model.d.ts +17 -0
  32. package/lib/types/render/network-simplex.d.ts +54 -0
  33. package/lib/types/render/order.d.ts +27 -0
  34. package/lib/types/render/paint.d.ts +42 -9
  35. package/lib/types/render/place.d.ts +35 -19
  36. package/lib/types/render/route.d.ts +77 -0
  37. package/lib/types/render/svg.d.ts +1 -1
  38. package/lib/types/render/symbol.d.ts +73 -0
  39. package/lib/types/render/text.d.ts +38 -0
  40. package/lib/types/render/wrap.d.ts +27 -0
  41. package/package.json +148 -148
  42. package/skills/model-everything-diagram/SKILL.md +84 -82
  43. package/src/render/channels.ts +309 -0
  44. package/src/render/collapse.ts +150 -155
  45. package/src/render/components.ts +126 -0
  46. package/src/render/coords.ts +328 -0
  47. package/src/render/cycle-break.ts +164 -0
  48. package/src/render/dummy.ts +65 -0
  49. package/src/render/from-mermaid.ts +261 -236
  50. package/src/render/from-plantuml.ts +469 -178
  51. package/src/render/layer.ts +98 -0
  52. package/src/render/layout.ts +101 -62
  53. package/src/render/model.ts +86 -67
  54. package/src/render/network-simplex.ts +290 -0
  55. package/src/render/order.ts +139 -0
  56. package/src/render/paint.ts +1006 -290
  57. package/src/render/place.ts +527 -234
  58. package/src/render/route.ts +269 -0
  59. package/src/render/svg.ts +270 -127
  60. package/src/render/symbol.ts +137 -0
  61. package/src/render/text.ts +86 -0
  62. package/src/render/wrap.ts +103 -0
@@ -1,234 +1,527 @@
1
- /**
2
- * Where the boxes go.
3
- *
4
- * Two placements, chosen by the source:
5
- *
6
- * - **layered** (no groups): breadth-first rows, centred, no containment - the shape a state machine wants.
7
- * - **tree** (groups present): a group is a block, its members stack inside it, a nested group is a block inside
8
- * its parent, and the top level stacks - the shape an architecture diagram wants, because a package whose
9
- * members float somewhere else on the page is not showing containment at all.
10
- *
11
- * Split out of the painter so the two questions stay separate: this module decides positions, `paint.ts` decides
12
- * what to draw between them. A change to edge routing cannot move a box, and a change to nesting cannot move a
13
- * line.
14
- */
15
-
16
- import { rankStates } from './layout.js'
17
- import type { Diagram, DiagramGroup, DiagramNode } from './model.js'
18
-
19
- /** Geometry of one placed box, before anything is drawn. */
20
- export type Box = { x: number; y: number; width: number; height: number }
21
-
22
- /** What a placement returns: every box, every drawn boundary, and how wide the content is. */
23
- export interface Placement {
24
- boxes: Map<string, Box>
25
- groupRects: Map<string, Box>
26
- width: number
27
- }
28
-
29
- /** Geometry constants shared by both placements. */
30
- export const MARGIN = 20
31
- const NODE_HEIGHT = 34
32
- const ROW_GAP = 58
33
- const COLUMN_GAP = 30
34
- const MIN_NODE_WIDTH = 88
35
- const MAX_NODE_WIDTH = 220
36
- const CHAR_WIDTH = 7.2
37
- /** A boundary's inner padding and the strip its name sits in. */
38
- const GROUP_PADDING = 10
39
- const GROUP_HEADER = 16
40
- /** Between two blocks stacked inside a boundary, and between two top-level blocks. */
41
- const BLOCK_GAP = 14
42
-
43
- /** A box wide enough for its label, clamped so a long id cannot dominate the page. */
44
- export const nodeWidth = (label: string): number => Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, label.length * CHAR_WIDTH + 26))
45
-
46
-
47
-
48
- /** The layered placement: breadth-first rows, centred, no containment. */
49
- function placeByRanks(diagram: Diagram, nodes: DiagramNode[], top: number): Placement {
50
- const ranked = rankStates(diagram.nodes, diagram.edges, diagram.init)
51
- const rows: string[][] = [...ranked.rows]
52
- if (ranked.unreachable.length > 0) rows.push(ranked.unreachable)
53
-
54
- const boxes = new Map<string, Box>()
55
- let widest = 0
56
- rows.forEach((row, index) => {
57
- const widths = row.map((id) => nodeWidth(id))
58
- widest = Math.max(widest, widths.reduce((total, width) => total + width, 0) + Math.max(0, row.length - 1) * COLUMN_GAP)
59
- let x = 0
60
- row.forEach((id, position) => {
61
- boxes.set(id, { x, y: top + index * (NODE_HEIGHT + ROW_GAP), width: widths[position], height: NODE_HEIGHT })
62
- x += widths[position] + COLUMN_GAP
63
- })
64
- })
65
- rows.forEach((row) => {
66
- const rowWidth = row.reduce((total, id) => total + (boxes.get(id) as Box).width, 0) + Math.max(0, row.length - 1) * COLUMN_GAP
67
- const offset = (widest - rowWidth) / 2
68
- for (const id of row) (boxes.get(id) as Box).x += offset + MARGIN
69
- })
70
- return { boxes, groupRects: new Map(), width: widest + MARGIN * 2 }
71
- }
72
-
73
- /**
74
- * Which group each id belongs to, and which group contains which.
75
- *
76
- * Membership is read from `members`; containment is read from `parent`, because only the parser knows where a
77
- * boundary was written. A model assembled by hand may omit `parent`, and then containment is inferred from
78
- * member sets - the tightest superset wins - which is the best such a model can offer.
79
- *
80
- * Guards, because a model is data and data can be wrong: a parent that does not exist is ignored, a group that
81
- * contains itself or closes a cycle is treated as a root, and a member claimed by two groups belongs to the
82
- * tightest one, so a nested boundary is not drawn around nothing.
83
- */
84
- function groupTree(groups: DiagramGroup[]): { owner: Map<string, string>; children: Map<string, string[]>; roots: string[] } {
85
- const byId = new Map(groups.map((group) => [group.id, group] as const))
86
-
87
- const owner = new Map<string, string>()
88
- const bySize = [...groups].sort((left, right) => left.members.length - right.members.length)
89
- for (const group of bySize) {
90
- for (const member of group.members) if (!owner.has(member)) owner.set(member, group.id)
91
- }
92
-
93
- const declared = (group: DiagramGroup): string | undefined => {
94
- const parent = group.parent
95
- if (parent === undefined || parent === group.id) return undefined
96
- return byId.has(parent) ? parent : undefined
97
- }
98
-
99
- const members = new Map(groups.map((group) => [group.id, new Set(group.members)] as const))
100
- const infer = (group: DiagramGroup): string | undefined => {
101
- let best: { id: string; size: number } | undefined
102
- for (const other of groups) {
103
- if (other.id === group.id) continue
104
- const outer = members.get(other.id) as Set<string>
105
- const inner = members.get(group.id) as Set<string>
106
- if (inner.size === 0 || inner.size >= outer.size) continue
107
- let contained = true
108
- for (const member of inner) {
109
- if (!outer.has(member)) {
110
- contained = false
111
- break
112
- }
113
- }
114
- if (contained && (best === undefined || outer.size < best.size)) best = { id: other.id, size: outer.size }
115
- }
116
- return best?.id
117
- }
118
-
119
- const parentOf = new Map<string, string>()
120
- for (const group of groups) {
121
- const parent = declared(group) ?? infer(group)
122
- if (parent !== undefined) parentOf.set(group.id, parent)
123
- }
124
- // A cycle would recurse forever: walk up from each group and drop the link that closes the loop.
125
- for (const group of groups) {
126
- const seen = new Set<string>([group.id])
127
- let cursor = parentOf.get(group.id)
128
- while (cursor !== undefined) {
129
- if (seen.has(cursor)) {
130
- parentOf.delete(cursor)
131
- break
132
- }
133
- seen.add(cursor)
134
- cursor = parentOf.get(cursor)
135
- }
136
- }
137
-
138
- const children = new Map<string, string[]>()
139
- const roots: string[] = []
140
- for (const group of groups) {
141
- const parent = parentOf.get(group.id)
142
- if (parent === undefined) roots.push(group.id)
143
- else children.set(parent, [...(children.get(parent) ?? []), group.id])
144
- }
145
- return { owner, children, roots }
146
- }
147
-
148
- interface Measured {
149
- width: number
150
- height: number
151
- }
152
-
153
- /** The tree placement: a boundary is a block, its contents stack inside it, the top level stacks. */
154
- function placeByGroups(diagram: Diagram, nodes: DiagramNode[], top: number): Placement {
155
- const groups = diagram.groups as DiagramGroup[]
156
- const byId = new Map(groups.map((group) => [group.id, group] as const))
157
- const { owner, children, roots } = groupTree(groups)
158
-
159
- // Nodes that belong to no group, and the first-seen order that keeps a source's own ordering visible.
160
- const order = new Map(nodes.map((node, index) => [node.id, index] as const))
161
- const loose = nodes.filter((node) => !owner.has(node.id)).map((node) => node.id)
162
-
163
- const nodeIds = new Set(nodes.map((node) => node.id))
164
- const rawContents = (groupId: string): string[] => {
165
- const group = byId.get(groupId) as DiagramGroup
166
- const own = [...new Set(group.members)].filter((member) => owner.get(member) === groupId && nodeIds.has(member))
167
- const nested = children.get(groupId) ?? []
168
- return [...own, ...nested]
169
- }
170
- // A boundary with nothing inside it is not drawn: an empty box claims a containment the source never stated.
171
- const contents = (groupId: string): string[] => rawContents(groupId).filter((child) => !byId.has(child) || rawContents(child).length > 0)
172
-
173
- const measure = (id: string): Measured => {
174
- if (!byId.has(id)) return { width: nodeWidth(id), height: NODE_HEIGHT }
175
- const items = contents(id)
176
- const sizes = items.map(measure)
177
- return {
178
- width: Math.max(MIN_NODE_WIDTH, ...sizes.map((size) => size.width)) + GROUP_PADDING * 2,
179
- height: GROUP_HEADER + GROUP_PADDING * 2 + sizes.reduce((total, size) => total + size.height, 0) + Math.max(0, items.length - 1) * BLOCK_GAP,
180
- }
181
- }
182
-
183
- const boxes = new Map<string, Box>()
184
- const groupRects = new Map<string, Box>()
185
-
186
- const place = (id: string, x: number, y: number): Measured => {
187
- const size = measure(id)
188
- if (!byId.has(id)) {
189
- boxes.set(id, { x, y, width: size.width, height: NODE_HEIGHT })
190
- return size
191
- }
192
- groupRects.set(id, { x, y, width: size.width, height: size.height })
193
- let cursor = y + GROUP_HEADER + GROUP_PADDING
194
- const inner = size.width - GROUP_PADDING * 2
195
- const ordered = [...contents(id)].sort((left, right) => firstIndex(left) - firstIndex(right))
196
- for (const child of ordered) {
197
- const childSize = measure(child)
198
- place(child, x + GROUP_PADDING + (inner - childSize.width) / 2, cursor)
199
- cursor += childSize.height + BLOCK_GAP
200
- }
201
- return size
202
- }
203
-
204
- // Top level: loose nodes and root groups, in the order the source named them.
205
- const visibleRoots = roots.filter((id) => contents(id).length > 0)
206
- // A block takes the earliest position of anything inside it, so a group is drawn where its first member was
207
- // named - not after every loose node, which put a downstream box above the packages it depends on.
208
- const firstIndex = (id: string): number => {
209
- // `order` holds every node, and a block is only ever asked about when it has contents.
210
- if (!byId.has(id)) return order.get(id) as number
211
- return Math.min(...contents(id).map(firstIndex))
212
- }
213
- const topItems = [...loose, ...visibleRoots].sort((left, right) => firstIndex(left) - firstIndex(right))
214
- const sizes = topItems.map(measure)
215
- const width = Math.max(MIN_NODE_WIDTH, ...sizes.map((size) => size.width))
216
- let cursor = top
217
- for (const item of topItems) {
218
- const size = measure(item)
219
- place(item, MARGIN + (width - size.width) / 2, cursor)
220
- cursor += size.height + BLOCK_GAP
221
- }
222
- return { boxes, groupRects, width: width + MARGIN * 2 }
223
- }
224
-
225
- /**
226
- * Place every box of a diagram.
227
- *
228
- * The choice of placement is a property of the source, not of the caller: a drawing that declares boundaries is
229
- * read as containers and gets the tree; one that does not is read as a machine and gets the ranks.
230
- */
231
- export function place(diagram: Diagram, top: number): Placement {
232
- const groups = diagram.groups ?? []
233
- return groups.length > 0 ? placeByGroups(diagram, diagram.nodes, top) : placeByRanks(diagram, diagram.nodes, top)
234
- }
1
+ /**
2
+ * Where the boxes go.
3
+ *
4
+ * The drawing is placed by the layered pipeline, in the order the mature implementations run it: cycle breaking turns
5
+ * the graph into a DAG, layering gives every box a row, dummy nodes give every long edge a lane, ordering reduces the
6
+ * crossings, and coordinate assignment decides the x of each lane and box. This module composes those stages; it does
7
+ * not decide any of them itself. Each stage is a pure module next door, so it can be asserted without a canvas.
8
+ *
9
+ * Two decompositions sit around the pipeline. A level - the top of the drawing, or the inside of a boundary - is
10
+ * split into weakly connected components, each laid out on its own and then packed onto the page, so a wide
11
+ * subgraph cannot squeeze an unrelated one. A boundary is a node in its parent's layer whose size is whatever its
12
+ * contents need, and its inside is laid out by the same pipeline; that is how nesting is drawn.
13
+ *
14
+ * What comes out is geometry only: a box per node, a rectangle per boundary, and - for a long edge - the lane its
15
+ * dummies reserved, so the painter can draw one polyline through it instead of searching around the boxes.
16
+ */
17
+
18
+ import { aspectOf, packRectangles, weakComponents } from './components.js'
19
+ import { assignChannels } from './channels.js'
20
+ import { assignCoordinates } from './coords.js'
21
+ import { breakCycles } from './cycle-break.js'
22
+ import { addDummies } from './dummy.js'
23
+ import { layer } from './layer.js'
24
+ import { seedOrder, type LayoutEdge, type LayoutGraph } from './layout.js'
25
+ import { order } from './order.js'
26
+ import { measure, MIN_NODE_WIDTH, type Measured as TextBox } from './text.js'
27
+ import { planBands } from './wrap.js'
28
+ import type { Diagram, DiagramEdge, DiagramGroup, DiagramNode } from './model.js'
29
+
30
+ /** Geometry of one placed box, before anything is drawn. */
31
+ export type Box = { x: number; y: number; width: number; height: number }
32
+
33
+ /** A point in the drawing's own coordinates. */
34
+ export interface Point {
35
+ x: number
36
+ y: number
37
+ }
38
+
39
+ /** The lane a long edge's dummies reserved, in layout order: from the layout source down to the layout target. */
40
+ export interface Lane {
41
+ /** One point per gap the edge crosses, at the lane's own x: it keeps its own order, and the painter walks it. */
42
+ points: Point[]
43
+ /** True when the layout runs opposite to the source's arrow, so the painter walks the lane backwards. */
44
+ reversed: boolean
45
+ }
46
+
47
+ /** What a placement returns: every box, every drawn boundary, every reserved lane, and how wide the content is. */
48
+ export interface Placement {
49
+ boxes: Map<string, Box>
50
+ groupRects: Map<string, Box>
51
+ width: number
52
+ lanes: Map<number, Lane>
53
+ /** The height of the one horizontal run an edge between two adjacent rows needs. */
54
+ runs: Map<number, number>
55
+ /** Edges whose two ends ended up in different wrapped columns: they are read across, not down. */
56
+ across: Set<number>
57
+ }
58
+
59
+ /** Geometry constants in one place: a container with different proportions changes these, not the rules. */
60
+ export const MARGIN = 20
61
+ /** The strip between two layers: room for a route and, when the level has labels, for one to sit in. */
62
+ const LAYER_GAP = 44
63
+ const LABELLED_LAYER_GAP = 54
64
+ /** The space between two boxes in one layer. */
65
+ const NODE_GAP = 30
66
+ /** How far a lane keeps from a box in a row it crosses, and the least two lanes in one gap may share. */
67
+ const LANE_CLEARANCE = 12
68
+ const LANE_SPACING = 14
69
+ /** Two components are two drawings on one page, so they get a wider gap than two boxes. */
70
+ const COMPONENT_GAP = 30
71
+ /** A boundary's inner padding and the strip its name sits in. */
72
+ const GROUP_PADDING = 10
73
+ const GROUP_HEADER = 16
74
+
75
+ /** The text a node will draw, measured from its label - the id is what the source called it, not what it says. */
76
+ export function textOf(node: DiagramNode): TextBox {
77
+ return measure(node.label !== undefined && node.label !== '' ? node.label : node.id)
78
+ }
79
+
80
+ /** One thing placed at a level: a box, or a boundary whose size came from its own contents. */
81
+ interface Entity {
82
+ id: string
83
+ group: boolean
84
+ width: number
85
+ height: number
86
+ }
87
+
88
+ /** The geometry of one level (the page, or one boundary's inside) relative to its own top-left corner. */
89
+ interface LevelLayout {
90
+ width: number
91
+ height: number
92
+ items: Map<string, { box: Box; group: boolean }>
93
+ lanes: Map<number, Lane>
94
+ runs: Map<number, number>
95
+ across: Set<number>
96
+ }
97
+
98
+ /** Which group each id belongs to, and which group contains which. */
99
+ interface GroupTree {
100
+ /** The group a node is drawn inside, when it is drawn inside one at all. */
101
+ owner: Map<string, string>
102
+ /** The group a group is drawn inside. */
103
+ parentOf: Map<string, string>
104
+ children: Map<string, string[]>
105
+ roots: string[]
106
+ }
107
+
108
+ /**
109
+ * Read membership and containment off the model, which is data and can be wrong.
110
+ *
111
+ * Membership is read from `members`; containment from `parent`, because only the parser knows where a boundary was
112
+ * written - a hand-written model may omit it, and then the tightest member-set that contains this one is the best
113
+ * guess available. Guards, because a model is data: a parent that does not exist is ignored, a group that contains
114
+ * itself or closes a cycle is treated as a root, and a member claimed by two groups belongs to the tightest one.
115
+ */
116
+ function groupTree(groups: DiagramGroup[]): GroupTree {
117
+ const byId = new Map(groups.map((group) => [group.id, group] as const))
118
+
119
+ const owner = new Map<string, string>()
120
+ const bySize = [...groups].sort((left, right) => left.members.length - right.members.length)
121
+ for (const group of bySize) {
122
+ for (const member of group.members) if (!owner.has(member)) owner.set(member, group.id)
123
+ }
124
+
125
+ const declared = (group: DiagramGroup): string | undefined => {
126
+ const parent = group.parent
127
+ if (parent === undefined || parent === group.id) return undefined
128
+ return byId.has(parent) ? parent : undefined
129
+ }
130
+
131
+ const members = new Map(groups.map((group) => [group.id, new Set(group.members)] as const))
132
+ const infer = (group: DiagramGroup): string | undefined => {
133
+ let best: { id: string; size: number } | undefined
134
+ for (const other of groups) {
135
+ if (other.id === group.id) continue
136
+ const outer = members.get(other.id) as Set<string>
137
+ const inner = members.get(group.id) as Set<string>
138
+ if (inner.size === 0 || inner.size >= outer.size) continue
139
+ let contained = true
140
+ for (const member of inner) {
141
+ if (!outer.has(member)) {
142
+ contained = false
143
+ break
144
+ }
145
+ }
146
+ if (contained && (best === undefined || outer.size < best.size)) best = { id: other.id, size: outer.size }
147
+ }
148
+ return best?.id
149
+ }
150
+
151
+ const parentOf = new Map<string, string>()
152
+ for (const group of groups) {
153
+ const parent = declared(group) ?? infer(group)
154
+ if (parent !== undefined) parentOf.set(group.id, parent)
155
+ }
156
+ // A cycle would recurse forever: walk up from each group and drop the link that closes the loop.
157
+ for (const group of groups) {
158
+ const seen = new Set<string>([group.id])
159
+ let cursor = parentOf.get(group.id)
160
+ while (cursor !== undefined) {
161
+ if (seen.has(cursor)) {
162
+ parentOf.delete(cursor)
163
+ break
164
+ }
165
+ seen.add(cursor)
166
+ cursor = parentOf.get(cursor)
167
+ }
168
+ }
169
+
170
+ const children = new Map<string, string[]>()
171
+ const roots: string[] = []
172
+ for (const group of groups) {
173
+ const parent = parentOf.get(group.id)
174
+ if (parent === undefined) roots.push(group.id)
175
+ else children.set(parent, [...(children.get(parent) ?? []), group.id])
176
+ }
177
+ return { owner, parentOf, children, roots }
178
+ }
179
+
180
+ /** One weakly connected component of a level, placed on its own. */
181
+ type Piece = LevelLayout
182
+
183
+ export function place(diagram: Diagram, top: number): Placement {
184
+ const groups = diagram.groups ?? []
185
+ const nodes: DiagramNode[] = []
186
+ const seenNode = new Set<string>()
187
+ for (const node of diagram.nodes) {
188
+ if (seenNode.has(node.id)) continue
189
+ seenNode.add(node.id)
190
+ nodes.push(node)
191
+ }
192
+ const nodeById = new Map(nodes.map((node) => [node.id, node] as const))
193
+ const groupById = new Map(groups.map((group) => [group.id, group] as const))
194
+ const tree = groupTree(groups)
195
+
196
+ // The order the source wrote things in: the fallback order wherever connectivity says nothing.
197
+ const appearance = new Map<string, number>()
198
+ for (const node of nodes) if (!appearance.has(node.id)) appearance.set(node.id, appearance.size)
199
+ for (const group of groups) if (!appearance.has(group.id)) appearance.set(group.id, appearance.size)
200
+
201
+ const visible = new Set<string>()
202
+ const asked = new Map<string, boolean>()
203
+ const isVisible = (id: string): boolean => {
204
+ const known = asked.get(id)
205
+ if (known !== undefined) return known
206
+ const group = groupById.get(id) as DiagramGroup
207
+ let found = false
208
+ for (const member of group.members) {
209
+ if (tree.owner.get(member) === id && nodeById.has(member)) {
210
+ found = true
211
+ break
212
+ }
213
+ }
214
+ if (!found) {
215
+ for (const child of tree.children.get(id) ?? []) {
216
+ if (isVisible(child)) {
217
+ found = true
218
+ break
219
+ }
220
+ }
221
+ }
222
+ asked.set(id, found)
223
+ if (found) visible.add(id)
224
+ return found
225
+ }
226
+ // Every group is asked, not only the roots: a nested group's visibility is what tells its parent it has contents.
227
+ for (const group of groups) isVisible(group.id)
228
+
229
+ const directNodes = (scope: string): string[] => nodes.filter((node) => tree.owner.get(node.id) === scope).map((node) => node.id)
230
+ const directGroups = (scope: string): string[] => (tree.children.get(scope) ?? []).filter((child) => visible.has(child))
231
+
232
+ const firstIndexCache = new Map<string, number>()
233
+ const firstIndexOf = (id: string): number => {
234
+ const cached = firstIndexCache.get(id)
235
+ if (cached !== undefined) return cached
236
+ if (nodeById.has(id)) return appearance.get(id) as number
237
+ const members = (groupById.get(id) as DiagramGroup).members
238
+ .filter((member) => tree.owner.get(member) === id && nodeById.has(member))
239
+ .map((member) => appearance.get(member) as number)
240
+ const children = directGroups(id).map(firstIndexOf)
241
+ const all = [...members, ...children]
242
+ const found = Math.min(...all)
243
+ firstIndexCache.set(id, found)
244
+ return found
245
+ }
246
+
247
+ /** Which group or node a diagram node is drawn as, at one level; `undefined` when it is outside that level. */
248
+ const entityAt = (scope: string | undefined, id: string): string | undefined => {
249
+ // An edge may name a boundary instead of a box: it attaches to the boundary's own rectangle. A group is
250
+ // an entity at the level it is drawn in, and a nested group belongs to its outermost drawn ancestor there.
251
+ const boundary = groupById.get(id)
252
+ if (boundary !== undefined) {
253
+ if (!visible.has(id)) return undefined
254
+ if (scope === undefined) {
255
+ let cursor = id
256
+ while (tree.parentOf.has(cursor)) cursor = tree.parentOf.get(cursor) as string
257
+ return cursor
258
+ }
259
+ return tree.parentOf.get(id) === scope ? id : undefined
260
+ }
261
+ if (!nodeById.has(id)) return undefined
262
+ const group = tree.owner.get(id)
263
+ if (scope === undefined) {
264
+ if (group === undefined || !visible.has(group)) return id
265
+ let cursor = group
266
+ while (tree.parentOf.has(cursor)) cursor = tree.parentOf.get(cursor) as string
267
+ return cursor
268
+ }
269
+ if (group === scope) return id
270
+ if (group === undefined) return undefined
271
+ return tree.parentOf.get(group) === scope && visible.has(group) ? group : undefined
272
+ }
273
+
274
+ const groupLayouts = new Map<string, LevelLayout>()
275
+ const measureGroup = (id: string): { width: number; height: number } => {
276
+ const inside = buildLevel(id)
277
+ const size = {
278
+ width: Math.max(MIN_NODE_WIDTH, inside.width) + GROUP_PADDING * 2,
279
+ height: inside.height + GROUP_HEADER + GROUP_PADDING * 2,
280
+ }
281
+ groupLayouts.set(id, inside)
282
+ return size
283
+ }
284
+
285
+ const entityOf = (id: string): Entity => {
286
+ const node = nodeById.get(id)
287
+ if (node !== undefined) {
288
+ const text = textOf(node)
289
+ return { id, group: false, width: text.width, height: text.height }
290
+ }
291
+ const size = measureGroup(id)
292
+ return { id, group: true, width: size.width, height: size.height }
293
+ }
294
+
295
+ const entitiesIn = (scope: string | undefined): Entity[] => {
296
+ const found: Entity[] = []
297
+ if (scope === undefined) {
298
+ for (const node of nodes) {
299
+ const group = tree.owner.get(node.id)
300
+ if (group === undefined || !visible.has(group)) found.push(entityOf(node.id))
301
+ }
302
+ for (const root of tree.roots) if (visible.has(root)) found.push(entityOf(root))
303
+ } else {
304
+ for (const id of directNodes(scope)) found.push(entityOf(id))
305
+ for (const id of directGroups(scope)) found.push(entityOf(id))
306
+ }
307
+ return found.sort((left, right) => firstIndexOf(left.id) - firstIndexOf(right.id))
308
+ }
309
+
310
+ const edgesIn = (scope: string | undefined): LayoutEdge[] => {
311
+ const found: LayoutEdge[] = []
312
+ diagram.edges.forEach((edge, index) => {
313
+ if (edge.from === edge.to) return
314
+ const from = entityAt(scope, edge.from)
315
+ const to = entityAt(scope, edge.to)
316
+ if (from === undefined || to === undefined || from === to) return
317
+ found.push({ index, from, to })
318
+ })
319
+ return found
320
+ }
321
+
322
+ /** The entity that holds the initial state at this level, which is where layer zero is pinned. */
323
+ const pinnedAt = (scope: string | undefined): string | undefined => {
324
+ if (diagram.init === undefined) return undefined
325
+ const entity = entityAt(scope, diagram.init)
326
+ return entity === diagram.init || (entity !== undefined && groupById.has(entity)) ? entity : undefined
327
+ }
328
+
329
+ /**
330
+ * Lay out one weakly connected component: the pipeline, unshortened.
331
+ *
332
+ * A component has its own layers, its own ordering and its own coordinates. Nothing outside it can move it.
333
+ */
334
+ const layoutPiece = (entities: Entity[], edges: LayoutEdge[], pinned: string | undefined, seed: Map<string, number>): Piece => {
335
+ const byId = new Map(entities.map((entity) => [entity.id, entity] as const))
336
+ const graph: LayoutGraph = {
337
+ nodes: entities.map((entity) => ({ id: entity.id, width: entity.width, height: entity.height })),
338
+ edges,
339
+ ...(pinned === undefined ? {} : { init: pinned }),
340
+ }
341
+ const broken = breakCycles(graph)
342
+ const layered = layer(graph, broken.edges)
343
+ const augmented = addDummies(layered, broken.edges)
344
+ const ordered = order(augmented.layers, augmented.chains, seed)
345
+ const gap = edges.some((edge) => (diagram.edges[edge.index] as DiagramEdge).label !== '') ? LABELLED_LAYER_GAP : LAYER_GAP
346
+ const coordinates = assignCoordinates(
347
+ {
348
+ layers: ordered.layers,
349
+ chains: augmented.chains,
350
+ sizeOf: (id) => {
351
+ const entity = byId.get(id) as Entity
352
+ return { width: entity.width, height: entity.height }
353
+ },
354
+ chainOf: augmented.chainOf,
355
+ },
356
+ { gap: NODE_GAP, layerGap: gap },
357
+ )
358
+ const bands = planBands(coordinates.layerWidth, coordinates.layerHeight, gap)
359
+ const bandOfLayer = new Array<number>(coordinates.layerHeight.length).fill(0)
360
+ bands.bands.forEach((band, index) => {
361
+ for (let depth = band.first; depth <= band.last; depth += 1) bandOfLayer[depth] = index
362
+ })
363
+ const layerTop: number[] = []
364
+ for (const band of bands.bands) {
365
+ let y = 0
366
+ for (let depth = band.first; depth <= band.last; depth += 1) {
367
+ layerTop[depth] = y
368
+ y += coordinates.layerHeight[depth] + gap
369
+ }
370
+ }
371
+
372
+ const items = new Map<string, { box: Box; group: boolean }>()
373
+ ordered.layers.forEach((row, depth) => {
374
+ const shift = bands.bands[bandOfLayer[depth]].offsetX
375
+ const top = layerTop[depth]
376
+ const rowHeight = coordinates.layerHeight[depth]
377
+ for (const id of row) {
378
+ const entity = byId.get(id)
379
+ if (entity === undefined) continue
380
+ const centre = coordinates.x.get(id) as number
381
+ items.set(id, {
382
+ box: {
383
+ x: centre + shift - entity.width / 2,
384
+ y: top + (rowHeight - entity.height) / 2,
385
+ width: entity.width,
386
+ height: entity.height,
387
+ },
388
+ group: entity.group,
389
+ })
390
+ }
391
+ })
392
+
393
+ // An edge whose ends sit in two different columns is read across the page, not down it: the painter draws it
394
+ // out of the side of one box and into the side of the other, which is where a wrapped reading order turns.
395
+ const across = new Set<number>()
396
+ for (const edge of edges) {
397
+ const from = layered.layerOf.get(edge.from) as number
398
+ const to = layered.layerOf.get(edge.to) as number
399
+ if (bandOfLayer[from] !== bandOfLayer[to]) across.add(edge.index)
400
+ }
401
+
402
+ // The channels: where each long edge's lane runs and where the runs between two rows sit. A lane is only worth
403
+ // reserving when both ends are boxes - a lane that ends on a boundary has no box to arrive at - and one that
404
+ // crosses a band boundary is two lanes with different column offsets. Those edges get the router instead.
405
+ const laneProposal = new Map<string, number>()
406
+ for (const chain of augmented.chains) {
407
+ if (chain.path.length <= 2) continue
408
+ const lane = augmented.chainOf.get(chain.path[1]) as string
409
+ const fromLayer = layered.layerOf.get(chain.path[0]) as number
410
+ const proposed = coordinates.x.get(lane) as number
411
+ laneProposal.set(lane, proposed + bands.bands[bandOfLayer[fromLayer]].offsetX)
412
+ }
413
+ const itemBoxes = new Map<string, Box>()
414
+ for (const [id, item] of items) itemBoxes.set(id, item.box)
415
+ const channels = assignChannels(
416
+ {
417
+ layers: ordered.layers,
418
+ chains: augmented.chains,
419
+ chainOf: augmented.chainOf,
420
+ boxes: itemBoxes,
421
+ layerY: layerTop,
422
+ layerHeight: coordinates.layerHeight,
423
+ laneX: laneProposal,
424
+ },
425
+ { clearance: LANE_CLEARANCE, spacing: LANE_SPACING },
426
+ )
427
+ const lanes = new Map<number, Lane>()
428
+ for (const chain of augmented.chains) {
429
+ if (chain.path.length <= 2) continue
430
+ const start = byId.get(chain.path[0]) as Entity
431
+ const end = byId.get(chain.path[chain.path.length - 1]) as Entity
432
+ if (start.group || end.group) continue
433
+ const fromLayer = layered.layerOf.get(chain.path[0]) as number
434
+ const toLayer = layered.layerOf.get(chain.path[chain.path.length - 1]) as number
435
+ if (bandOfLayer[fromLayer] !== bandOfLayer[toLayer]) continue
436
+ const channel = channels.lanes.get(chain.index) as { x: number; y: number[] }
437
+ lanes.set(chain.index, {
438
+ points: channel.y.map((y) => ({ x: channel.x, y })),
439
+ reversed: broken.reversed.has(chain.index),
440
+ })
441
+ }
442
+ const runs = new Map<number, number>()
443
+ for (const [edgeIndex, y] of channels.runs) if (!across.has(edgeIndex)) runs.set(edgeIndex, y)
444
+
445
+ let right = 0
446
+ let bottom = 0
447
+ for (const entry of items.values()) {
448
+ right = Math.max(right, entry.box.x + entry.box.width)
449
+ bottom = Math.max(bottom, entry.box.y + entry.box.height)
450
+ }
451
+ return { width: right, height: bottom, items, lanes, runs, across }
452
+ }
453
+
454
+ function buildLevel(scope: string | undefined): LevelLayout {
455
+ const entities = entitiesIn(scope)
456
+ if (entities.length === 0) {
457
+ return { width: 0, height: 0, items: new Map(), lanes: new Map(), runs: new Map(), across: new Set() }
458
+ }
459
+ const edges = edgesIn(scope)
460
+ const pinned = pinnedAt(scope)
461
+ const seed = seedOrder({ nodes: entities.map((entity) => ({ id: entity.id, width: entity.width, height: entity.height })), edges, ...(pinned === undefined ? {} : { init: pinned }) })
462
+ const components = weakComponents(entities, edges)
463
+ const pieces: Piece[] = []
464
+ for (const members of components) {
465
+ const inside = new Set(members)
466
+ const pieceEntities = entities.filter((entity) => inside.has(entity.id))
467
+ const pieceEdges = edges.filter((edge) => inside.has(edge.from) && inside.has(edge.to))
468
+ const pin = pinned !== undefined && inside.has(pinned) ? pinned : undefined
469
+ pieces.push(layoutPiece(pieceEntities, pieceEdges, pin, seed))
470
+ }
471
+ const packed = packRectangles(pieces.map((piece) => ({ width: piece.width, height: piece.height })), { gap: COMPONENT_GAP })
472
+ const layout: LevelLayout = { width: packed.width, height: packed.height, items: new Map(), lanes: new Map(), runs: new Map(), across: new Set() }
473
+ pieces.forEach((piece, index) => {
474
+ for (const edgeIndex of piece.across) layout.across.add(edgeIndex)
475
+ const at = packed.placements[index]
476
+ for (const [id, item] of piece.items) {
477
+ layout.items.set(id, { box: { x: item.box.x + at.x, y: item.box.y + at.y, width: item.box.width, height: item.box.height }, group: item.group })
478
+ }
479
+ for (const [edgeIndex, lane] of piece.lanes) {
480
+ layout.lanes.set(edgeIndex, { points: lane.points.map((point) => ({ x: point.x + at.x, y: point.y + at.y })), reversed: lane.reversed })
481
+ }
482
+ for (const [edgeIndex, y] of piece.runs) layout.runs.set(edgeIndex, y + at.y)
483
+ })
484
+ return layout
485
+ }
486
+
487
+ const root = buildLevel(undefined)
488
+ const boxes = new Map<string, Box>()
489
+ const groupRects = new Map<string, Box>()
490
+ const lanes = new Map<number, Lane>()
491
+ const runs = new Map<number, number>()
492
+ const across = new Set<number>()
493
+ const placeLevel = (layout: LevelLayout, originX: number, originY: number): void => {
494
+ for (const edgeIndex of layout.across) across.add(edgeIndex)
495
+ for (const [id, item] of layout.items) {
496
+ const box = { x: originX + item.box.x, y: originY + item.box.y, width: item.box.width, height: item.box.height }
497
+ if (item.group) {
498
+ groupRects.set(id, box)
499
+ const inside = groupLayouts.get(id) as LevelLayout
500
+ placeLevel(inside, box.x + (box.width - inside.width) / 2, box.y + GROUP_HEADER + GROUP_PADDING)
501
+ continue
502
+ }
503
+ boxes.set(id, box)
504
+ }
505
+ for (const [edgeIndex, lane] of layout.lanes) {
506
+ lanes.set(edgeIndex, { points: lane.points.map((point) => ({ x: point.x + originX, y: point.y + originY })), reversed: lane.reversed })
507
+ }
508
+ for (const [edgeIndex, y] of layout.runs) runs.set(edgeIndex, y + originY)
509
+ }
510
+ placeLevel(root, MARGIN, top)
511
+
512
+ let right = MARGIN
513
+ for (const box of boxes.values()) right = Math.max(right, box.x + box.width)
514
+ for (const box of groupRects.values()) right = Math.max(right, box.x + box.width)
515
+ return { boxes, groupRects, width: right + MARGIN, lanes, runs, across }
516
+ }
517
+
518
+ /** The aspect ratio of a placement, for the tests that check a drawing is a page and not a strip. */
519
+ export function placementAspect(placement: Placement): number {
520
+ let right = 0
521
+ let bottom = 0
522
+ for (const box of [...placement.boxes.values(), ...placement.groupRects.values()]) {
523
+ right = Math.max(right, box.x + box.width)
524
+ bottom = Math.max(bottom, box.y + box.height)
525
+ }
526
+ return aspectOf(right, bottom)
527
+ }