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,309 @@
1
+ /**
2
+ * Channel assignment: where the vertical lanes and the horizontal runs between two rows go.
3
+ *
4
+ * The stages before this one decide where the boxes are, and the painter decides what a box looks like. What is left
5
+ * is the space between two rows, and an edge that decides that space alone will collide with the next one: every long
6
+ * edge has to cross a row somewhere, and every edge whose two ends are not vertically aligned needs one horizontal run
7
+ * inside the gap it crosses. Chosen independently, two runs land on one height and read as a junction, and a run
8
+ * sweeps across a vertical another edge is already using.
9
+ *
10
+ * So this stage assigns both, after the boxes have settled and before anything is drawn:
11
+ *
12
+ * - **A lane's x.** A long edge owns one vertical lane through the rows it crosses. The x is a position no box
13
+ * occupies in any of those rows, and lanes keep the order their dummies have in the ordering, so two lanes never
14
+ * share an x inside one gap. Alignment is worth a detour: a lane that lands inside a box's own port span turns a
15
+ * four-bend staircase into a straight drop, so alignment comes before distance from the proposed position.
16
+ * - **A run's y.** The horizontal runs inside one gap are stacked at distinct heights - Graphviz's virtual nodes - so
17
+ * two runs never lie on one line, and a nested run stays near the box it leaves.
18
+ *
19
+ * Pure: rows, chains and coordinates in; lane positions and run heights out.
20
+ */
21
+
22
+ import type { Chain } from './dummy.js'
23
+
24
+ /** A box, as far as routing is concerned: only a box can move a lane. */
25
+ export interface ChannelBox {
26
+ x: number
27
+ y: number
28
+ width: number
29
+ height: number
30
+ }
31
+
32
+ export interface ChannelInput {
33
+ /** The ordered rows, dummies included: a lane keeps its dummy's order. */
34
+ layers: string[][]
35
+ /** Every edge's chain, from the layout source to the layout target. */
36
+ chains: Chain[]
37
+ /** A dummy instance, and the lane it belongs to. */
38
+ chainOf: ReadonlyMap<string, string>
39
+ /** The boxes that block a lane. A dummy has none. */
40
+ boxes: ReadonlyMap<string, ChannelBox>
41
+ /** The top of each row, and how tall it is. */
42
+ layerY: number[]
43
+ layerHeight: number[]
44
+ /** What coordinate assignment proposed for each lane. */
45
+ laneX: ReadonlyMap<string, number>
46
+ }
47
+
48
+ export interface ChannelOptions {
49
+ /** How far a lane stays from a box in a row it crosses. */
50
+ clearance: number
51
+ /** The narrowest two lanes sharing a gap may be. */
52
+ spacing: number
53
+ }
54
+
55
+ /** One long edge's lane: the x of its vertical run, and the height of a run in each gap it crosses. */
56
+ export interface LaneChannel {
57
+ x: number
58
+ y: number[]
59
+ }
60
+
61
+ export interface ChannelPlan {
62
+ /** By diagram edge index: the lane a long edge reserved, with one corridor height per gap it crosses. */
63
+ lanes: Map<number, LaneChannel>
64
+ /** By diagram edge index: the height of the one run an edge between two adjacent rows needs. */
65
+ runs: Map<number, number>
66
+ }
67
+
68
+ /** How far a port sits from a box's corner. The painter places ports from this same number. */
69
+ export const PORT_INSET = 10
70
+
71
+ /** An x-interval, open at either end when nothing bounds it. */
72
+ interface Span {
73
+ lo: number
74
+ hi: number
75
+ }
76
+
77
+ /** The x's a port may sit at on a box's top or bottom side. */
78
+ function spanOf(box: ChannelBox): Span {
79
+ const lo = box.x + PORT_INSET
80
+ const hi = box.x + box.width - PORT_INSET
81
+ return hi > lo ? { lo, hi } : { lo: box.x + box.width / 2, hi: box.x + box.width / 2 }
82
+ }
83
+
84
+ function clamp(value: number, span: Span): number {
85
+ return Math.min(span.hi, Math.max(span.lo, value))
86
+ }
87
+
88
+ function inside(value: number, span: Span): boolean {
89
+ return value >= span.lo - 0.001 && value <= span.hi + 0.001
90
+ }
91
+
92
+ /** The intervals a blocked strip cuts out of the free ones. */
93
+ function cut(free: Span[], blocked: Span): Span[] {
94
+ const out: Span[] = []
95
+ for (const span of free) {
96
+ if (blocked.hi <= span.lo || blocked.lo >= span.hi) {
97
+ out.push(span)
98
+ continue
99
+ }
100
+ if (blocked.lo > span.lo) out.push({ lo: span.lo, hi: blocked.lo })
101
+ if (blocked.hi < span.hi) out.push({ lo: blocked.hi, hi: span.hi })
102
+ }
103
+ return out
104
+ }
105
+
106
+ /** The nearest free x at or after `value`: a lane pushed right never lands inside a box. */
107
+ function atOrAfter(value: number, free: Span[]): number {
108
+ let found = value
109
+ for (const span of free) {
110
+ if (span.hi < value) continue
111
+ found = Math.max(value, span.lo)
112
+ break
113
+ }
114
+ return found
115
+ }
116
+
117
+ /** One long edge, and the space its lane may use. */
118
+ interface Long {
119
+ edge: number
120
+ lane: string
121
+ /** The ids it passes through, dummies included. */
122
+ path: string[]
123
+ /** The rows it starts in and ends in. */
124
+ from: number
125
+ to: number
126
+ /** The x-intervals free of boxes in every row it crosses. */
127
+ free: Span[]
128
+ source: Span
129
+ target: Span
130
+ /** What coordinate assignment proposed, and what was finally chosen. */
131
+ proposal: number
132
+ x: number
133
+ }
134
+
135
+ /** The x a lane takes: aligned with a box's own port if it can be, then the proposed position, then the detour. */
136
+ function choose(entry: Long): number {
137
+ const candidates: number[] = [entry.proposal, entry.source.lo, entry.source.hi, entry.target.lo, entry.target.hi]
138
+ candidates.push((clamp(entry.proposal, entry.source) + clamp(entry.proposal, entry.target)) / 2)
139
+ // Ordered by what the drawing wants: no detour at either end first, then the position coordinate assignment
140
+ // proposed, then the shorter detour. A free x is never this stage's to invent - the corridor is already there.
141
+ let best = entry.proposal
142
+ let bestBends = Infinity
143
+ let bestFar = Infinity
144
+ let bestReach = Infinity
145
+ for (const span of entry.free) {
146
+ for (const value of candidates) {
147
+ const x = clamp(value, span)
148
+ const bends = (inside(x, entry.source) ? 0 : 1) + (inside(x, entry.target) ? 0 : 1)
149
+ const far = Math.abs(x - entry.proposal)
150
+ const reach = Math.abs(x - clamp(x, entry.source)) + Math.abs(x - clamp(x, entry.target))
151
+ const better =
152
+ bends < bestBends ||
153
+ (bends === bestBends && (far < bestFar - 0.001 || (Math.abs(far - bestFar) <= 0.001 && reach < bestReach)))
154
+ if (!better) continue
155
+ best = x
156
+ bestBends = bends
157
+ bestFar = far
158
+ bestReach = reach
159
+ }
160
+ }
161
+ return best
162
+ }
163
+
164
+ /**
165
+ * The two ports the painter will place for an edge between two adjacent rows.
166
+ *
167
+ * The painter reads the side off the rows and the position off the box the edge points at; this repeats that rule so
168
+ * the run's height can be stacked against the runs it will actually share a gap with.
169
+ */
170
+ function adjacentPorts(source: ChannelBox, target: ChannelBox): { from: number; to: number } {
171
+ const fromMiddle = source.x + source.width / 2
172
+ const toMiddle = target.x + target.width / 2
173
+ const fromSpan = spanOf(source)
174
+ const toSpan = spanOf(target)
175
+ const chosen = inside(fromMiddle, toSpan) ? fromMiddle : clamp(toMiddle, fromSpan)
176
+ return { from: clamp(chosen, fromSpan), to: clamp(chosen, toSpan) }
177
+ }
178
+
179
+ export function assignChannels(input: ChannelInput, options: ChannelOptions): ChannelPlan {
180
+ const { layers, chains, chainOf, boxes, layerY, layerHeight, laneX } = input
181
+ const rowOf = new Map<string, number>()
182
+ layers.forEach((row, depth) => {
183
+ for (const id of row) rowOf.set(id, depth)
184
+ })
185
+
186
+ // A lane's x: free in every row it crosses, and reachable from both ends.
187
+ const long: Long[] = []
188
+ for (const chain of chains) {
189
+ if (chain.path.length <= 2) continue
190
+ const head = chain.path[0] as string
191
+ const tail = chain.path[chain.path.length - 1] as string
192
+ const from = rowOf.get(head)
193
+ const to = rowOf.get(tail)
194
+ if (from === undefined || to === undefined || to <= from) continue
195
+ const lane = chainOf.get(chain.path[1] as string)
196
+ const source = boxes.get(head)
197
+ const target = boxes.get(tail)
198
+ // A lane into a boundary has no box to arrive at: that edge is the router's.
199
+ if (lane === undefined || source === undefined || target === undefined) continue
200
+ let free: Span[] = [{ lo: -Infinity, hi: Infinity }]
201
+ for (let depth = from + 1; depth < to; depth += 1) {
202
+ for (const id of layers[depth] as string[]) {
203
+ const box = boxes.get(id)
204
+ if (box === undefined) continue
205
+ free = cut(free, { lo: box.x - options.clearance, hi: box.x + box.width + options.clearance })
206
+ }
207
+ }
208
+ const centre = (source.x + source.width / 2 + target.x + target.width / 2) / 2
209
+ long.push({
210
+ edge: chain.index,
211
+ lane,
212
+ path: chain.path,
213
+ from,
214
+ to,
215
+ free,
216
+ source: spanOf(source),
217
+ target: spanOf(target),
218
+ proposal: laneX.get(lane) ?? centre,
219
+ x: 0,
220
+ })
221
+ }
222
+ for (const entry of long) entry.x = choose(entry)
223
+
224
+ // The order inside a gap: two lanes never share an x, and the left one is the one the ordering put left.
225
+ const byGap = new Map<number, Array<{ entry: Long; index: number }>>()
226
+ for (const entry of long) {
227
+ for (let gap = entry.from; gap < entry.to; gap += 1) {
228
+ // The dummy in the lower row of the gap; the last gap has none, and its upper row's dummy orders it.
229
+ const row = gap + 1 < entry.to ? gap + 1 : gap
230
+ const id = entry.path[row - entry.from] as string
231
+ const index = (layers[row] as string[]).indexOf(id)
232
+ byGap.set(gap, [...(byGap.get(gap) ?? []), { entry, index }])
233
+ }
234
+ }
235
+ for (const [, list] of byGap) list.sort((left, right) => left.index - right.index)
236
+ for (let round = 0; round < 4; round += 1) {
237
+ let moved = false
238
+ for (const [, list] of byGap) {
239
+ let previous = -Infinity
240
+ for (const item of list) {
241
+ const x = item.entry.x < previous + options.spacing ? atOrAfter(previous + options.spacing, item.entry.free) : item.entry.x
242
+ if (x !== item.entry.x) {
243
+ item.entry.x = x
244
+ moved = true
245
+ }
246
+ previous = item.entry.x
247
+ }
248
+ }
249
+ if (!moved) break
250
+ }
251
+
252
+ // The runs: one per edge end that does not land on its lane, stacked at distinct heights inside the gap.
253
+ const stacks = new Map<number, Array<{ edge: number; gap: number; left: number }>>()
254
+ const want = (gap: number, edge: number, left: number): void => {
255
+ stacks.set(gap, [...(stacks.get(gap) ?? []), { edge, gap, left }])
256
+ }
257
+ for (const entry of long) {
258
+ const head = clamp(entry.x, entry.source)
259
+ const tail = clamp(entry.x, entry.target)
260
+ if (Math.abs(head - entry.x) > 0.5) want(entry.from, entry.edge, Math.min(head, entry.x))
261
+ if (Math.abs(tail - entry.x) > 0.5) want(entry.to - 1, entry.edge, Math.min(tail, entry.x))
262
+ }
263
+ for (const chain of chains) {
264
+ if (chain.path.length > 2) continue
265
+ const head = chain.path[0] as string
266
+ const tail = chain.path[chain.path.length - 1] as string
267
+ const from = rowOf.get(head)
268
+ const to = rowOf.get(tail)
269
+ if (from === undefined || to === undefined || Math.abs(from - to) !== 1) continue
270
+ const source = boxes.get(head)
271
+ const target = boxes.get(tail)
272
+ if (source === undefined || target === undefined) continue
273
+ const ports = adjacentPorts(source, target)
274
+ want(Math.min(from, to), chain.index, Math.min(ports.from, ports.to))
275
+ }
276
+
277
+ // A gap always has a row below it, so its strip is bounded: one stack per gap, inside that strip.
278
+ const midOf = (gap: number): number => (layerY[gap] + layerHeight[gap] + layerY[gap + 1]) / 2
279
+ const heightOf = new Map<number, Map<number, number>>()
280
+ for (const [gap, list] of stacks) {
281
+ const top = layerY[gap] + layerHeight[gap]
282
+ const bottom = layerY[gap + 1]
283
+ const pad = Math.min(8, Math.max(1, (bottom - top) / 3))
284
+ const lo = top + pad
285
+ const hi = Math.max(lo, bottom - pad)
286
+ // Left to right, so a run that stays near its box sits near the row it left.
287
+ const sorted = [...list].sort((left, right) => left.left - right.left || left.edge - right.edge)
288
+ sorted.forEach((item, index) => {
289
+ const y = sorted.length === 1 ? (lo + hi) / 2 : lo + ((hi - lo) * (index + 1)) / (sorted.length + 1)
290
+ const heights = heightOf.get(item.edge) ?? new Map<number, number>()
291
+ heights.set(item.gap, y)
292
+ heightOf.set(item.edge, heights)
293
+ })
294
+ }
295
+
296
+ const lanes = new Map<number, LaneChannel>()
297
+ for (const entry of long) {
298
+ const y: number[] = []
299
+ for (let gap = entry.from; gap < entry.to; gap += 1) y.push(heightOf.get(entry.edge)?.get(gap) ?? midOf(gap))
300
+ lanes.set(entry.edge, { x: entry.x, y })
301
+ }
302
+ const runs = new Map<number, number>()
303
+ for (const [edge, heights] of heightOf) {
304
+ // A long edge's heights belong to its lane; an edge between two rows has the one run it asked for.
305
+ if (lanes.has(edge)) continue
306
+ runs.set(edge, [...heights.values()][0])
307
+ }
308
+ return { lanes, runs }
309
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Collapsing a group: the drawing with one boundary folded into a single box.
3
+ *
4
+ * A fold is a transform on the model, not a mode of the painter. That keeps the painter's rules unchanged -
5
+ * it still draws boxes and edges it is given - and it makes the behaviour testable without a browser: feed a
6
+ * diagram in, assert what came out.
7
+ *
8
+ * Folding is what makes a hierarchy usable: a reader who has understood a package wants it out of the way, and
9
+ * an architecture diagram that cannot be folded is read at one zoom level forever.
10
+ */
11
+
12
+ import type { Diagram, DiagramEdge, DiagramGroup, DiagramNode } from './model.js'
13
+
14
+ /** How many nodes a group holds, counting the whole subtree - what the folded box offers to unfold. */
15
+ function subtreeSize(groups: DiagramGroup[], groupId: string, seen: Set<string>): number {
16
+ if (seen.has(groupId)) return 0
17
+ seen.add(groupId)
18
+ const group = groups.find((entry) => entry.id === groupId) as DiagramGroup
19
+ const children = groups.filter((entry) => entry.parent === groupId)
20
+ return group.members.length + children.reduce((total, child) => total + subtreeSize(groups, child.id, seen), 0)
21
+ }
22
+
23
+ /** The group that directly holds a node, when one does. */
24
+ function ownerOf(groups: DiagramGroup[], nodeId: string): string | undefined {
25
+ return groups.find((group) => group.members.includes(nodeId))?.id
26
+ }
27
+
28
+ /**
29
+ * Fold the named groups.
30
+ *
31
+ * A folded group becomes a node carrying its name and how many nodes it holds; its members - and every group
32
+ * inside it - leave the drawing. An edge that pointed at something inside the fold is re-aimed at the fold's
33
+ * box, and an edge between two members of the same fold disappears: both ends are now the same box, and a
34
+ * self-loop would be a claim the source never made.
35
+ *
36
+ * Ids nobody declared are ignored, and folding a group whose contents are already gone changes nothing, so the
37
+ * function is total on any diagram and any set of names.
38
+ */
39
+ export function collapse(diagram: Diagram, folded: Iterable<string>): Diagram {
40
+ const asked = new Set(folded)
41
+ const groups: DiagramGroup[] = diagram.groups ?? []
42
+ if (asked.size === 0 || groups.length === 0) return diagram
43
+
44
+ // Only groups that exist, and that still have contents, can be folded.
45
+ const byId = new Map(groups.map((group) => [group.id, group] as const))
46
+ const targets = new Set<string>()
47
+ for (const id of asked) {
48
+ const group = byId.get(id)
49
+ if (group !== undefined && subtreeSize(groups, id, new Set()) > 0) targets.add(id)
50
+ }
51
+ if (targets.size === 0) return diagram
52
+
53
+ // A fold inside another fold is served by the outer one: the inner box would not be drawn anyway.
54
+ for (const id of [...targets]) {
55
+ let cursor = byId.get(id)?.parent
56
+ const seen = new Set<string>()
57
+ while (cursor !== undefined && !seen.has(cursor)) {
58
+ seen.add(cursor)
59
+ if (targets.has(cursor)) {
60
+ targets.delete(id)
61
+ break
62
+ }
63
+ cursor = byId.get(cursor)?.parent
64
+ }
65
+ }
66
+
67
+ const removedNodes = new Set<string>()
68
+ const removedGroups = new Set<string>()
69
+ const descend = (groupId: string): void => {
70
+ removedGroups.add(groupId)
71
+ const group = byId.get(groupId) as DiagramGroup
72
+ for (const member of group.members) removedNodes.add(member)
73
+ for (const child of groups.filter((entry) => entry.parent === groupId)) descend(child.id)
74
+ }
75
+ for (const id of targets) descend(id)
76
+
77
+ /** The innermost fold that swallowed this id, if any. */
78
+ const foldOf = (id: string): string | undefined => {
79
+ for (const target of targets) {
80
+ const inSubtree = (groupId: string, seen: Set<string>): boolean => {
81
+ if (seen.has(groupId)) return false
82
+ seen.add(groupId)
83
+ if (groupId === target) return true
84
+ return groups.filter((entry) => entry.parent === groupId).some((child) => inSubtree(child.id, seen))
85
+ }
86
+ const group = byId.get(target) as DiagramGroup
87
+ if (group.members.includes(id) || inSubtree(ownerOf(groups, id) as string, new Set())) return target
88
+ }
89
+ return undefined
90
+ }
91
+
92
+ // The box that replaces a fold takes the place of its first member, so the drawing keeps the order the source
93
+ // gave it: a folded package belongs where the package was, not at the end of the page.
94
+ const nodes: DiagramNode[] = []
95
+ const emitted = new Set<string>()
96
+ for (const node of diagram.nodes) {
97
+ if (!removedNodes.has(node.id)) {
98
+ nodes.push(node)
99
+ continue
100
+ }
101
+ const fold = foldOf(node.id)
102
+ if (fold === undefined || emitted.has(fold)) continue
103
+ emitted.add(fold)
104
+ const group = byId.get(fold) as DiagramGroup
105
+ nodes.push({ id: fold, label: group.label + ' (' + String(subtreeSize(groups, fold, new Set())) + ')', role: 'normal' })
106
+ }
107
+ // A fold whose members are not in `nodes` at all still deserves a box rather than vanishing.
108
+ for (const id of targets) {
109
+ if (emitted.has(id)) continue
110
+ const group = byId.get(id) as DiagramGroup
111
+ nodes.push({ id, label: group.label + ' (' + String(subtreeSize(groups, id, new Set())) + ')', role: 'normal' })
112
+ }
113
+
114
+ const edges: DiagramEdge[] = []
115
+ const seenEdges = new Set<string>()
116
+ for (const edge of diagram.edges) {
117
+ const from = removedNodes.has(edge.from) ? foldOf(edge.from) : edge.from
118
+ const to = removedNodes.has(edge.to) ? foldOf(edge.to) : edge.to
119
+ if (from === undefined || to === undefined) continue
120
+ if (from === to) continue
121
+ const key = from + '>' + to + ':' + edge.label
122
+ if (seenEdges.has(key)) continue
123
+ seenEdges.add(key)
124
+ edges.push({ ...edge, from, to })
125
+ }
126
+
127
+ // A folded box stands where its group stood, so it belongs to that group's parent: without this the box that
128
+ // replaced an inner boundary would be drawn outside the outer one, which reads as "it left the package".
129
+ const adopted = new Map<string, string[]>()
130
+ for (const id of targets) {
131
+ const parent = byId.get(id)?.parent
132
+ if (parent === undefined) continue
133
+ adopted.set(parent, [...(adopted.get(parent) ?? []), id])
134
+ }
135
+ const remaining = groups
136
+ .filter((group) => !removedGroups.has(group.id) && targets.has(group.id) === false)
137
+ .map((group) => {
138
+ const extra = adopted.get(group.id)
139
+ if (extra === undefined) return group
140
+ return { ...group, members: [...group.members.filter((member) => !removedNodes.has(member)), ...extra] }
141
+ })
142
+ const caption = diagram.caption + (targets.size === 1 ? ', 1 group folded' : ', ' + String(targets.size) + ' groups folded')
143
+ return {
144
+ ...diagram,
145
+ nodes,
146
+ edges,
147
+ ...(remaining.length === 0 ? { groups: undefined } : { groups: remaining }),
148
+ caption,
149
+ }
150
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The two decompositions a drawing needs before it can be packed.
3
+ *
4
+ * **Weakly connected components.** Two subgraphs with no edge between them are two drawings that happen to share a
5
+ * page. Laying them out together makes them share one set of layers, so the wider one sets every column width and
6
+ * the narrower one is squeezed beside it; moving one moves the other. Split first, lay each out on its own, then pack
7
+ * the rectangles.
8
+ *
9
+ * **Packing.** A pile of rectangles has to become a page. The target is the aspect ratio a page and a screen share -
10
+ * 1.6 - and a shelf packer is enough to get there: sort by height, fill a shelf until the next rectangle would make
11
+ * the drawing wider than the page it is aiming at, then start the next shelf. No overlap, and a deterministic order,
12
+ * so the same rectangles always pack the same way.
13
+ *
14
+ * Pure, and asserted on the rectangles themselves.
15
+ */
16
+
17
+ export interface SizedRectangle {
18
+ width: number
19
+ height: number
20
+ }
21
+
22
+ export interface PackedRectangle {
23
+ x: number
24
+ y: number
25
+ width: number
26
+ height: number
27
+ }
28
+
29
+ /** The aspect ratio a page and a landscape screen share. */
30
+ const PAGE_ASPECT = 1.6
31
+
32
+ /**
33
+ * The nodes of each weakly connected component, in the order the nodes were written.
34
+ *
35
+ * An isolated node is a component of its own: it is not noise, it is a drawing with nothing joining it, and the
36
+ * packer is what decides where it goes.
37
+ */
38
+ export function weakComponents(
39
+ nodes: ReadonlyArray<{ id: string }>,
40
+ edges: ReadonlyArray<{ from: string; to: string }>,
41
+ ): string[][] {
42
+ const known = new Set(nodes.map((node) => node.id))
43
+ const neighbours = new Map<string, string[]>()
44
+ const link = (from: string, to: string): void => {
45
+ neighbours.set(from, [...(neighbours.get(from) ?? []), to])
46
+ }
47
+ for (const edge of edges) {
48
+ if (!known.has(edge.from) || !known.has(edge.to) || edge.from === edge.to) continue
49
+ link(edge.from, edge.to)
50
+ link(edge.to, edge.from)
51
+ }
52
+ const seen = new Set<string>()
53
+ const components: string[][] = []
54
+ for (const node of nodes) {
55
+ if (seen.has(node.id)) continue
56
+ const members: string[] = []
57
+ const queue = [node.id]
58
+ seen.add(node.id)
59
+ for (let head = 0; head < queue.length; head += 1) {
60
+ const id = queue[head]
61
+ members.push(id)
62
+ for (const next of neighbours.get(id) ?? []) {
63
+ if (seen.has(next)) continue
64
+ seen.add(next)
65
+ queue.push(next)
66
+ }
67
+ }
68
+ components.push(members)
69
+ }
70
+ return components
71
+ }
72
+
73
+ export interface PackOptions {
74
+ /** The empty strip kept between two rectangles, on both axes. */
75
+ gap?: number
76
+ /** The page the packer aims at; the drawing comes out as close to it as the rectangles allow. */
77
+ aspect?: number
78
+ }
79
+
80
+ /**
81
+ * Pack rectangles into a page of roughly `aspect`, using shelves.
82
+ *
83
+ * The shelf width is derived from the total area and the target aspect: that is the width at which a perfect grid
84
+ * would have the wanted shape, so a shelf that fills it lands near the target. Rectangles are taken tallest first,
85
+ * which is what keeps a shelf from being mostly empty air above a short rectangle.
86
+ */
87
+ export function packRectangles(rectangles: ReadonlyArray<SizedRectangle>, options: PackOptions = {}): {
88
+ placements: PackedRectangle[]
89
+ width: number
90
+ height: number
91
+ } {
92
+ const gap = options.gap ?? 30
93
+ const aspect = options.aspect ?? PAGE_ASPECT
94
+ if (rectangles.length === 0) return { placements: [], width: 0, height: 0 }
95
+
96
+ const area = rectangles.reduce((total, rectangle) => total + (rectangle.width + gap) * (rectangle.height + gap), 0)
97
+ const targetWidth = Math.max(gap, Math.sqrt(area * aspect))
98
+ const order = rectangles
99
+ .map((rectangle, index) => ({ rectangle, index }))
100
+ .sort((left, right) => right.rectangle.height - left.rectangle.height || right.rectangle.width - left.rectangle.width || left.index - right.index)
101
+
102
+ const placements: PackedRectangle[] = rectangles.map((rectangle) => ({ x: 0, y: 0, width: rectangle.width, height: rectangle.height }))
103
+ let x = 0
104
+ let y = 0
105
+ let shelfHeight = 0
106
+ let width = 0
107
+ for (const entry of order) {
108
+ const rectangle = entry.rectangle
109
+ if (x > 0 && x + rectangle.width > targetWidth) {
110
+ y += shelfHeight + gap
111
+ x = 0
112
+ shelfHeight = 0
113
+ }
114
+ placements[entry.index] = { x, y, width: rectangle.width, height: rectangle.height }
115
+ x += rectangle.width + gap
116
+ width = Math.max(width, x - gap)
117
+ shelfHeight = Math.max(shelfHeight, rectangle.height)
118
+ }
119
+ return { placements, width, height: y + shelfHeight }
120
+ }
121
+
122
+ /** The aspect ratio of a box, as a number a reader can compare against the page's. */
123
+ export function aspectOf(width: number, height: number): number {
124
+ if (width <= 0 || height <= 0) return 1
125
+ return Math.max(width, height) / Math.min(width, height)
126
+ }