dsh-model-everything 0.4.1 → 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 (114) hide show
  1. package/README.en-US.md +75 -19
  2. package/README.md +377 -345
  3. package/cordis.patch.yml +9 -0
  4. package/diagram/package.json +5 -0
  5. package/lib/client.js +3494 -0
  6. package/lib/diagram-diff-tool.js +90 -0
  7. package/lib/diagram-render-tool.js +102 -0
  8. package/lib/diagram-review-tool.js +99 -0
  9. package/lib/diagram.js +43 -0
  10. package/lib/gate.js +14 -12
  11. package/lib/presentation.js +10 -0
  12. package/lib/render/budget.js +99 -0
  13. package/lib/render/channels.js +243 -0
  14. package/lib/render/collapse.js +155 -0
  15. package/lib/render/components.js +99 -0
  16. package/lib/render/coords.js +288 -0
  17. package/lib/render/cycle-break.js +147 -0
  18. package/lib/render/diff.js +138 -0
  19. package/lib/render/dummy.js +43 -0
  20. package/lib/render/from-mermaid.js +235 -0
  21. package/lib/render/from-plantuml.js +430 -0
  22. package/lib/render/index.js +42 -0
  23. package/lib/render/layer.js +91 -0
  24. package/lib/render/layout.js +68 -0
  25. package/lib/render/model.js +17 -0
  26. package/lib/render/network-simplex.js +272 -0
  27. package/lib/render/order.js +122 -0
  28. package/lib/render/paint.js +891 -0
  29. package/lib/render/palette.js +127 -0
  30. package/lib/render/place.js +482 -0
  31. package/lib/render/route.js +225 -0
  32. package/lib/render/svg.js +229 -0
  33. package/lib/render/symbol.js +108 -0
  34. package/lib/render/text.js +74 -0
  35. package/lib/render/viewmodel.js +126 -0
  36. package/lib/render/wrap.js +87 -0
  37. package/lib/render/write.js +14 -0
  38. package/lib/scan.js +248 -8
  39. package/lib/structure-tool.js +2 -2
  40. package/lib/types/client.d.ts +83 -0
  41. package/lib/types/diagram-diff-tool.d.ts +8 -0
  42. package/lib/types/diagram-render-tool.d.ts +8 -0
  43. package/lib/types/diagram-review-tool.d.ts +8 -0
  44. package/lib/types/diagram.d.ts +34 -0
  45. package/lib/types/presentation.d.ts +7 -0
  46. package/lib/types/render/budget.d.ts +55 -0
  47. package/lib/types/render/channels.d.ts +63 -0
  48. package/lib/types/render/collapse.d.ts +23 -0
  49. package/lib/types/render/components.d.ts +57 -0
  50. package/lib/types/render/coords.d.ts +64 -0
  51. package/lib/types/render/cycle-break.d.ts +45 -0
  52. package/lib/types/render/diff.d.ts +44 -0
  53. package/lib/types/render/dummy.d.ts +32 -0
  54. package/lib/types/render/from-mermaid.d.ts +27 -0
  55. package/lib/types/render/from-plantuml.d.ts +33 -0
  56. package/lib/types/render/index.d.ts +38 -0
  57. package/lib/types/render/layer.d.ts +28 -0
  58. package/lib/types/render/layout.d.ts +54 -0
  59. package/lib/types/render/model.d.ts +73 -0
  60. package/lib/types/render/network-simplex.d.ts +54 -0
  61. package/lib/types/render/order.d.ts +27 -0
  62. package/lib/types/render/paint.d.ts +127 -0
  63. package/lib/types/render/palette.d.ts +41 -0
  64. package/lib/types/render/place.d.ts +55 -0
  65. package/lib/types/render/route.d.ts +77 -0
  66. package/lib/types/render/svg.d.ts +19 -0
  67. package/lib/types/render/symbol.d.ts +73 -0
  68. package/lib/types/render/text.d.ts +38 -0
  69. package/lib/types/render/viewmodel.d.ts +20 -0
  70. package/lib/types/render/wrap.d.ts +27 -0
  71. package/lib/types/render/write.d.ts +14 -0
  72. package/lib/types/scan.d.ts +10 -2
  73. package/lib/types/structure.d.ts +1 -0
  74. package/package.json +148 -132
  75. package/skills/model-everything/SKILL.md +454 -454
  76. package/skills/model-everything/references/architecture-domains.md +137 -0
  77. package/skills/model-everything/references/architecture-views.md +119 -0
  78. package/skills/model-everything-diagram/SKILL.md +84 -0
  79. package/src/client.ts +307 -0
  80. package/src/diagram-diff-tool.ts +94 -0
  81. package/src/diagram-render-tool.ts +108 -0
  82. package/src/diagram-review-tool.ts +109 -0
  83. package/src/diagram.ts +61 -0
  84. package/src/gate.ts +14 -12
  85. package/src/presentation.ts +12 -0
  86. package/src/render/budget.ts +135 -0
  87. package/src/render/channels.ts +309 -0
  88. package/src/render/collapse.ts +150 -0
  89. package/src/render/components.ts +126 -0
  90. package/src/render/coords.ts +328 -0
  91. package/src/render/cycle-break.ts +164 -0
  92. package/src/render/diff.ts +162 -0
  93. package/src/render/dummy.ts +65 -0
  94. package/src/render/from-mermaid.ts +261 -0
  95. package/src/render/from-plantuml.ts +469 -0
  96. package/src/render/index.ts +73 -0
  97. package/src/render/layer.ts +98 -0
  98. package/src/render/layout.ts +101 -0
  99. package/src/render/model.ts +86 -0
  100. package/src/render/network-simplex.ts +290 -0
  101. package/src/render/order.ts +139 -0
  102. package/src/render/paint.ts +1006 -0
  103. package/src/render/palette.ts +164 -0
  104. package/src/render/place.ts +527 -0
  105. package/src/render/route.ts +269 -0
  106. package/src/render/svg.ts +270 -0
  107. package/src/render/symbol.ts +137 -0
  108. package/src/render/text.ts +86 -0
  109. package/src/render/viewmodel.ts +136 -0
  110. package/src/render/wrap.ts +103 -0
  111. package/src/render/write.ts +17 -0
  112. package/src/scan.ts +807 -564
  113. package/src/structure-tool.ts +2 -2
  114. package/src/structure.ts +1 -1
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Verdict to colour, in the two surfaces the page can be in, and in the scheme a reader prefers.
3
+ *
4
+ * A drawing is read before it is parsed, so the first thing it says is the answer. The roles are named rather
5
+ * than coloured: a scheme maps a role to a value, and every scheme is chosen so the drawing survives both a
6
+ * light and a dark page - the reader never has to fix a palette to read a result.
7
+ *
8
+ * The mode is read at render time from the page's own background (see `client.ts`). The host publishes its
9
+ * palette as CSS variables whose names are private to it, and a drawing that hard-coded them would break on
10
+ * the next theme; the luminance of the surface is the one fact that cannot drift.
11
+ *
12
+ * Pure, and tested: a wrong role is a wrong verdict shown as a shape.
13
+ */
14
+
15
+ export type ColourRole =
16
+ | 'pass'
17
+ | 'findings'
18
+ | 'fail'
19
+ | 'undecided'
20
+ | 'muted'
21
+ | 'text'
22
+ | 'edge'
23
+ | 'surface'
24
+ | 'background'
25
+
26
+ export type Palette = Record<ColourRole, string>
27
+
28
+ /** Which surface the drawing will sit on. */
29
+ export type PaletteMode = 'light' | 'dark'
30
+
31
+ /** A named set of colours. A scheme with both variants follows the page; one with only light does not. */
32
+ export type PaletteScheme = 'default' | 'github' | 'high-contrast' | 'print'
33
+
34
+ /** Ours: a quiet neutral drawing, with a green/amber/red answer. */
35
+ const NEUTRAL_LIGHT: Palette = {
36
+ pass: '#2f8f5b',
37
+ findings: '#b7791f',
38
+ fail: '#c0392b',
39
+ undecided: '#6b7280',
40
+ muted: '#9aa3ad',
41
+ text: '#1f2933',
42
+ edge: '#6b7280',
43
+ surface: 'transparent',
44
+ background: '#ffffff',
45
+ }
46
+
47
+ const NEUTRAL_DARK: Palette = {
48
+ pass: '#4ac486',
49
+ findings: '#e0b050',
50
+ fail: '#f2705f',
51
+ undecided: '#9aa3ad',
52
+ muted: '#7d8794',
53
+ text: '#e6e8eb',
54
+ edge: '#8b949e',
55
+ surface: 'transparent',
56
+ background: '#0d1117',
57
+ }
58
+
59
+ /**
60
+ * GitHub's own palette, light: the colours a reader already knows from the place they keep the diagram.
61
+ * Values are Primer's foreground and canvas tokens, not invented for this plugin.
62
+ */
63
+ const GITHUB_LIGHT: Palette = {
64
+ pass: '#1a7f37',
65
+ findings: '#9a6700',
66
+ fail: '#cf222e',
67
+ undecided: '#656d76',
68
+ muted: '#8c959f',
69
+ text: '#1f2328',
70
+ edge: '#656d76',
71
+ surface: 'transparent',
72
+ background: '#ffffff',
73
+ }
74
+
75
+ /** GitHub dark default: canvas #0d1117, foreground #e6edf3. */
76
+ const GITHUB_DARK: Palette = {
77
+ pass: '#3fb950',
78
+ findings: '#d29922',
79
+ fail: '#f85149',
80
+ undecided: '#7d8590',
81
+ muted: '#6e7681',
82
+ text: '#e6edf3',
83
+ edge: '#7d8590',
84
+ surface: 'transparent',
85
+ background: '#0d1117',
86
+ }
87
+
88
+ /** Black on white, for a projector or a poor print. The shape carries the meaning, not the hue. */
89
+ const HIGH_CONTRAST: Palette = {
90
+ pass: '#0a5c35',
91
+ findings: '#7a4300',
92
+ fail: '#a30000',
93
+ undecided: '#333333',
94
+ muted: '#444444',
95
+ text: '#000000',
96
+ edge: '#000000',
97
+ surface: 'transparent',
98
+ background: '#ffffff',
99
+ }
100
+
101
+ /** Greyscale throughout: a drawing that will be printed on a black-and-white printer. */
102
+ const PRINT: Palette = {
103
+ pass: '#333333',
104
+ findings: '#555555',
105
+ fail: '#000000',
106
+ undecided: '#666666',
107
+ muted: '#777777',
108
+ text: '#000000',
109
+ edge: '#333333',
110
+ surface: 'transparent',
111
+ background: '#ffffff',
112
+ }
113
+
114
+ interface Scheme {
115
+ light: Palette
116
+ /** Absent when the scheme is deliberately the same on both surfaces. */
117
+ dark?: Palette
118
+ }
119
+
120
+ const SCHEMES: Record<PaletteScheme, Scheme> = {
121
+ default: { light: NEUTRAL_LIGHT, dark: NEUTRAL_DARK },
122
+ github: { light: GITHUB_LIGHT, dark: GITHUB_DARK },
123
+ 'high-contrast': { light: HIGH_CONTRAST },
124
+ print: { light: PRINT },
125
+ }
126
+
127
+ export interface PaletteOptions {
128
+ mode?: PaletteMode
129
+ scheme?: PaletteScheme
130
+ }
131
+
132
+ /**
133
+ * Resolve a palette.
134
+ *
135
+ * `overrides` win over everything, so a caller can retheme one role without inventing a scheme. A scheme with
136
+ * a dark variant follows the surface; one without stays as declared - high-contrast and print are what they
137
+ * say they are, on purpose. An unknown name falls back to the default palette rather than throwing, because
138
+ * the value can arrive from a DOM control.
139
+ */
140
+ export function palette(overrides: Partial<Palette> = {}, options: PaletteOptions = {}): Palette {
141
+ const scheme = options.scheme ?? 'default'
142
+ const mode = options.mode ?? 'light'
143
+ const chosen = SCHEMES[scheme] ?? SCHEMES.default
144
+ const base = mode === 'dark' ? (chosen.dark ?? chosen.light) : chosen.light
145
+ return { ...base, ...overrides }
146
+ }
147
+
148
+ /**
149
+ * The role a verdict earns.
150
+ *
151
+ * `pass_with_findings` is deliberately not `pass`: it reads as the middle band, because a card that paints a
152
+ * report with warnings the same colour as a clean one has stopped carrying the answer.
153
+ */
154
+ export function roleForVerdict(verdict: string | undefined): ColourRole {
155
+ if (verdict === 'pass') return 'pass'
156
+ if (verdict === 'pass_with_findings') return 'findings'
157
+ if (verdict === 'fail') return 'fail'
158
+ return 'undecided'
159
+ }
160
+
161
+ /** Every scheme by name, so a picker does not hard-code the list. */
162
+ export function schemeNames(): PaletteScheme[] {
163
+ return Object.keys(SCHEMES) as PaletteScheme[]
164
+ }
@@ -0,0 +1,527 @@
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
+ }