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,1006 @@
1
+ /**
2
+ * Geometry: what a diagram turns into, in coordinates.
3
+ *
4
+ * A drawing has to survive three containers - inline in a conversation, full-screen, and a persistent
5
+ * sidebar tab - so nothing here knows about pixel size. It emits a content-sized canvas and lets the
6
+ * container scale it: the same numbers serve a 360px card and a 1200px whiteboard.
7
+ *
8
+ * The drawing is placed by the layered pipeline (`place.ts`), which also says which lane a long edge reserved.
9
+ * Edges are drawn from where the boxes ended up, in two ways only: a long edge follows its lane as one polyline,
10
+ * and everything else goes through the orthogonal router. Which side of a box an edge leaves by is read off the
11
+ * route, not picked by comparing the boxes' centres: a route between two rows arrives vertically, so the endpoint
12
+ * is snapped onto the top or bottom perimeter in the direction the route approaches. Where along that side it sits
13
+ * follows the symbol: a rectangle spreads its ports along the side, a cylinder's top port sits on its cap curve, and a
14
+ * diamond or a circle has four legal points and no side to spread along at all.
15
+ *
16
+ * A symbol with discrete points gives each of its edges a point of its own: the point whose outward normal faces the
17
+ * box at the other end is the one that edge takes, and an edge only shares a point when all four are spoken for. A
18
+ * node with a run on its side keeps doing what it always did, because there the ports are places on the run and not
19
+ * points at all. Sharing is the fallback, not the rule: two labelled branches of a decision leave by two vertices.
20
+ *
21
+ * An edge's name is drawn on the edge, not beside it: it is anchored at the midpoint of the edge's longest straight
22
+ * run, over a rectangle in the page colour that masks the line behind the text, and it slides along that run when
23
+ * something is in the way. Labels are placed after every edge is drawn, because what a label has to avoid is the
24
+ * whole picture.
25
+ *
26
+ * Pure: a diagram in, shapes out, every decision assertable.
27
+ */
28
+
29
+ import { MARGIN, place, textOf, type Box } from './place.js'
30
+ import { PORT_INSET } from './channels.js'
31
+ import { anchor, approachSides, crossesBox, normalOf, ROUTE_STUB, routeEdge, simplify, type Ports, type Side } from './route.js'
32
+ import { cylinderApex, cylinderCaps, hasDiscretePorts, hexagonCut, stadiumRadius } from './symbol.js'
33
+ import { textWidth } from './text.js'
34
+ import { palette, roleForVerdict, type ColourRole, type Palette, type PaletteOptions } from './palette.js'
35
+ import { labelOf, type Diagram, type DiagramEdge, type DiagramGroup, type DiagramNode, type EdgeArrow, type NodeShape } from './model.js'
36
+
37
+ export interface Point {
38
+ x: number
39
+ y: number
40
+ }
41
+
42
+ export interface BoxShape {
43
+ kind: 'box'
44
+ x: number
45
+ y: number
46
+ width: number
47
+ height: number
48
+ /** The lines to draw, already wrapped; `label` keeps the whole text for anything that reads the shape. */
49
+ label: string
50
+ lines: string[]
51
+ /** The symbol to draw. Undefined means a rectangle. */
52
+ shape?: NodeShape
53
+ colour: ColourRole
54
+ initial: boolean
55
+ }
56
+
57
+ export interface LinkShape {
58
+ kind: 'link'
59
+ x1: number
60
+ y1: number
61
+ x2: number
62
+ y2: number
63
+ label: string
64
+ /** The centre the label is drawn on, and therefore a point on the edge itself. */
65
+ labelX: number
66
+ labelY: number
67
+ colour: ColourRole
68
+ /** A dashed line, when the notation distinguished one. */
69
+ dashed?: boolean
70
+ /** The arrow the source edge stated. Undefined means the plain arrow every edge has been drawn with. */
71
+ arrow?: EdgeArrow
72
+ }
73
+
74
+ export interface PathShape {
75
+ kind: 'path'
76
+ points: Point[]
77
+ label: string
78
+ /** The centre the label is drawn on, and therefore a point on the edge itself. */
79
+ labelX: number
80
+ labelY: number
81
+ colour: ColourRole
82
+ dashed?: boolean
83
+ arrow?: EdgeArrow
84
+ }
85
+
86
+ /** A boundary around several states: a package, a layer, a subgraph. */
87
+ export interface GroupShape {
88
+ kind: 'group'
89
+ /** The group's own id, so a container can fold it without guessing from the label. */
90
+ id: string
91
+ x: number
92
+ y: number
93
+ width: number
94
+ height: number
95
+ label: string
96
+ colour: ColourRole
97
+ }
98
+
99
+ export interface CaptionShape {
100
+ kind: 'caption'
101
+ x: number
102
+ y: number
103
+ lines: string[]
104
+ colour: ColourRole
105
+ }
106
+
107
+ export interface TitleShape {
108
+ kind: 'title'
109
+ x: number
110
+ y: number
111
+ text: string
112
+ colour: ColourRole
113
+ }
114
+
115
+ export type Shape = GroupShape | BoxShape | LinkShape | PathShape | CaptionShape | TitleShape
116
+
117
+ /** Which side of each box an edge leaves by and arrives at, and, for a long edge, the lane x it meets there. */
118
+ interface Facing {
119
+ exit: Side
120
+ entry: Side
121
+ at?: number
122
+ }
123
+
124
+ export interface Canvas {
125
+ width: number
126
+ height: number
127
+ shapes: Shape[]
128
+ }
129
+
130
+ /** Geometry constants in one place: a container with different proportions changes these, not the rules. */
131
+ const TITLE_HEIGHT = 26
132
+ const CAPTION_CHARS = 78
133
+ const CAPTION_LINE = 15
134
+ const CAPTION_GAP = 16
135
+ const CAPTION_CHAR_WIDTH = 6.2
136
+
137
+ /** The closest two ports on one side may sit, so two edges never leave from the same pixel. */
138
+ const PORT_SEPARATION = 14
139
+
140
+ /**
141
+ * The sides of a box, in the order a symbol's own points are drawn: a diamond's top, right, bottom and left vertices.
142
+ *
143
+ * A shape with discrete ports has exactly one legal point per side, so this is also the order its points are offered
144
+ * to the edges that want one, and the order a tie between two equally good sides falls back to.
145
+ */
146
+ const PORT_SIDES: Side[] = ['top', 'right', 'bottom', 'left']
147
+
148
+ /** Is this one of the two sides a vertical run leaves by? A run is vertical in its own axis, not in the page. */
149
+ const isVerticalSide = (side: Side): boolean => side === 'top' || side === 'bottom'
150
+
151
+ /**
152
+ * How far apart two edges that join the same pair are drawn.
153
+ *
154
+ * Wider than the least two ports may sit: two statements about one pair have to read as two lines, and a gap the
155
+ * port packer would allow is a gap a reader sees as one thick line. Exported so a test can name the pattern.
156
+ */
157
+ export const MULTI_EDGE_SPACING = 20
158
+
159
+ /**
160
+ * Spread the ports of one side along it: each stays as close to the point it asked for as the gap to its neighbours
161
+ * allows, and the whole row is kept on the box.
162
+ *
163
+ * This is the same monotone fit coordinate assignment uses on x, applied to one side of one box: a forward pass that
164
+ * keeps every port at least `PORT_SEPARATION` to the right of the one before it, then a backward pass that pulls the
165
+ * row back onto the box without closing those gaps.
166
+ */
167
+ function packPorts(wanted: number[], low: number, high: number): number[] {
168
+ const placed: number[] = []
169
+ let previous = low - PORT_SEPARATION
170
+ for (const value of wanted) {
171
+ previous = Math.min(high, Math.max(value, previous + PORT_SEPARATION))
172
+ placed.push(previous)
173
+ }
174
+ let next = high + PORT_SEPARATION
175
+ for (let index = placed.length - 1; index >= 0; index -= 1) {
176
+ next = Math.max(low, Math.min(placed[index], next - PORT_SEPARATION))
177
+ placed[index] = next
178
+ }
179
+ return placed
180
+ }
181
+
182
+ /** Which colour a box earns from its role. One mapping, used by every container. */
183
+ export function roleOfNode(node: DiagramNode): ColourRole {
184
+ if (node.role === 'error') return 'fail'
185
+ if (node.role === 'warning') return 'findings'
186
+ if (node.role === 'muted') return 'muted'
187
+ return 'edge'
188
+ }
189
+
190
+ /** Wrap on spaces; break a token longer than the line rather than let it overflow the canvas. */
191
+ function wrap(text: string, limit: number): string[] {
192
+ const lines: string[] = []
193
+ let line = ''
194
+ for (const word of text.split(/\s+/).filter((part) => part !== '')) {
195
+ const candidate = line === '' ? word : line + ' ' + word
196
+ if (candidate.length <= limit) {
197
+ line = candidate
198
+ continue
199
+ }
200
+ if (line !== '') lines.push(line)
201
+ line = ''
202
+ let rest = word
203
+ while (rest.length > limit) {
204
+ lines.push(rest.slice(0, limit))
205
+ rest = rest.slice(limit)
206
+ }
207
+ line = rest
208
+ }
209
+ if (line !== '') lines.push(line)
210
+ return lines.length === 0 ? [''] : lines
211
+ }
212
+
213
+ /** The colour a caption earns, read from the verdict it quotes. */
214
+ function captionRole(caption: string): ColourRole {
215
+ return roleForVerdict(caption.split(' — ')[0] as string)
216
+ }
217
+
218
+
219
+ /**
220
+ * How tall the mask an edge label is drawn over is.
221
+ *
222
+ * Exported because two modules must agree on it: the painter reserves the rectangle when it looks for a clear
223
+ * place, and the writer draws the rectangle that was reserved. A label with two sizes is a label half masked.
224
+ */
225
+ export const LABEL_HEIGHT = 12
226
+
227
+ /** The room a label's mask keeps from anything it may not cover, so two rectangles never read as one. */
228
+ const LABEL_PAD = 2
229
+
230
+ /** How far a label steps along its own run while it looks for a clear place to sit. */
231
+ const LABEL_STEP = 3
232
+
233
+ /** A straight piece of an edge, as the collision test needs it. */
234
+ interface Segment {
235
+ x1: number
236
+ y1: number
237
+ x2: number
238
+ y2: number
239
+ }
240
+
241
+ /** Where a label sits: the point it is centred on, and the rectangle its mask covers. */
242
+ interface LabelSpot {
243
+ x: number
244
+ y: number
245
+ width: number
246
+ height: number
247
+ }
248
+
249
+ /** The point an edge's label is drawn at.
250
+ *
251
+ * A label is not furniture parked beside the line: it names the transition, so it sits on the line - anchored at
252
+ * the middle of the longest straight run of its own edge - and the writer masks the stretch of line it covers.
253
+ * Text beside a line can be read as belonging to whatever it happens to sit next to; text that interrupts its own
254
+ * line reads as part of it.
255
+ *
256
+ * The anchor then has to be earned. A mask over a box hides a border, over another label it makes both
257
+ * unreadable, and over a third edge's line it hides that line. So the label walks along its own run in small
258
+ * steps, in both directions and bounded so its rectangle stays on that run, and takes the first place that is
259
+ * clear. It never leaves the run: a label moved onto a neighbouring edge is a label that names the wrong thing.
260
+ */
261
+ function placeLabel(
262
+ label: string,
263
+ from: Point,
264
+ to: Point,
265
+ boxes: Iterable<Box>,
266
+ placed: LabelSpot[],
267
+ segments: Segment[],
268
+ ): LabelSpot {
269
+ const width = textWidth(label)
270
+ const height = LABEL_HEIGHT
271
+ const spotAt = (x: number, y: number): LabelSpot => ({ x, y, width, height })
272
+ const hitsBox = (spot: LabelSpot, box: Box): boolean =>
273
+ spot.x - spot.width / 2 < box.x + box.width + LABEL_PAD &&
274
+ spot.x + spot.width / 2 > box.x - LABEL_PAD &&
275
+ spot.y - spot.height / 2 < box.y + box.height + LABEL_PAD &&
276
+ spot.y + spot.height / 2 > box.y - LABEL_PAD
277
+ const hitsSpot = (spot: LabelSpot, other: LabelSpot): boolean =>
278
+ spot.x - spot.width / 2 < other.x + other.width / 2 + LABEL_PAD &&
279
+ spot.x + spot.width / 2 > other.x - other.width / 2 - LABEL_PAD &&
280
+ spot.y - spot.height / 2 < other.y + other.height / 2 + LABEL_PAD &&
281
+ spot.y + spot.height / 2 > other.y - other.height / 2 - LABEL_PAD
282
+ // A label on top of a line is as unreadable as one on top of a box: the mask hides the stretch of line it
283
+ // covers, which on its own edge is the point of the mask and on any other edge is a line that goes missing.
284
+ const hitsSegment = (spot: LabelSpot, segment: Segment): boolean =>
285
+ spot.x + spot.width / 2 > Math.min(segment.x1, segment.x2) - LABEL_PAD &&
286
+ spot.x - spot.width / 2 < Math.max(segment.x1, segment.x2) + LABEL_PAD &&
287
+ spot.y + spot.height / 2 > Math.min(segment.y1, segment.y2) - LABEL_PAD &&
288
+ spot.y - spot.height / 2 < Math.max(segment.y1, segment.y2) + LABEL_PAD
289
+ const clearOfBoxes = (spot: LabelSpot): boolean => {
290
+ for (const box of boxes) if (hitsBox(spot, box)) return false
291
+ for (const earlier of placed) if (hitsSpot(spot, earlier)) return false
292
+ return true
293
+ }
294
+ const clearOfEdges = (spot: LabelSpot): boolean => {
295
+ for (const segment of segments) if (hitsSegment(spot, segment)) return false
296
+ return true
297
+ }
298
+ const middle = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }
299
+ // The run is axis-aligned, so its Manhattan length is its length.
300
+ const dx = to.x - from.x
301
+ const dy = to.y - from.y
302
+ const length = Math.abs(dx) + Math.abs(dy)
303
+ const ux = dx / length
304
+ const uy = dy / length
305
+ // The walk is bounded so the label's own rectangle stays on the run that names it: a mask that hangs off the
306
+ // end of its segment would be half over the next thing and would not read as sitting on this edge.
307
+ const along = (Math.abs(ux) * width) / 2 + (Math.abs(uy) * height) / 2
308
+ const bound = Math.max(0, length / 2 - along)
309
+ const walk: number[] = [0]
310
+ for (let step = LABEL_STEP; step <= bound + 0.001; step += LABEL_STEP) walk.push(step, -step)
311
+ const at = (offset: number): LabelSpot => spotAt(middle.x + ux * offset, middle.y + uy * offset)
312
+ for (const offset of walk) {
313
+ const spot = at(offset)
314
+ if (clearOfBoxes(spot) && clearOfEdges(spot)) return spot
315
+ }
316
+ // Nothing on the run is clear of every other edge - two runs may lie side by side for their whole length, and
317
+ // then no place on one is clear of the other. Covering a neighbour's line is the least of the three: text over
318
+ // a line still reads, while text over a box hides the box and text over text is unreadable. The label stays
319
+ // on its own edge, which is the one thing it may not give up.
320
+ for (const offset of walk) {
321
+ const spot = at(offset)
322
+ if (clearOfBoxes(spot)) return spot
323
+ }
324
+ return at(0)
325
+ }
326
+
327
+
328
+ /** The furthest right and bottom point any shape reaches. */
329
+ function drawnExtent(shapes: Shape[]): { width: number; height: number } {
330
+ let width = 0
331
+ let height = 0
332
+ const consider = (x: number, y: number): void => {
333
+ if (x > width) width = x
334
+ if (y > height) height = y
335
+ }
336
+ for (const shape of shapes) {
337
+ switch (shape.kind) {
338
+ case 'box':
339
+ case 'group':
340
+ consider(shape.x + shape.width, shape.y + shape.height)
341
+ break
342
+ case 'link':
343
+ consider(Math.max(shape.x1, shape.x2), Math.max(shape.y1, shape.y2))
344
+ if (shape.label !== '') consider(shape.labelX + textWidth(shape.label) / 2, shape.labelY + LABEL_HEIGHT / 2)
345
+ break
346
+ case 'path': {
347
+ for (const point of shape.points) consider(point.x, point.y)
348
+ if (shape.label !== '') consider(shape.labelX + textWidth(shape.label) / 2, shape.labelY + LABEL_HEIGHT / 2)
349
+ break
350
+ }
351
+ case 'caption':
352
+ case 'title':
353
+ consider(textWidth((shape as { lines?: string[]; text?: string }).lines?.[0] ?? '') + shape.x, shape.y + 12)
354
+ break
355
+ }
356
+ }
357
+ return { width, height }
358
+ }
359
+
360
+ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, options: PaletteOptions = {}): { canvas: Canvas; colours: Palette } {
361
+ const colours = palette(overrides, options)
362
+ const shapes: Shape[] = []
363
+
364
+ const groups = diagram.groups ?? []
365
+ const topOffset = MARGIN + TITLE_HEIGHT
366
+ const placement = place(diagram, topOffset)
367
+ const { boxes, groupRects } = placement
368
+
369
+ // An edge may end on a boundary instead of a box: the boundary's rectangle is then the port surface, and
370
+ // the arrow head lands on its border. A group is not a member box, so every lookup that used to ask only
371
+ // `boxes` asks this instead.
372
+ const endBox = (id: string): Box | undefined => boxes.get(id) ?? groupRects.get(id)
373
+
374
+ // The symbol a box is drawn as, which decides which points on its sides an edge may meet. A boundary, and a node
375
+ // the source gave no shape, is a rectangle.
376
+ const shapeById = new Map<string, NodeShape>()
377
+ for (const node of diagram.nodes) if (node.shape !== undefined && !shapeById.has(node.id)) shapeById.set(node.id, node.shape)
378
+ const shapeOf = (id: string): NodeShape => shapeById.get(id) ?? 'rect'
379
+
380
+ // Boundaries first, so everything else is painted on top of them.
381
+ for (const group of groups) {
382
+ const rect = groupRects.get(group.id)
383
+ if (rect === undefined) continue
384
+ shapes.push({ kind: 'group', id: group.id, x: rect.x, y: rect.y, width: rect.width, height: rect.height, label: group.label, colour: 'muted' })
385
+ }
386
+
387
+ const drawn = new Set<string>()
388
+ // A Map iterator is consumed by the first pass: collision checks need a list.
389
+ const boxList = Array.from(boxes.values())
390
+ /** The edges drawn as lines, in paint order: their labels are placed once every one of them is known. */
391
+ const routes: Array<LinkShape | PathShape> = []
392
+ for (const node of diagram.nodes) {
393
+ // A state id may repeat in a malformed model: the first placement wins and the repeat is skipped.
394
+ if (drawn.has(node.id)) continue
395
+ const box = boxes.get(node.id) as Box
396
+ drawn.add(node.id)
397
+ const text = textOf(node)
398
+ shapes.push({
399
+ kind: 'box',
400
+ x: box.x,
401
+ y: box.y,
402
+ width: box.width,
403
+ height: box.height,
404
+ label: labelOf(node),
405
+ lines: text.lines,
406
+ ...(node.shape === undefined ? {} : { shape: node.shape }),
407
+ colour: roleOfNode(node),
408
+ initial: diagram.init === node.id,
409
+ })
410
+ }
411
+
412
+ const roleById = new Map(diagram.nodes.map((node) => [node.id, roleOfNode(node)] as const))
413
+
414
+ // One pair may carry two statements - an association and a composition, or the same arrow written twice. They are
415
+ // two relations, not one, and one bend path between them reads as a thick line or as a mistake. So every edge of
416
+ // such a set is a line of its own: the first keeps the place the drawing gave it, and the rest take an offset
417
+ // perpendicular to the direction they run, alternating sign and growing - 0, +1, -1, +2, -2 spacings - so the set
418
+ // fans out from the pair instead of doubling it. A self loop is left out: it has no main direction to be
419
+ // perpendicular to, and its own shape is already not a line between two sides.
420
+ const multiEdgeSets = new Map<string, number[]>()
421
+ diagram.edges.forEach((edge, index) => {
422
+ if (edge.from === edge.to) return
423
+ if (endBox(edge.from) === undefined || endBox(edge.to) === undefined) return
424
+ const key = edge.from < edge.to ? edge.from + '\u0000' + edge.to : edge.to + '\u0000' + edge.from
425
+ const list = multiEdgeSets.get(key)
426
+ if (list === undefined) multiEdgeSets.set(key, [index])
427
+ else list.push(index)
428
+ })
429
+ const fanOf = new Map<number, number>()
430
+ for (const list of multiEdgeSets.values()) {
431
+ if (list.length < 2) continue
432
+ list.forEach((index, position) => {
433
+ if (position === 0) return
434
+ fanOf.set(index, (position % 2 === 1 ? 1 : -1) * Math.ceil(position / 2) * MULTI_EDGE_SPACING)
435
+ })
436
+ }
437
+
438
+ // Ports. The side comes from the route, not from comparing the two boxes' centres: a layered drawing runs
439
+ // downwards, so an edge between two rows leaves the bottom and arrives at the top, and a back edge does the
440
+ // opposite - unless the symbol has four legal points, and then the side is the point the edge was given. Where along
441
+ // the side the port sits follows the drawing: a long edge takes the point its lane meets, so the lane leaves and
442
+ // arrives square on, and a short edge points at the box it joins. Two edges that want the same place are spaced
443
+ // apart, so two ports never share a pixel.
444
+ const endKey = (index: number, end: 'exit' | 'entry'): string => String(index) + ':' + end
445
+ const endsAt = new Map<string, number>()
446
+ for (const edge of diagram.edges) {
447
+ if (edge.from === edge.to) continue
448
+ if (endBox(edge.from) === undefined || endBox(edge.to) === undefined) continue
449
+ endsAt.set(edge.from, (endsAt.get(edge.from) ?? 0) + 1)
450
+ endsAt.set(edge.to, (endsAt.get(edge.to) ?? 0) + 1)
451
+ }
452
+
453
+ /** The side each edge end's port is on, where the port is a point rather than a place along a run. */
454
+ const claimed = new Map<string, Side>()
455
+ const claimedAt = new Map<string, Side[]>()
456
+ const claim = (id: string, index: number, end: 'exit' | 'entry', side: Side): void => {
457
+ claimed.set(endKey(index, end), side)
458
+ claimedAt.set(id, [...(claimedAt.get(id) ?? []), side])
459
+ }
460
+
461
+ /**
462
+ * The side a discrete port takes: the one whose outward normal points most nearly at the other box.
463
+ *
464
+ * The score is the cosine of the angle between the normal and the direction to the other box, so 1 is dead ahead
465
+ * and the four sides of a diamond rank by how much of the way to the target each one faces. Equal scores are broken
466
+ * by the point nearest the other box - the port the route has least ground to cover from - and then by the order the
467
+ * sides are drawn in. That last tie-break is what "the earlier edge keeps the earlier point" means in practice: the
468
+ * edges are walked in source order, and each one takes the best side still free.
469
+ */
470
+ const bestSide = (id: string, other: Box): Side | undefined => {
471
+ const box = endBox(id) as Box
472
+ const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
473
+ const toward = { x: other.x + other.width / 2 - centre.x, y: other.y + other.height / 2 - centre.y }
474
+ const reach = Math.hypot(toward.x, toward.y)
475
+ const taken = claimedAt.get(id) ?? []
476
+ let best: { side: Side; score: number; near: number } | undefined
477
+ for (const side of PORT_SIDES) {
478
+ if (taken.includes(side)) continue
479
+ const normal = normalOf(side)
480
+ const score = reach === 0 ? 0 : (normal.x * toward.x + normal.y * toward.y) / reach
481
+ // How far the point is from the other box's rectangle: the nearest port is the one the edge reaches first.
482
+ const point = anchor(box, side)
483
+ const near = Math.hypot(
484
+ Math.max(other.x - point.x, 0, point.x - (other.x + other.width)),
485
+ Math.max(other.y - point.y, 0, point.y - (other.y + other.height)),
486
+ )
487
+ const better =
488
+ best === undefined ||
489
+ score > best.score + 1e-9 ||
490
+ (Math.abs(score - best.score) <= 1e-9 && near < best.near - 1e-9)
491
+ if (better) best = { side, score, near }
492
+ }
493
+ return best?.side
494
+ }
495
+
496
+ // A side the drawing has already decided is claimed first, because a point there would point the route away from
497
+ // the corridor it has to run in: a long edge leaves by the side its lane starts from, and an edge read across two
498
+ // wrapped columns leaves and arrives on the sides the reading order turns at.
499
+ diagram.edges.forEach((edge, index) => {
500
+ if (edge.from === edge.to) return
501
+ const source = endBox(edge.from)
502
+ const target = endBox(edge.to)
503
+ if (source === undefined || target === undefined) return
504
+ if (placement.across.has(index)) {
505
+ claim(edge.from, index, 'exit', 'right')
506
+ claim(edge.to, index, 'entry', 'left')
507
+ return
508
+ }
509
+ const lane = placement.lanes.get(index)
510
+ if (lane === undefined || lane.points.length === 0) return
511
+ const down = target.y > source.y
512
+ claim(edge.from, index, 'exit', down ? 'bottom' : 'top')
513
+ claim(edge.to, index, 'entry', down ? 'top' : 'bottom')
514
+ })
515
+
516
+ // Then a symbol with four legal points gives every edge end a point of its own, in source order, until the four are
517
+ // gone. Only a node that needs one port, or one whose points are all spoken for, keeps the old answer - the side the
518
+ // route approaches by - and then two edges share that side's single point and fan apart later. That is the fallback,
519
+ // and it is why a decision with two labelled branches no longer draws both of them from one vertex.
520
+ diagram.edges.forEach((edge, index) => {
521
+ if (edge.from === edge.to) return
522
+ const source = endBox(edge.from)
523
+ const target = endBox(edge.to)
524
+ if (source === undefined || target === undefined) return
525
+ for (const end of ['exit', 'entry'] as const) {
526
+ if (claimed.has(endKey(index, end))) continue
527
+ const id = end === 'exit' ? edge.from : edge.to
528
+ if ((endsAt.get(id) ?? 0) < 2) continue
529
+ if (!hasDiscretePorts(shapeOf(id))) continue
530
+ const side = bestSide(id, end === 'exit' ? target : source)
531
+ if (side === undefined) continue
532
+ claim(id, index, end, side)
533
+ }
534
+ })
535
+
536
+ const facing = diagram.edges.map((edge, index): Facing | undefined => {
537
+ const source = endBox(edge.from)
538
+ const target = endBox(edge.to)
539
+ if (source === undefined || target === undefined || edge.from === edge.to) return undefined
540
+ // A wrapped drawing is read in columns: the edge from one column to the next leaves and arrives at the side,
541
+ // which is the direction that reading order travels. A column is always to the right of the one before it.
542
+ const sides = placement.across.has(index)
543
+ ? { exit: 'right' as Side, entry: 'left' as Side }
544
+ : approachSides(source, target)
545
+ const lane = placement.lanes.get(index)
546
+ if (lane === undefined || lane.points.length === 0) {
547
+ return {
548
+ exit: claimed.get(endKey(index, 'exit')) ?? sides.exit,
549
+ entry: claimed.get(endKey(index, 'entry')) ?? sides.entry,
550
+ }
551
+ }
552
+ // A lane runs down the page, so its ends face the rows above and below wherever the box centres are.
553
+ const down = target.y > source.y
554
+ return {
555
+ exit: claimed.get(endKey(index, 'exit')) ?? (down ? 'bottom' : 'top'),
556
+ entry: claimed.get(endKey(index, 'entry')) ?? (down ? 'top' : 'bottom'),
557
+ at: lane.points[0].x,
558
+ }
559
+ })
560
+ // A long edge's lane is where the whole edge runs, so a fan moves the lane instead of bending the edge twice at its
561
+ // ends. It tries the place that keeps the set evenly spaced first, then its own place moved by the same offset; a
562
+ // move that would cross a box is refused, because an edge through a box is the worse defect. Refused, the edge keeps
563
+ // its lane - the two ports still differ, because every long edge already has a lane of its own.
564
+ const laneClear = (points: Point[], x: number, from: Box, to: Box): boolean => {
565
+ for (let at = 0; at + 1 < points.length; at += 1) {
566
+ for (const box of boxList) {
567
+ if (box === from || box === to) continue
568
+ if (crossesBox(x, points[at].y, x, points[at + 1].y, box)) return false
569
+ }
570
+ }
571
+ return true
572
+ }
573
+ const laneShift = new Map<number, number>()
574
+ for (const list of multiEdgeSets.values()) {
575
+ if (list.length < 2) continue
576
+ const lead = placement.lanes.get(list[0] as number)
577
+ if (lead === undefined || lead.points.length === 0) continue
578
+ const base = (lead.points[0] as Point).x
579
+ const taken = new Set<number>()
580
+ for (const index of list) {
581
+ const lane = placement.lanes.get(index) as { points: Point[] }
582
+ const own = lane.points[0].x
583
+ const offset = fanOf.get(index) ?? 0
584
+ const edge = diagram.edges[index] as DiagramEdge
585
+ const from = endBox(edge.from) as Box
586
+ const to = endBox(edge.to) as Box
587
+ let target = own
588
+ if (offset !== 0) {
589
+ for (const candidate of [base + offset, own + offset]) {
590
+ if (taken.has(candidate) || !laneClear(lane.points, candidate, from, to)) continue
591
+ target = candidate
592
+ break
593
+ }
594
+ }
595
+ taken.add(target)
596
+ if (target !== own) laneShift.set(index, target - own)
597
+ }
598
+ }
599
+
600
+ const wanted = new Map<string, number>()
601
+ const slotKey = (boxId: string, side: Side, index: number): string => boxId + ':' + side + ':' + String(index)
602
+ /**
603
+ * Where a port may sit on one side of one node, as the interval its coordinate may take.
604
+ *
605
+ * This is the per-symbol rule, and it is why the rule lives here: a port slot is chosen in this module, so this is
606
+ * where the symbol has to be known. A rectangle spreads a port along the side, and a subroutine does the same. A
607
+ * diamond's side meets its outline at exactly one point - the vertex - so its interval is that point, and a circle's
608
+ * is its cardinal point; a point is all that side has to give, so two edges on one side share it rather than take
609
+ * two points in the air. A hexagon's top and bottom are the flat run between the cut corners, and its left and right
610
+ * are the vertices. A stadium's flat run stops at the end caps, and a cylinder's flat sides are the two vertical
611
+ * runs, never the caps.
612
+ *
613
+ * A zero-width interval is not a failure: the packer keeps every port on it, so a symbol that offers one point on a
614
+ * side hands that point to every edge that wants it. Which is why a symbol with four points is given one edge per
615
+ * side first - the point is only shared when the four are taken.
616
+ */
617
+ const spanOf = (boxId: string, side: Side, corner: boolean): { low: number; high: number } => {
618
+ const box = endBox(boxId) as Box
619
+ const vertical = side === 'top' || side === 'bottom'
620
+ const middle = vertical ? box.x + box.width / 2 : box.y + box.height / 2
621
+ const within = (low: number, high: number): { low: number; high: number } => ({ low, high: Math.max(low, high) })
622
+ switch (shapeOf(boxId)) {
623
+ // The four vertices of a diamond, and the four cardinal points of an ellipse, are the middles of its sides.
624
+ case 'diamond':
625
+ case 'circle':
626
+ return within(middle, middle)
627
+ case 'hexagon':
628
+ return vertical ? within(box.x + hexagonCut(box.width), box.x + box.width - hexagonCut(box.width)) : within(middle, middle)
629
+ case 'stadium': {
630
+ const radius = stadiumRadius(box)
631
+ return vertical ? within(box.x + radius, box.x + box.width - radius) : within(middle, middle)
632
+ }
633
+ case 'cylinder': {
634
+ if (vertical) return within(middle, middle)
635
+ const cap = cylinderCaps(box).cap
636
+ return within(box.y + cap, box.y + box.height - cap)
637
+ }
638
+ default: {
639
+ // A rectangle, and a subroutine's doubled side, keep the margins they have always had; `corner` relaxes them
640
+ // because a side that is crowded uses its corners.
641
+ const inset = corner ? 2 : vertical ? PORT_INSET : 8
642
+ return vertical ? within(box.x + inset, box.x + box.width - inset) : within(box.y + inset, box.y + box.height - inset)
643
+ }
644
+ }
645
+ }
646
+ const clampTo = (boxId: string, side: Side, value: number, corner: boolean): number => {
647
+ const span = spanOf(boxId, side, corner)
648
+ return Math.min(span.high, Math.max(span.low, value))
649
+ }
650
+ /**
651
+ * The boundary point a coordinate along one side takes.
652
+ *
653
+ * The box's rectangle is the point for every symbol whose side is the box's side. A cylinder's top and bottom are
654
+ * arcs, so a port there sits on the cap curve's apex - `cap / 4` inside the box, not on the box's own edge.
655
+ */
656
+ const pointOn = (boxId: string, side: Side, along: number): Point => {
657
+ const box = endBox(boxId) as Box
658
+ if (side === 'left') return { x: box.x, y: along }
659
+ if (side === 'right') return { x: box.x + box.width, y: along }
660
+ if (side === 'top') return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'top').y : box.y }
661
+ return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'bottom').y : box.y + box.height }
662
+ }
663
+ for (const [index, entry] of facing.entries()) {
664
+ if (entry === undefined) continue
665
+ const edge = diagram.edges[index]
666
+ const from = endBox(edge.from) as Box
667
+ const to = endBox(edge.to) as Box
668
+ const middle = (box: Box, side: Side): number => (isVerticalSide(side) ? box.x + box.width / 2 : box.y + box.height / 2)
669
+ const fromMiddle = middle(from, entry.exit)
670
+ const toMiddle = middle(to, entry.entry)
671
+ // Both ports sit on one line - and the edge is a straight run - when the source's middle falls on the target's
672
+ // side; otherwise the line is the target's middle clamped into the source's side, which is as close to a straight
673
+ // run as a box that does not overlap the other allows. A long edge asks for the point its own lane meets instead,
674
+ // and takes the corner nearest it when the lane runs outside the box: that edge turns anyway, and a straight edge
675
+ // made to give up its port would gain two bends for nothing.
676
+ const corner = entry.at !== undefined
677
+ // The k-th edge of a multi-edge set runs on a line of its own, perpendicular to the direction it runs: the fan's
678
+ // offset is added to the side's own coordinate, which is x for a vertical run and y for a horizontal one. A long
679
+ // edge carries the offset in its lane instead, so the whole edge moves rather than gaining two bends.
680
+ const offset = fanOf.get(index) ?? 0
681
+ let exitWanted: number
682
+ let entryWanted: number
683
+ if (entry.at !== undefined) {
684
+ const chosen = entry.at + (laneShift.get(index) ?? 0)
685
+ exitWanted = clampTo(edge.from, entry.exit, chosen, corner)
686
+ entryWanted = clampTo(edge.to, entry.entry, chosen, corner)
687
+ } else if (isVerticalSide(entry.exit) === isVerticalSide(entry.entry)) {
688
+ // Two ports on sides of the same kind: one coordinate serves both, as it always has.
689
+ const target = spanOf(edge.to, entry.entry, false)
690
+ const chosen = (fromMiddle >= target.low && fromMiddle <= target.high ? fromMiddle : clampTo(edge.from, entry.exit, toMiddle, false)) + offset
691
+ exitWanted = clampTo(edge.from, entry.exit, chosen, corner)
692
+ entryWanted = clampTo(edge.to, entry.entry, chosen, corner)
693
+ } else {
694
+ // The two ports sit on sides of different kinds, which only a discrete point causes. The end that is fixed on
695
+ // its symbol's point keeps that point's coordinate; the other one sits where the route actually arrives - one
696
+ // step out of the fixed port along its own normal - so the two legs of the route meet in a straight drop
697
+ // instead of jogging sideways to reach a middle that the route never passes.
698
+ exitWanted = hasDiscretePorts(shapeOf(edge.from))
699
+ ? fromMiddle
700
+ : clampTo(edge.from, entry.exit, anchor(to, entry.entry).x + normalOf(entry.entry).x * ROUTE_STUB, false) + offset
701
+ entryWanted = hasDiscretePorts(shapeOf(edge.to))
702
+ ? toMiddle
703
+ : clampTo(edge.to, entry.entry, anchor(from, entry.exit).x + normalOf(entry.exit).x * ROUTE_STUB, false) + offset
704
+ }
705
+ wanted.set(slotKey(edge.from, entry.exit, index), exitWanted)
706
+ wanted.set(slotKey(edge.to, entry.entry, index), entryWanted)
707
+ }
708
+ const sides = new Map<string, Array<{ index: number; wanted: number }>>()
709
+ for (const [index, entry] of facing.entries()) {
710
+ if (entry === undefined) continue
711
+ const edge = diagram.edges[index]
712
+ sides.set(edge.from + ':' + entry.exit, [
713
+ ...(sides.get(edge.from + ':' + entry.exit) ?? []),
714
+ { index, wanted: wanted.get(slotKey(edge.from, entry.exit, index)) as number },
715
+ ])
716
+ sides.set(edge.to + ':' + entry.entry, [
717
+ ...(sides.get(edge.to + ':' + entry.entry) ?? []),
718
+ { index, wanted: wanted.get(slotKey(edge.to, entry.entry, index)) as number },
719
+ ])
720
+ }
721
+ // Pack every side, then hand each entry the position its exit was given: a pair that faced each other sat on one x
722
+ // before packing, and an edge the packing had to move should keep its straight run rather than gain two bends.
723
+ let placedPort = new Map<string, number>()
724
+ for (let round = 0; round < 3; round += 1) {
725
+ placedPort = new Map<string, number>()
726
+ for (const [key, list] of sides) {
727
+ const [boxId, side] = key.split(':') as [string, Side]
728
+ const span = spanOf(boxId, side, true)
729
+ const order = [...list].sort((left, right) => left.wanted - right.wanted || left.index - right.index)
730
+ const packed = packPorts(
731
+ order.map((entry) => clampTo(boxId, side, entry.wanted, true)),
732
+ span.low,
733
+ span.high,
734
+ )
735
+ order.forEach((entry, at) => placedPort.set(slotKey(boxId, side, entry.index), packed[at]))
736
+ }
737
+ let moved = false
738
+ for (const [index, entry] of facing.entries()) {
739
+ if (entry === undefined) continue
740
+ const edge = diagram.edges[index]
741
+ const vertical = entry.exit === 'bottom' || entry.exit === 'top'
742
+ if (!vertical) continue
743
+ const exit = placedPort.get(slotKey(edge.from, entry.exit, index)) as number
744
+ if (Math.abs(exit - (placedPort.get(slotKey(edge.to, entry.entry, index)) as number)) < 0.01) continue
745
+ // Only an entry that can reach the exit's x asks for it: a box that does not span it keeps its own point.
746
+ if (Math.abs(clampTo(edge.to, entry.entry, exit, true) - exit) > 0.01) continue
747
+ for (const slot of sides.get(edge.to + ':' + entry.entry) as Array<{ index: number; wanted: number }>) {
748
+ if (slot.index !== index) continue
749
+ slot.wanted = exit
750
+ moved = true
751
+ }
752
+ }
753
+ if (!moved) break
754
+ }
755
+ const portOf = (boxId: string, side: Side, edgeIndex: number): Point => {
756
+ const coordinate = placedPort.get(slotKey(boxId, side, edgeIndex)) as number
757
+ return pointOn(boxId, side, coordinate)
758
+ }
759
+ const anchorsFor = new Map<number, Ports>()
760
+ for (const [index, entry] of facing.entries()) {
761
+ if (entry === undefined) continue
762
+ const edge = diagram.edges[index]
763
+ anchorsFor.set(index, {
764
+ exit: portOf(edge.from, entry.exit, index),
765
+ entry: portOf(edge.to, entry.entry, index),
766
+ exitSide: entry.exit,
767
+ entrySide: entry.entry,
768
+ })
769
+ }
770
+
771
+ /** The longest straight run of a polyline: where a label has the most room. */
772
+ const longestRun = (points: Point[]): { from: Point; to: Point } => {
773
+ let best = { from: points[0], to: points[points.length - 1], length: -1 }
774
+ for (let index = 0; index + 1 < points.length; index += 1) {
775
+ const from = points[index]
776
+ const to = points[index + 1]
777
+ const length = Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
778
+ if (length > best.length) best = { from, to, length }
779
+ }
780
+ return best
781
+ }
782
+
783
+ /** The points a drawn edge passes through, whichever of the two shapes carries it. */
784
+ const pointsOfRoute = (shape: LinkShape | PathShape): Point[] =>
785
+ shape.kind === 'path' ? shape.points : [{ x: shape.x1, y: shape.y1 }, { x: shape.x2, y: shape.y2 }]
786
+
787
+ /** The straight pieces an edge draws, so a label can tell its own line from every other one. */
788
+ const segmentsOfRoute = (shape: LinkShape | PathShape): Segment[] => {
789
+ const points = pointsOfRoute(shape)
790
+ const out: Segment[] = []
791
+ for (let index = 0; index + 1 < points.length; index += 1) {
792
+ const from = points[index]
793
+ const to = points[index + 1]
794
+ out.push({ x1: from.x, y1: from.y, x2: to.x, y2: to.y })
795
+ }
796
+ return out
797
+ }
798
+
799
+ /** Paint one edge as a polyline. Its label is placed once every edge is drawn; see the pass after the loop. */
800
+ const paintPath = (edge: DiagramEdge, points: Point[], colour: ColourRole): void => {
801
+ const shape: PathShape = {
802
+ kind: 'path',
803
+ points,
804
+ label: edge.label,
805
+ labelX: 0,
806
+ labelY: 0,
807
+ colour,
808
+ ...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
809
+ ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
810
+ }
811
+ shapes.push(shape)
812
+ routes.push(shape)
813
+ }
814
+
815
+ /** Two points are a line and more of them are a polyline; both are an edge carrying a label. */
816
+ const paintRoute = (edge: DiagramEdge, points: Point[], colour: ColourRole): void => {
817
+ if (points.length > 2) {
818
+ paintPath(edge, points, colour)
819
+ return
820
+ }
821
+ const exit = points[0]
822
+ const entry = points[points.length - 1]
823
+ const shape: LinkShape = {
824
+ kind: 'link',
825
+ x1: exit.x,
826
+ y1: exit.y,
827
+ x2: entry.x,
828
+ y2: entry.y,
829
+ label: edge.label,
830
+ labelX: 0,
831
+ labelY: 0,
832
+ colour,
833
+ ...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
834
+ ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
835
+ }
836
+ shapes.push(shape)
837
+ routes.push(shape)
838
+ }
839
+
840
+ /**
841
+ * The polyline through a run: out of one port, across the strip between two rows, and into the other, square to
842
+ * whatever side each port belongs to.
843
+ *
844
+ * Two ports on top and bottom sides come out as the polyline the run always drew. A port on a left or right side
845
+ * cannot be entered by a vertical drop, so the route steps out of it by one stub first and the strip meets the port
846
+ * at a right angle: the edge leaves - or arrives - by the side its point belongs to, which is the side the router is
847
+ * told about too.
848
+ */
849
+ const runRoute = (ports: Ports, runY: number): Point[] => {
850
+ const out = isVerticalSide(ports.exitSide) ? 0 : normalOf(ports.exitSide).x * ROUTE_STUB
851
+ const into = isVerticalSide(ports.entrySide) ? 0 : normalOf(ports.entrySide).x * ROUTE_STUB
852
+ const points: Point[] = [ports.exit]
853
+ if (out !== 0) points.push({ x: ports.exit.x + out, y: ports.exit.y })
854
+ points.push({ x: ports.exit.x + out, y: runY }, { x: ports.entry.x + into, y: runY })
855
+ if (into !== 0) points.push({ x: ports.entry.x + into, y: ports.entry.y })
856
+ points.push(ports.entry)
857
+ return simplify(points)
858
+ }
859
+
860
+ /**
861
+ * Can the run draw this edge at all?
862
+ *
863
+ * A port on a top or bottom side faces along the vertical run, and the strip has to be on the side the port faces. A
864
+ * point that faces away - the last of a diamond's four, when the others are taken - would have the route leave the
865
+ * port and come straight back through its own box. Such an edge is the router's to draw: going round a box is what
866
+ * the router does, and it knows the box is there.
867
+ */
868
+ const runFits = (ports: Ports, runY: number): boolean => {
869
+ if (isVerticalSide(ports.exitSide) && Math.abs(runY - ports.exit.y) > 0.5) {
870
+ if (Math.sign(runY - ports.exit.y) !== Math.sign(normalOf(ports.exitSide).y)) return false
871
+ }
872
+ if (isVerticalSide(ports.entrySide) && Math.abs(ports.entry.y - runY) > 0.5) {
873
+ if (Math.sign(ports.entry.y - runY) !== -Math.sign(normalOf(ports.entrySide).y)) return false
874
+ }
875
+ return true
876
+ }
877
+
878
+ /** Does the route pass through a box it does not attach to? A run is an empty strip; a step out of a side may not be. */
879
+ const clearsBoxes = (points: Point[], from: Box, to: Box): boolean => {
880
+ for (let at = 0; at + 1 < points.length; at += 1) {
881
+ const a = points[at]
882
+ const b = points[at + 1]
883
+ for (const box of boxList) {
884
+ if (box === from || box === to) continue
885
+ if (crossesBox(a.x, a.y, b.x, b.y, box)) return false
886
+ }
887
+ }
888
+ return true
889
+ }
890
+
891
+ for (const [edgeIndex, edge] of diagram.edges.entries()) {
892
+ const source = endBox(edge.from)
893
+ const target = endBox(edge.to)
894
+ if (source === undefined || target === undefined) continue
895
+ const colour = roleById.get(edge.from) ?? 'muted'
896
+
897
+ if (edge.from === edge.to) {
898
+ const x = source.x + source.width
899
+ const y = source.y + source.height / 2
900
+ shapes.push({
901
+ kind: 'path',
902
+ points: [
903
+ { x, y: y - 9 },
904
+ { x: x + 14, y: y - 9 },
905
+ { x: x + 14, y: y + 9 },
906
+ { x, y: y + 9 },
907
+ ],
908
+ label: edge.label,
909
+ // The one label that is not on its own edge: a self loop's longest run is eighteen points, and its name
910
+ // does not fit on that. It sits beside the loop, where it belongs to the edge and touches nothing.
911
+ labelX: x + 20 + textWidth(edge.label) / 2,
912
+ labelY: y,
913
+ colour,
914
+ ...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
915
+ ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
916
+ })
917
+ continue
918
+ }
919
+
920
+ const anchors = anchorsFor.get(edgeIndex)
921
+
922
+ // A long edge follows the lane the channel stage gave it: down the exit stub, across to the lane's x on the run
923
+ // the gap was assigned, down the lane, and across to the entry stub. The spine carries one point per gap, so the
924
+ // two runs are the places where a port and the lane disagree, and everything between them is one straight drop.
925
+ const lane = placement.lanes.get(edgeIndex)
926
+ if (lane !== undefined && lane.points.length > 0 && anchors !== undefined) {
927
+ const shift = laneShift.get(edgeIndex) ?? 0
928
+ const ordered = lane.reversed ? [...lane.points].reverse() : lane.points
929
+ const waypoints = shift === 0 ? ordered : ordered.map((point) => ({ x: point.x + shift, y: point.y }))
930
+ const points: Point[] = [anchors.exit]
931
+ let previous = anchors.exit
932
+ for (const waypoint of waypoints) {
933
+ if (Math.abs(previous.x - waypoint.x) > 0.5 && Math.abs(previous.y - waypoint.y) > 0.5) points.push({ x: previous.x, y: waypoint.y })
934
+ points.push(waypoint)
935
+ previous = waypoint
936
+ }
937
+ if (Math.abs(previous.x - anchors.entry.x) > 0.5 && Math.abs(previous.y - anchors.entry.y) > 0.5) points.push({ x: anchors.entry.x, y: previous.y })
938
+ points.push(anchors.entry)
939
+ paintPath(edge, simplify(points), colour)
940
+ continue
941
+ }
942
+
943
+ // Two rows with nothing between them: the edge is the two-bend run the channel stage stacked. A run sits in the
944
+ // empty strip between its own two rows and inside its own component's column, so it cannot meet a box - unless a
945
+ // port on a side steps out of it, and then the step is checked and a route that would meet a box is the router's.
946
+ const runY = placement.runs.get(edgeIndex)
947
+ if (runY !== undefined && anchors !== undefined && runFits(anchors, runY)) {
948
+ const points = runRoute(anchors, runY)
949
+ const mixed = isVerticalSide(anchors.exitSide) !== isVerticalSide(anchors.entrySide)
950
+ if (!mixed || clearsBoxes(points, source, target)) {
951
+ paintRoute(edge, points, colour)
952
+ continue
953
+ }
954
+ }
955
+
956
+ paintRoute(edge, routeEdge(source, target, boxList, anchors).points, colour)
957
+ }
958
+
959
+ // Labels last. A label has to clear every other edge, and until the last edge is drawn the painter does not know
960
+ // where those are: this is why the whole pass exists rather than a decision made edge by edge.
961
+ const routeSegments = routes.map((shape) => segmentsOfRoute(shape))
962
+ const placedLabels: LabelSpot[] = []
963
+ routes.forEach((shape, index) => {
964
+ const run = longestRun(pointsOfRoute(shape))
965
+ // The middle of the longest run is where the label belongs; a label with nothing to avoid stays exactly there.
966
+ let spot: LabelSpot = { x: (run.from.x + run.to.x) / 2, y: (run.from.y + run.to.y) / 2, width: textWidth(shape.label), height: LABEL_HEIGHT }
967
+ if (shape.label !== '') {
968
+ spot = placeLabel(shape.label, run.from, run.to, boxList, placedLabels, routeSegments.flatMap((list, at) => (at === index ? [] : list)))
969
+ placedLabels.push(spot)
970
+ }
971
+ shape.labelX = spot.x
972
+ shape.labelY = spot.y
973
+ })
974
+
975
+ shapes.push({ kind: 'title', x: MARGIN, y: MARGIN + 12, text: diagram.title, colour: 'text' })
976
+
977
+ const contentRight = Math.max(
978
+ MARGIN,
979
+ ...Array.from(boxes.values(), (box) => box.x + box.width),
980
+ ...Array.from(groupRects.values(), (rect) => rect.x + rect.width),
981
+ )
982
+ const captionLines = wrap(diagram.caption, CAPTION_CHARS)
983
+ const captionWidth = Math.max(...captionLines.map((line) => line.length * CAPTION_CHAR_WIDTH)) + MARGIN * 2
984
+ const width = Math.max(contentRight + MARGIN, captionWidth, 240)
985
+
986
+ const contentBottom = Math.max(
987
+ topOffset,
988
+ ...Array.from(boxes.values(), (box) => box.y + box.height),
989
+ ...Array.from(groupRects.values(), (rect) => rect.y + rect.height),
990
+ )
991
+ const captionTop = contentBottom + CAPTION_GAP
992
+ shapes.push({ kind: 'caption', x: MARGIN, y: captionTop, lines: captionLines, colour: captionRole(diagram.caption) })
993
+ const height = captionTop + captionLines.length * CAPTION_LINE + MARGIN / 2
994
+
995
+ // Everything drawn counts, including the parts that leave the box grid: an edge routed beside the drawing and a
996
+ // label wider than the gap between two boxes were both cut off at the right edge.
997
+ const extent = drawnExtent(shapes)
998
+ const canvasWidth = Math.max(width, extent.width + MARGIN)
999
+ const canvasHeight = Math.max(height, extent.height + MARGIN)
1000
+
1001
+ return { canvas: { width: Math.round(canvasWidth), height: Math.round(canvasHeight), shapes }, colours }
1002
+ }
1003
+
1004
+ export function paint(diagram: Diagram, overrides: Partial<Palette> = {}): Canvas {
1005
+ return paintWith(diagram, overrides).canvas
1006
+ }