dsh-model-everything 0.5.0 → 0.5.2

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 (65) hide show
  1. package/README.en-US.md +12 -0
  2. package/README.md +4 -0
  3. package/lib/client.js +2829 -224
  4. package/lib/render/channels.js +243 -0
  5. package/lib/render/collapse.js +12 -20
  6. package/lib/render/components.js +99 -0
  7. package/lib/render/coords.js +288 -0
  8. package/lib/render/cycle-break.js +147 -0
  9. package/lib/render/dummy.js +43 -0
  10. package/lib/render/from-mermaid.js +34 -10
  11. package/lib/render/from-plantuml.js +318 -46
  12. package/lib/render/layer.js +91 -0
  13. package/lib/render/layout.js +27 -11
  14. package/lib/render/network-simplex.js +272 -0
  15. package/lib/render/order.js +122 -0
  16. package/lib/render/paint.js +1227 -69
  17. package/lib/render/place.js +393 -121
  18. package/lib/render/route.js +225 -0
  19. package/lib/render/svg.js +146 -21
  20. package/lib/render/symbol.js +108 -0
  21. package/lib/render/text.js +74 -0
  22. package/lib/render/trunk.js +213 -0
  23. package/lib/render/wrap.js +87 -0
  24. package/lib/types/render/channels.d.ts +63 -0
  25. package/lib/types/render/components.d.ts +57 -0
  26. package/lib/types/render/coords.d.ts +64 -0
  27. package/lib/types/render/cycle-break.d.ts +45 -0
  28. package/lib/types/render/dummy.d.ts +32 -0
  29. package/lib/types/render/from-plantuml.d.ts +12 -6
  30. package/lib/types/render/layer.d.ts +28 -0
  31. package/lib/types/render/layout.d.ts +43 -11
  32. package/lib/types/render/model.d.ts +17 -0
  33. package/lib/types/render/network-simplex.d.ts +54 -0
  34. package/lib/types/render/order.d.ts +27 -0
  35. package/lib/types/render/paint.d.ts +157 -10
  36. package/lib/types/render/place.d.ts +40 -19
  37. package/lib/types/render/route.d.ts +77 -0
  38. package/lib/types/render/svg.d.ts +1 -1
  39. package/lib/types/render/symbol.d.ts +73 -0
  40. package/lib/types/render/text.d.ts +38 -0
  41. package/lib/types/render/trunk.d.ts +104 -0
  42. package/lib/types/render/wrap.d.ts +27 -0
  43. package/package.json +148 -148
  44. package/skills/model-everything-diagram/SKILL.md +85 -82
  45. package/src/render/channels.ts +309 -0
  46. package/src/render/collapse.ts +150 -155
  47. package/src/render/components.ts +126 -0
  48. package/src/render/coords.ts +328 -0
  49. package/src/render/cycle-break.ts +164 -0
  50. package/src/render/dummy.ts +65 -0
  51. package/src/render/from-mermaid.ts +261 -236
  52. package/src/render/from-plantuml.ts +469 -178
  53. package/src/render/layer.ts +98 -0
  54. package/src/render/layout.ts +101 -62
  55. package/src/render/model.ts +86 -67
  56. package/src/render/network-simplex.ts +290 -0
  57. package/src/render/order.ts +139 -0
  58. package/src/render/paint.ts +1570 -290
  59. package/src/render/place.ts +532 -234
  60. package/src/render/route.ts +269 -0
  61. package/src/render/svg.ts +274 -127
  62. package/src/render/symbol.ts +137 -0
  63. package/src/render/text.ts +86 -0
  64. package/src/render/trunk.ts +275 -0
  65. package/src/render/wrap.ts +103 -0
@@ -1,290 +1,1570 @@
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
- * Two placements, chosen by the source:
9
- *
10
- * - **layered** (no groups): breadth-first rows, forward edges dropping straight down, back edges on a side
11
- * channel clear of every box, self loops as small loops. The shape a state machine wants.
12
- * - **tree** (groups present): a group is a block, its members stack inside it, a nested group is a block
13
- * inside its parent, and the top level stacks. The shape an architecture diagram wants, because a package
14
- * whose members float somewhere else on the page is not showing containment at all.
15
- *
16
- * Edges are placed from where the boxes ended up, so neither placement needs to tell the edge code which one
17
- * ran: "below" is a straight line, "beside" is horizontal, "above" routes around.
18
- *
19
- * Pure: a diagram in, shapes out, every decision assertable.
20
- */
21
-
22
- import { MARGIN, place, type Box } from './place.js'
23
- import { palette, roleForVerdict, type ColourRole, type Palette, type PaletteOptions } from './palette.js'
24
- import { labelOf, type Diagram, type DiagramGroup, type DiagramNode } from './model.js'
25
-
26
- export interface Point {
27
- x: number
28
- y: number
29
- }
30
-
31
- export interface BoxShape {
32
- kind: 'box'
33
- x: number
34
- y: number
35
- width: number
36
- height: number
37
- label: string
38
- colour: ColourRole
39
- initial: boolean
40
- }
41
-
42
- export interface LinkShape {
43
- kind: 'link'
44
- x1: number
45
- y1: number
46
- x2: number
47
- y2: number
48
- label: string
49
- labelX: number
50
- labelY: number
51
- colour: ColourRole
52
- }
53
-
54
- export interface PathShape {
55
- kind: 'path'
56
- points: Point[]
57
- label: string
58
- labelX: number
59
- labelY: number
60
- colour: ColourRole
61
- }
62
-
63
- /** A boundary around several states: a package, a layer, a subgraph. */
64
- export interface GroupShape {
65
- kind: 'group'
66
- /** The group's own id, so a container can fold it without guessing from the label. */
67
- id: string
68
- x: number
69
- y: number
70
- width: number
71
- height: number
72
- label: string
73
- colour: ColourRole
74
- }
75
-
76
- export interface CaptionShape {
77
- kind: 'caption'
78
- x: number
79
- y: number
80
- lines: string[]
81
- colour: ColourRole
82
- }
83
-
84
- export interface TitleShape {
85
- kind: 'title'
86
- x: number
87
- y: number
88
- text: string
89
- colour: ColourRole
90
- }
91
-
92
- export type Shape = GroupShape | BoxShape | LinkShape | PathShape | CaptionShape | TitleShape
93
-
94
- export interface Canvas {
95
- width: number
96
- height: number
97
- shapes: Shape[]
98
- }
99
-
100
- /** Geometry constants in one place: a container with different proportions changes these, not the rules. */
101
- const MIN_NODE_WIDTH = 88
102
- const MAX_NODE_WIDTH = 220
103
- const CHAR_WIDTH = 7.2
104
-
105
- const nodeWidth = (label: string): number => Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, label.length * CHAR_WIDTH + 26))
106
-
107
- const TITLE_HEIGHT = 26
108
- const NODE_HEIGHT = 34
109
- const CHANNEL_GAP = 16
110
- const CAPTION_CHARS = 78
111
- const CAPTION_LINE = 15
112
- const CAPTION_GAP = 16
113
- const CAPTION_CHAR_WIDTH = 6.2
114
-
115
- /** Which colour a box earns from its role. One mapping, used by every container. */
116
- export function roleOfNode(node: DiagramNode): ColourRole {
117
- if (node.role === 'error') return 'fail'
118
- if (node.role === 'warning') return 'findings'
119
- if (node.role === 'muted') return 'muted'
120
- return 'edge'
121
- }
122
-
123
- /** Wrap on spaces; break a token longer than the line rather than let it overflow the canvas. */
124
- function wrap(text: string, limit: number): string[] {
125
- const lines: string[] = []
126
- let line = ''
127
- for (const word of text.split(/\s+/).filter((part) => part !== '')) {
128
- const candidate = line === '' ? word : line + ' ' + word
129
- if (candidate.length <= limit) {
130
- line = candidate
131
- continue
132
- }
133
- if (line !== '') lines.push(line)
134
- line = ''
135
- let rest = word
136
- while (rest.length > limit) {
137
- lines.push(rest.slice(0, limit))
138
- rest = rest.slice(limit)
139
- }
140
- line = rest
141
- }
142
- if (line !== '') lines.push(line)
143
- return lines.length === 0 ? [''] : lines
144
- }
145
-
146
- /** The colour a caption earns, read from the verdict it quotes. */
147
- function captionRole(caption: string): ColourRole {
148
- return roleForVerdict(caption.split(' — ')[0] as string)
149
- }
150
-
151
- export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, options: PaletteOptions = {}): { canvas: Canvas; colours: Palette } {
152
- const colours = palette(overrides, options)
153
- const shapes: Shape[] = []
154
-
155
- const groups = diagram.groups ?? []
156
- const topOffset = MARGIN + TITLE_HEIGHT
157
- const placement = place(diagram, topOffset)
158
- const { boxes, groupRects } = placement
159
-
160
- // Boundaries first, so everything else is painted on top of them.
161
- for (const group of groups) {
162
- const rect = groupRects.get(group.id)
163
- if (rect === undefined) continue
164
- shapes.push({ kind: 'group', id: group.id, x: rect.x, y: rect.y, width: rect.width, height: rect.height, label: group.label, colour: 'muted' })
165
- }
166
-
167
- const drawn = new Set<string>()
168
- for (const node of diagram.nodes) {
169
- // A state id may repeat in a malformed model: the first placement wins and the repeat is skipped.
170
- if (drawn.has(node.id)) continue
171
- const box = boxes.get(node.id) as Box
172
- drawn.add(node.id)
173
- shapes.push({
174
- kind: 'box',
175
- x: box.x,
176
- y: box.y,
177
- width: box.width,
178
- height: box.height,
179
- label: labelOf(node),
180
- colour: roleOfNode(node),
181
- initial: diagram.init === node.id,
182
- })
183
- }
184
-
185
- const roleById = new Map(diagram.nodes.map((node) => [node.id, roleOfNode(node)] as const))
186
- let channel = Math.max(placement.width, ...Array.from(boxes.values(), (box) => box.x + box.width)) + CHANNEL_GAP
187
- let channelUses = 0
188
-
189
- for (const edge of diagram.edges) {
190
- const source = boxes.get(edge.from)
191
- const target = boxes.get(edge.to)
192
- if (source === undefined || target === undefined) continue
193
- const colour = roleById.get(edge.from) as ColourRole
194
- // Placement decided where things are; "below", "beside" and "above" are read from the result.
195
- const sameRow = Math.abs(target.y - source.y) < NODE_HEIGHT / 2
196
-
197
- if (edge.from === edge.to) {
198
- const x = source.x + source.width
199
- const y = source.y + source.height / 2
200
- shapes.push({
201
- kind: 'path',
202
- points: [
203
- { x, y: y - 9 },
204
- { x: x + 14, y: y - 9 },
205
- { x: x + 14, y: y + 9 },
206
- { x, y: y + 9 },
207
- ],
208
- label: edge.label,
209
- labelX: x + 20,
210
- labelY: y,
211
- colour,
212
- })
213
- continue
214
- }
215
-
216
- if (target.y > source.y && !sameRow) {
217
- const x1 = source.x + source.width / 2
218
- const y1 = source.y + source.height
219
- const x2 = target.x + target.width / 2
220
- const y2 = target.y
221
- shapes.push({ kind: 'link', x1, y1, x2, y2, label: edge.label, labelX: (x1 + x2) / 2 + 7, labelY: (y1 + y2) / 2, colour })
222
- continue
223
- }
224
-
225
- if (sameRow) {
226
- const right = source.x + source.width
227
- const left = target.x
228
- const y = source.y + source.height / 2
229
- const forward = right <= left
230
- shapes.push({
231
- kind: 'link',
232
- x1: forward ? right : source.x,
233
- y1: y,
234
- x2: forward ? left : target.x + target.width,
235
- y2: y,
236
- label: edge.label,
237
- labelX: (right + left) / 2,
238
- labelY: y - 7,
239
- colour,
240
- })
241
- continue
242
- }
243
-
244
- // Backward: leave right, run down a lane clear of every box, come back in from the right.
245
- const lane = channel + (channelUses % 3) * 8
246
- channelUses += 1
247
- channel = Math.max(channel, lane)
248
- const startY = source.y + source.height / 2
249
- const endY = target.y + target.height / 2
250
- shapes.push({
251
- kind: 'path',
252
- points: [
253
- { x: source.x + source.width, y: startY },
254
- { x: lane, y: startY },
255
- { x: lane, y: endY },
256
- { x: target.x + target.width, y: endY },
257
- ],
258
- label: edge.label,
259
- labelX: lane + 6,
260
- labelY: (startY + endY) / 2,
261
- colour,
262
- })
263
- }
264
-
265
- shapes.push({ kind: 'title', x: MARGIN, y: MARGIN + 12, text: diagram.title, colour: 'text' })
266
-
267
- const contentRight = Math.max(
268
- MARGIN,
269
- ...Array.from(boxes.values(), (box) => box.x + box.width),
270
- ...Array.from(groupRects.values(), (rect) => rect.x + rect.width),
271
- )
272
- const captionLines = wrap(diagram.caption, CAPTION_CHARS)
273
- const captionWidth = Math.max(...captionLines.map((line) => line.length * CAPTION_CHAR_WIDTH)) + MARGIN * 2
274
- const width = Math.max(contentRight + MARGIN + (channelUses > 0 ? CHANNEL_GAP * 3 : 0), captionWidth, 240)
275
-
276
- const contentBottom = Math.max(
277
- topOffset,
278
- ...Array.from(boxes.values(), (box) => box.y + box.height),
279
- ...Array.from(groupRects.values(), (rect) => rect.y + rect.height),
280
- )
281
- const captionTop = contentBottom + CAPTION_GAP
282
- shapes.push({ kind: 'caption', x: MARGIN, y: captionTop, lines: captionLines, colour: captionRole(diagram.caption) })
283
- const height = captionTop + captionLines.length * CAPTION_LINE + MARGIN / 2
284
-
285
- return { canvas: { width: Math.round(width), height: Math.round(height), shapes }, colours }
286
- }
287
-
288
- export function paint(diagram: Diagram, overrides: Partial<Palette> = {}): Canvas {
289
- return paintWith(diagram, overrides).canvas
290
- }
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. A side that carries one port is
15
+ * centred on it, and a side that carries several spreads them symmetrically about the middle; the bend budget comes
16
+ * first, so a port that is the end of a straight run or of a lane keeps the coordinate its route asked for.
17
+ *
18
+ * A symbol with discrete points gives each of its edges a point of its own: the point whose outward normal faces the
19
+ * 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
20
+ * node with a run on its side keeps doing what it always did, because there the ports are places on the run and not
21
+ * points at all. Sharing is the fallback, not the rule: two labelled branches of a decision leave by two vertices.
22
+ *
23
+ * An edge's name is drawn on the edge, not beside it: it is anchored at the midpoint of one of the edge's own straight
24
+ * runs, over a rectangle in the page colour that masks the line behind the text, and it slides along that run when
25
+ * something is in the way. It tries every run - the longest first - because on a dense drawing the longest one can be
26
+ * crowded for its whole length while a shorter run of the same edge is clear. A label that can clear nothing still
27
+ * stays on its edge, takes the place with the fewest collisions, and records what it could not avoid. Labels are
28
+ * placed after every edge is drawn, because what a label has to avoid is the whole picture.
29
+ *
30
+ * Every route is checked before it is drawn, and the assembled drawing is checked once more: a segment may touch no
31
+ * box except the two the edge terminates at. A route is a proposal - a channel run, a lane, a trunk branch, a wrapped
32
+ * column's sideways read - and any proposal that would cross a box is handed to the router instead. When even the
33
+ * router cannot clear a box, the failure is reported on the canvas rather than hidden: a drawing that says which edge
34
+ * could not be routed is worth more than one that quietly draws it through a box.
35
+ *
36
+ * Pure: a diagram in, shapes out, every decision assertable.
37
+ */
38
+
39
+ import { MARGIN, place, textOf, type Box } from './place.js'
40
+ import { PORT_INSET } from './channels.js'
41
+ import { anchor, approachSides, crossesBox, normalOf, ROUTE_STUB, routeEdge, simplify, type Ports, type Side } from './route.js'
42
+ import { planTrunks, type TrunkEnd, type TrunkGroup, type TrunkPlan } from './trunk.js'
43
+ import { cylinderApex, cylinderCaps, hasDiscretePorts, hexagonCut, stadiumRadius } from './symbol.js'
44
+ import { textWidth } from './text.js'
45
+ import { palette, roleForVerdict, type ColourRole, type Palette, type PaletteOptions } from './palette.js'
46
+ import { labelOf, type Diagram, type DiagramEdge, type DiagramGroup, type DiagramNode, type EdgeArrow, type NodeShape } from './model.js'
47
+
48
+ export interface Point {
49
+ x: number
50
+ y: number
51
+ }
52
+
53
+ export interface BoxShape {
54
+ kind: 'box'
55
+ x: number
56
+ y: number
57
+ width: number
58
+ height: number
59
+ /** The lines to draw, already wrapped; `label` keeps the whole text for anything that reads the shape. */
60
+ label: string
61
+ lines: string[]
62
+ /** The symbol to draw. Undefined means a rectangle. */
63
+ shape?: NodeShape
64
+ colour: ColourRole
65
+ initial: boolean
66
+ }
67
+
68
+ /**
69
+ * Why a label sits where it does.
70
+ *
71
+ * A clear label records `collisions: 0` and the run it chose. A label that could clear nothing records the collisions
72
+ * it could not avoid, so a reader of the drawing (or a test) can tell a crowded drawing from a placement bug. The
73
+ * label never leaves its own edge to earn any of these numbers.
74
+ */
75
+ export interface LabelPlacement {
76
+ /** The straight run it sits on, 0 being the edge's longest. */
77
+ run: number
78
+ /** Everything its mask overlaps: the other labels, boxes and edges it would have had to move off its line to clear. */
79
+ collisions: number
80
+ boxes: number
81
+ labels: number
82
+ edges: number
83
+ }
84
+
85
+ export interface LinkShape {
86
+ kind: 'link'
87
+ x1: number
88
+ y1: number
89
+ x2: number
90
+ y2: number
91
+ label: string
92
+ /** The centre the label is drawn on, and therefore a point on the edge itself. */
93
+ labelX: number
94
+ labelY: number
95
+ colour: ColourRole
96
+ /** A dashed line, when the notation distinguished one. */
97
+ dashed?: boolean
98
+ /** The arrow the source edge stated. Undefined means the plain arrow every edge has been drawn with. */
99
+ arrow?: EdgeArrow
100
+ /** Why the label sits where it does. Undefined on an edge with no label. */
101
+ labelPlacement?: LabelPlacement
102
+ }
103
+
104
+ export interface PathShape {
105
+ kind: 'path'
106
+ points: Point[]
107
+ label: string
108
+ /** The centre the label is drawn on, and therefore a point on the edge itself. */
109
+ labelX: number
110
+ labelY: number
111
+ colour: ColourRole
112
+ dashed?: boolean
113
+ arrow?: EdgeArrow
114
+ /** Why the label sits where it does. Undefined on an edge with no label. */
115
+ labelPlacement?: LabelPlacement
116
+ }
117
+
118
+ /**
119
+ * A junction dot: the place one branch of a merged bundle meets the bundle's trunk.
120
+ *
121
+ * The dot is not decoration. Two lines that meet at a right angle read as a crossing unless one of them is marked as
122
+ * joining the other, and a bundle whose branches are not marked reads as a set of broken lines rather than a merge.
123
+ */
124
+ export interface JunctionShape {
125
+ kind: 'junction'
126
+ x: number
127
+ y: number
128
+ colour: ColourRole
129
+ /** The trunk the dot belongs to: one dot per branch, one trunk id per bundle. */
130
+ trunk: number
131
+ }
132
+
133
+ /** A boundary around several states: a package, a layer, a subgraph. */
134
+ export interface GroupShape {
135
+ kind: 'group'
136
+ /** The group's own id, so a container can fold it without guessing from the label. */
137
+ id: string
138
+ x: number
139
+ y: number
140
+ width: number
141
+ height: number
142
+ label: string
143
+ colour: ColourRole
144
+ }
145
+
146
+ export interface CaptionShape {
147
+ kind: 'caption'
148
+ x: number
149
+ y: number
150
+ lines: string[]
151
+ colour: ColourRole
152
+ }
153
+
154
+ export interface TitleShape {
155
+ kind: 'title'
156
+ x: number
157
+ y: number
158
+ text: string
159
+ colour: ColourRole
160
+ }
161
+
162
+ export type Shape = GroupShape | BoxShape | LinkShape | PathShape | JunctionShape | CaptionShape | TitleShape
163
+
164
+ /** Which side of each box an edge leaves by and arrives at, and, for a long edge, the lane x it meets there. */
165
+ interface Facing {
166
+ exit: Side
167
+ entry: Side
168
+ at?: number
169
+ }
170
+
171
+ /** One segment of a drawn route that could not be kept out of a box it does not terminate at. */
172
+ export interface RoutingFailure {
173
+ /** The edge's index in the diagram. */
174
+ edge: number
175
+ from: string
176
+ to: string
177
+ /** The box the segment passes through. */
178
+ box: string
179
+ x1: number
180
+ y1: number
181
+ x2: number
182
+ y2: number
183
+ }
184
+
185
+ export interface Canvas {
186
+ width: number
187
+ height: number
188
+ shapes: Shape[]
189
+ /**
190
+ * Every place a drawn segment still passes through a box it does not terminate at, after every route was checked.
191
+ *
192
+ * A drawing should carry none. The field exists for the case where no stage and no router could find a way around:
193
+ * a route that cannot be drawn honestly is reported here rather than silently drawn through a box.
194
+ */
195
+ routingFailures: RoutingFailure[]
196
+ /** How many edge labels could not be placed clear of every box, label and other edge. */
197
+ labelCollisions: number
198
+ }
199
+
200
+ /** Geometry constants in one place: a container with different proportions changes these, not the rules. */
201
+ const TITLE_HEIGHT = 26
202
+ const CAPTION_CHARS = 78
203
+ const CAPTION_LINE = 15
204
+ const CAPTION_GAP = 16
205
+ const CAPTION_CHAR_WIDTH = 6.2
206
+
207
+ /** The closest two ports on one side may sit, so two edges never leave from the same pixel. */
208
+ const PORT_SEPARATION = 14
209
+
210
+ /**
211
+ * The sides of a box, in the order a symbol's own points are drawn: a diamond's top, right, bottom and left vertices.
212
+ *
213
+ * A shape with discrete ports has exactly one legal point per side, so this is also the order its points are offered
214
+ * to the edges that want one, and the order a tie between two equally good sides falls back to.
215
+ */
216
+ const PORT_SIDES: Side[] = ['top', 'right', 'bottom', 'left']
217
+
218
+ /** Is this one of the two sides a vertical run leaves by? A run is vertical in its own axis, not in the page. */
219
+ const isVerticalSide = (side: Side): boolean => side === 'top' || side === 'bottom'
220
+
221
+ /**
222
+ * How far apart two edges that join the same pair are drawn.
223
+ *
224
+ * Wider than the least two ports may sit: two statements about one pair have to read as two lines, and a gap the
225
+ * port packer would allow is a gap a reader sees as one thick line. Exported so a test can name the pattern.
226
+ */
227
+ export const MULTI_EDGE_SPACING = 20
228
+
229
+ /**
230
+ * Spread the ports of one side along it: each stays as close to the point it asked for as the gap to its neighbours
231
+ * allows, and the whole row is kept on the box.
232
+ *
233
+ * This is the same monotone fit coordinate assignment uses on x, applied to one side of one box: a forward pass that
234
+ * keeps every port at least `PORT_SEPARATION` to the right of the one before it, then a backward pass that pulls the
235
+ * row back onto the box without closing those gaps.
236
+ */
237
+ function packPorts(wanted: number[], low: number, high: number): number[] {
238
+ const placed: number[] = []
239
+ let previous = low - PORT_SEPARATION
240
+ for (const value of wanted) {
241
+ previous = Math.min(high, Math.max(value, previous + PORT_SEPARATION))
242
+ placed.push(previous)
243
+ }
244
+ let next = high + PORT_SEPARATION
245
+ for (let index = placed.length - 1; index >= 0; index -= 1) {
246
+ next = Math.max(low, Math.min(placed[index], next - PORT_SEPARATION))
247
+ placed[index] = next
248
+ }
249
+ return placed
250
+ }
251
+
252
+ /**
253
+ * Centre a row of ports on the side it sits on.
254
+ *
255
+ * The row keeps its order, its gaps and its separation, and is translated as a whole so its outer two ports sit
256
+ * symmetrically about the middle of the span: two ports become the middle less and plus half their gap, and a longer
257
+ * row is the same run of places the routing asked for, moved to where the side has room for it. A row that cannot
258
+ * travel the whole way stops where its last port reaches the end of the span - the alternative is a port off the box.
259
+ */
260
+ function centreRun(placed: number[], low: number, high: number): number[] {
261
+ if (placed.length === 0) return placed
262
+ const first = placed[0] as number
263
+ const last = placed[placed.length - 1] as number
264
+ const middle = (low + high) / 2
265
+ const shift = Math.min(high - last, Math.max(low - first, middle - (first + last) / 2))
266
+ return shift === 0 ? placed : placed.map((value) => value + shift)
267
+ }
268
+
269
+ /** Which colour a box earns from its role. One mapping, used by every container. */
270
+ export function roleOfNode(node: DiagramNode): ColourRole {
271
+ if (node.role === 'error') return 'fail'
272
+ if (node.role === 'warning') return 'findings'
273
+ if (node.role === 'muted') return 'muted'
274
+ return 'edge'
275
+ }
276
+
277
+ /** Wrap on spaces; break a token longer than the line rather than let it overflow the canvas. */
278
+ function wrap(text: string, limit: number): string[] {
279
+ const lines: string[] = []
280
+ let line = ''
281
+ for (const word of text.split(/\s+/).filter((part) => part !== '')) {
282
+ const candidate = line === '' ? word : line + ' ' + word
283
+ if (candidate.length <= limit) {
284
+ line = candidate
285
+ continue
286
+ }
287
+ if (line !== '') lines.push(line)
288
+ line = ''
289
+ let rest = word
290
+ while (rest.length > limit) {
291
+ lines.push(rest.slice(0, limit))
292
+ rest = rest.slice(limit)
293
+ }
294
+ line = rest
295
+ }
296
+ if (line !== '') lines.push(line)
297
+ return lines.length === 0 ? [''] : lines
298
+ }
299
+
300
+ /** The colour a caption earns, read from the verdict it quotes. */
301
+ function captionRole(caption: string): ColourRole {
302
+ return roleForVerdict(caption.split(' — ')[0] as string)
303
+ }
304
+
305
+
306
+ /**
307
+ * How tall the mask an edge label is drawn over is.
308
+ *
309
+ * Exported because two modules must agree on it: the painter reserves the rectangle when it looks for a clear
310
+ * place, and the writer draws the rectangle that was reserved. A label with two sizes is a label half masked.
311
+ */
312
+ export const LABEL_HEIGHT = 12
313
+
314
+ /** The room a label's mask keeps from anything it may not cover, so two rectangles never read as one. */
315
+ const LABEL_PAD = 2
316
+
317
+ /** How far a label steps along its own run while it looks for a clear place to sit. */
318
+ const LABEL_STEP = 3
319
+
320
+ /** A straight piece of an edge, as the collision test needs it. */
321
+ interface Segment {
322
+ x1: number
323
+ y1: number
324
+ x2: number
325
+ y2: number
326
+ }
327
+
328
+ /** Where a label sits: the point it is centred on, and the rectangle its mask covers. */
329
+ interface LabelSpot {
330
+ x: number
331
+ y: number
332
+ width: number
333
+ height: number
334
+ }
335
+
336
+ /** A straight run of an edge: one of the pieces a label may sit on. */
337
+ interface Run {
338
+ from: Point
339
+ to: Point
340
+ /** The run is axis-aligned, so its Manhattan length is its length. */
341
+ length: number
342
+ /** Where the run sits in the route, so two equal runs keep the order they are drawn in. */
343
+ index: number
344
+ }
345
+
346
+ /** The runs of one edge, longest first. A label prefers the run with the most room. */
347
+ function runsOf(points: Point[]): Run[] {
348
+ const runs: Run[] = []
349
+ for (let index = 0; index + 1 < points.length; index += 1) {
350
+ const from = points[index]
351
+ const to = points[index + 1]
352
+ const length = Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
353
+ if (length < 1) continue
354
+ runs.push({ from, to, length, index })
355
+ }
356
+ return runs.sort((left, right) => right.length - left.length || left.index - right.index)
357
+ }
358
+
359
+ /**
360
+ * The point an edge's label is drawn at.
361
+ *
362
+ * A label is not furniture parked beside the line: it names the transition, so it sits on the line - anchored on one
363
+ * of its own straight runs - and the writer masks the stretch of line it covers. Text beside a line can be read as
364
+ * belonging to whatever it happens to sit next to; text that interrupts its own line reads as part of it.
365
+ *
366
+ * The anchor then has to be earned. A mask over a box hides a border, over another label it makes both unreadable,
367
+ * and over a third edge's line it hides that line. So the label walks along a run in small steps, in both directions
368
+ * and bounded so its rectangle stays on that run, and takes the first place that is clear. It tries the longest run
369
+ * first and then the shorter ones: on a dense drawing the longest run can be crowded for its whole length while a
370
+ * shorter run of the same edge is empty, and a label that gives up after one run leaves the drawing with a collision
371
+ * it did not have to accept.
372
+ *
373
+ * When no run offers a clear place, the label takes the place with the fewest collisions - boxes and other labels
374
+ * first, because text over a border hides the border and text over text is unreadable, then other edges, which a mask
375
+ * merely interrupts. It never leaves its own edge: a label moved onto a neighbouring edge is a label that names the
376
+ * wrong thing. The choice is returned with the label, so a crowded drawing can be told from a placement bug.
377
+ *
378
+ * Exported because the choice is pure and worth asserting without a layout: a test can hand it one polyline, one box
379
+ * and one other route, and read which run it took and what it could not clear.
380
+ */
381
+ export function placeLabel(
382
+ label: string,
383
+ points: Point[],
384
+ boxes: Iterable<Box>,
385
+ placed: LabelSpot[],
386
+ otherRoutes: Segment[][],
387
+ ): LabelPlacement & LabelSpot {
388
+ const width = textWidth(label)
389
+ const height = LABEL_HEIGHT
390
+ const spotAt = (x: number, y: number): LabelSpot => ({ x, y, width, height })
391
+ const hitsBox = (spot: LabelSpot, box: Box): boolean =>
392
+ spot.x - spot.width / 2 < box.x + box.width + LABEL_PAD &&
393
+ spot.x + spot.width / 2 > box.x - LABEL_PAD &&
394
+ spot.y - spot.height / 2 < box.y + box.height + LABEL_PAD &&
395
+ spot.y + spot.height / 2 > box.y - LABEL_PAD
396
+ const hitsSpot = (spot: LabelSpot, other: LabelSpot): boolean =>
397
+ spot.x - spot.width / 2 < other.x + other.width / 2 + LABEL_PAD &&
398
+ spot.x + spot.width / 2 > other.x - other.width / 2 - LABEL_PAD &&
399
+ spot.y - spot.height / 2 < other.y + other.height / 2 + LABEL_PAD &&
400
+ spot.y + spot.height / 2 > other.y - other.height / 2 - LABEL_PAD
401
+ // 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
402
+ // covers, which on its own edge is the point of the mask and on any other edge is a line that goes missing.
403
+ const hitsSegment = (spot: LabelSpot, segment: Segment): boolean =>
404
+ spot.x + spot.width / 2 > Math.min(segment.x1, segment.x2) - LABEL_PAD &&
405
+ spot.x - spot.width / 2 < Math.max(segment.x1, segment.x2) + LABEL_PAD &&
406
+ spot.y + spot.height / 2 > Math.min(segment.y1, segment.y2) - LABEL_PAD &&
407
+ spot.y - spot.height / 2 < Math.max(segment.y1, segment.y2) + LABEL_PAD
408
+ /** Everything one candidate place overlaps, counted by the thing it covers rather than by the segment that draws it. */
409
+ const collisionsAt = (spot: LabelSpot, run: number): LabelPlacement => {
410
+ let boxHits = 0
411
+ let labelHits = 0
412
+ let edgeHits = 0
413
+ for (const box of boxes) if (hitsBox(spot, box)) boxHits += 1
414
+ for (const earlier of placed) if (hitsSpot(spot, earlier)) labelHits += 1
415
+ for (const route of otherRoutes) {
416
+ for (const segment of route) {
417
+ if (!hitsSegment(spot, segment)) continue
418
+ edgeHits += 1
419
+ break
420
+ }
421
+ }
422
+ return { run, collisions: boxHits + labelHits + edgeHits, boxes: boxHits, labels: labelHits, edges: edgeHits }
423
+ }
424
+
425
+ const runs = runsOf(points)
426
+ if (runs.length === 0) {
427
+ // A route with no straight piece has no line to sit on: keep the label at the route's own point rather than move
428
+ // it into the air. Only a degenerate route reaches this, and the writer still draws its mask.
429
+ const point = points[0] ?? { x: 0, y: 0 }
430
+ return { ...spotAt(point.x, point.y), run: -1, collisions: 0, boxes: 0, labels: 0, edges: 0 }
431
+ }
432
+
433
+ let best: (LabelPlacement & LabelSpot) | undefined
434
+ for (const [runIndex, run] of runs.entries()) {
435
+ const dx = run.to.x - run.from.x
436
+ const dy = run.to.y - run.from.y
437
+ const ux = dx / run.length
438
+ const uy = dy / run.length
439
+ const middle = { x: (run.from.x + run.to.x) / 2, y: (run.from.y + run.to.y) / 2 }
440
+ // The walk is bounded so the label's own rectangle stays on the run that names it: a mask that hangs off the
441
+ // end of its segment would be half over the next thing and would not read as sitting on this edge.
442
+ const along = (Math.abs(ux) * width) / 2 + (Math.abs(uy) * height) / 2
443
+ const bound = Math.max(0, run.length / 2 - along)
444
+ const walk: number[] = [0]
445
+ for (let step = LABEL_STEP; step <= bound + 0.001; step += LABEL_STEP) walk.push(step, -step)
446
+ for (const offset of walk) {
447
+ const spot = spotAt(middle.x + ux * offset, middle.y + uy * offset)
448
+ const placement = collisionsAt(spot, runIndex)
449
+ // A clear place is the answer, and the first one is the preferred one: the search order is already the
450
+ // drawing's preference - longest run, own midpoint, then either side.
451
+ if (placement.collisions === 0) return { ...spot, ...placement }
452
+ if (
453
+ best === undefined ||
454
+ placement.collisions < best.collisions ||
455
+ (placement.collisions === best.collisions && placement.boxes + placement.labels < best.boxes + best.labels)
456
+ ) {
457
+ best = { ...spot, ...placement }
458
+ }
459
+ }
460
+ }
461
+ return best as LabelPlacement & LabelSpot
462
+ }
463
+
464
+
465
+ /** The furthest right and bottom point any shape reaches. */
466
+ function drawnExtent(shapes: Shape[]): { width: number; height: number } {
467
+ let width = 0
468
+ let height = 0
469
+ const consider = (x: number, y: number): void => {
470
+ if (x > width) width = x
471
+ if (y > height) height = y
472
+ }
473
+ for (const shape of shapes) {
474
+ switch (shape.kind) {
475
+ case 'box':
476
+ case 'group':
477
+ consider(shape.x + shape.width, shape.y + shape.height)
478
+ break
479
+ case 'link':
480
+ consider(Math.max(shape.x1, shape.x2), Math.max(shape.y1, shape.y2))
481
+ if (shape.label !== '') consider(shape.labelX + textWidth(shape.label) / 2, shape.labelY + LABEL_HEIGHT / 2)
482
+ break
483
+ case 'path': {
484
+ for (const point of shape.points) consider(point.x, point.y)
485
+ if (shape.label !== '') consider(shape.labelX + textWidth(shape.label) / 2, shape.labelY + LABEL_HEIGHT / 2)
486
+ break
487
+ }
488
+ case 'caption':
489
+ case 'title':
490
+ consider(textWidth((shape as { lines?: string[]; text?: string }).lines?.[0] ?? '') + shape.x, shape.y + 12)
491
+ break
492
+ case 'junction':
493
+ // A dot sits on a line that already reaches further than it does: counting it would never widen the canvas.
494
+ break
495
+ }
496
+ }
497
+ return { width, height }
498
+ }
499
+
500
+ /**
501
+ * The boxes a polyline passes through other than the two it terminates at.
502
+ *
503
+ * This is the drawing's one clearance rule, and it is deliberately written once: a channel run, a lane, a trunk
504
+ * branch, a wrapped column's sideways step and the router's fallback all have to obey the same test. A box is skipped
505
+ * only when it is one of the edge's own two ends, so a port that sits inside its own symbol (a cylinder's cap apex)
506
+ * is allowed and a segment that ends inside somebody else's box is not.
507
+ */
508
+ export function boxesCrossedBy(points: Point[], from: Box, to: Box, boxes: Iterable<Box>): Box[] {
509
+ const crossed: Box[] = []
510
+ for (let index = 0; index + 1 < points.length; index += 1) {
511
+ const a = points[index]
512
+ const b = points[index + 1]
513
+ for (const box of boxes) {
514
+ if (box === from || box === to) continue
515
+ if (crossesBox(a.x, a.y, b.x, b.y, box)) crossed.push(box)
516
+ }
517
+ }
518
+ return crossed
519
+ }
520
+
521
+ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, options: PaletteOptions = {}): { canvas: Canvas; colours: Palette } {
522
+ const colours = palette(overrides, options)
523
+ const shapes: Shape[] = []
524
+
525
+ const groups = diagram.groups ?? []
526
+ const topOffset = MARGIN + TITLE_HEIGHT
527
+ const placement = place(diagram, topOffset)
528
+ const { boxes, groupRects } = placement
529
+
530
+ // An edge may end on a boundary instead of a box: the boundary's rectangle is then the port surface, and
531
+ // the arrow head lands on its border. A group is not a member box, so every lookup that used to ask only
532
+ // `boxes` asks this instead.
533
+ const endBox = (id: string): Box | undefined => boxes.get(id) ?? groupRects.get(id)
534
+
535
+ // The symbol a box is drawn as, which decides which points on its sides an edge may meet. A boundary, and a node
536
+ // the source gave no shape, is a rectangle.
537
+ const shapeById = new Map<string, NodeShape>()
538
+ for (const node of diagram.nodes) if (node.shape !== undefined && !shapeById.has(node.id)) shapeById.set(node.id, node.shape)
539
+ const shapeOf = (id: string): NodeShape => shapeById.get(id) ?? 'rect'
540
+
541
+ // Boundaries first, so everything else is painted on top of them.
542
+ for (const group of groups) {
543
+ const rect = groupRects.get(group.id)
544
+ if (rect === undefined) continue
545
+ shapes.push({ kind: 'group', id: group.id, x: rect.x, y: rect.y, width: rect.width, height: rect.height, label: group.label, colour: 'muted' })
546
+ }
547
+
548
+ const drawn = new Set<string>()
549
+ // A Map iterator is consumed by the first pass: collision checks need a list.
550
+ const boxList = Array.from(boxes.values())
551
+ /** One drawn edge: the shape, its index in the diagram, and whether its label was fixed rather than placed. */
552
+ interface DrawnRoute {
553
+ shape: LinkShape | PathShape
554
+ edge: number
555
+ /** A self loop's label is drawn beside the loop by design; the placement pass leaves it alone. */
556
+ fixed: boolean
557
+ }
558
+ /** The edges drawn as lines, in paint order: their labels are placed once every one of them is known. */
559
+ const routes: DrawnRoute[] = []
560
+ for (const node of diagram.nodes) {
561
+ // A state id may repeat in a malformed model: the first placement wins and the repeat is skipped.
562
+ if (drawn.has(node.id)) continue
563
+ const box = boxes.get(node.id) as Box
564
+ drawn.add(node.id)
565
+ const text = textOf(node)
566
+ shapes.push({
567
+ kind: 'box',
568
+ x: box.x,
569
+ y: box.y,
570
+ width: box.width,
571
+ height: box.height,
572
+ label: labelOf(node),
573
+ lines: text.lines,
574
+ ...(node.shape === undefined ? {} : { shape: node.shape }),
575
+ colour: roleOfNode(node),
576
+ initial: diagram.init === node.id,
577
+ })
578
+ }
579
+
580
+ const roleById = new Map(diagram.nodes.map((node) => [node.id, roleOfNode(node)] as const))
581
+
582
+ // One pair may carry two statements - an association and a composition, or the same arrow written twice. They are
583
+ // two relations, not one, and one bend path between them reads as a thick line or as a mistake. So every edge of
584
+ // such a set is a line of its own: the first keeps the place the drawing gave it, and the rest take an offset
585
+ // perpendicular to the direction they run, alternating sign and growing - 0, +1, -1, +2, -2 spacings - so the set
586
+ // fans out from the pair instead of doubling it. A self loop is left out: it has no main direction to be
587
+ // perpendicular to, and its own shape is already not a line between two sides.
588
+ const multiEdgeSets = new Map<string, number[]>()
589
+ diagram.edges.forEach((edge, index) => {
590
+ if (edge.from === edge.to) return
591
+ if (endBox(edge.from) === undefined || endBox(edge.to) === undefined) return
592
+ const key = edge.from < edge.to ? edge.from + '\u0000' + edge.to : edge.to + '\u0000' + edge.from
593
+ const list = multiEdgeSets.get(key)
594
+ if (list === undefined) multiEdgeSets.set(key, [index])
595
+ else list.push(index)
596
+ })
597
+ const fanOf = new Map<number, number>()
598
+ for (const list of multiEdgeSets.values()) {
599
+ if (list.length < 2) continue
600
+ list.forEach((index, position) => {
601
+ if (position === 0) return
602
+ fanOf.set(index, (position % 2 === 1 ? 1 : -1) * Math.ceil(position / 2) * MULTI_EDGE_SPACING)
603
+ })
604
+ }
605
+
606
+ // Ports. The side comes from the route, not from comparing the two boxes' centres: a layered drawing runs
607
+ // downwards, so an edge between two rows leaves the bottom and arrives at the top, and a back edge does the
608
+ // opposite - unless the symbol has four legal points, and then the side is the point the edge was given. Where along
609
+ // the side the port sits follows the drawing: a long edge takes the point its lane meets, so the lane leaves and
610
+ // arrives square on, and a short edge points at the box it joins. Two edges that want the same place are spaced
611
+ // apart, so two ports never share a pixel.
612
+ const endKey = (index: number, end: 'exit' | 'entry'): string => String(index) + ':' + end
613
+ const endsAt = new Map<string, number>()
614
+ for (const edge of diagram.edges) {
615
+ if (edge.from === edge.to) continue
616
+ if (endBox(edge.from) === undefined || endBox(edge.to) === undefined) continue
617
+ endsAt.set(edge.from, (endsAt.get(edge.from) ?? 0) + 1)
618
+ endsAt.set(edge.to, (endsAt.get(edge.to) ?? 0) + 1)
619
+ }
620
+
621
+ /**
622
+ * Where a port may sit on one side of one node, as the interval its coordinate may take.
623
+ *
624
+ * 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
625
+ * where the symbol has to be known. A rectangle spreads a port along the side, and a subroutine does the same. A
626
+ * diamond's side meets its outline at exactly one point - the vertex - so its interval is that point, and a circle's
627
+ * is its cardinal point; a point is all that side has to give, so two edges on one side share it rather than take
628
+ * two points in the air. A hexagon's top and bottom are the flat run between the cut corners, and its left and right
629
+ * are the vertices. A stadium's flat run stops at the end caps, and a cylinder's flat sides are the two vertical
630
+ * runs, never the caps.
631
+ *
632
+ * A zero-width interval is not a failure: the packer keeps every port on it, so a symbol that offers one point on a
633
+ * side hands that point to every edge that wants it. Which is why a symbol with four points is given one edge per
634
+ * side first - the point is only shared when the four are taken.
635
+ */
636
+ const spanOf = (boxId: string, side: Side, corner: boolean): { low: number; high: number } => {
637
+ const box = endBox(boxId) as Box
638
+ const vertical = side === 'top' || side === 'bottom'
639
+ const middle = vertical ? box.x + box.width / 2 : box.y + box.height / 2
640
+ const within = (low: number, high: number): { low: number; high: number } => ({ low, high: Math.max(low, high) })
641
+ switch (shapeOf(boxId)) {
642
+ // The four vertices of a diamond, and the four cardinal points of an ellipse, are the middles of its sides.
643
+ case 'diamond':
644
+ case 'circle':
645
+ return within(middle, middle)
646
+ case 'hexagon':
647
+ return vertical ? within(box.x + hexagonCut(box.width), box.x + box.width - hexagonCut(box.width)) : within(middle, middle)
648
+ case 'stadium': {
649
+ const radius = stadiumRadius(box)
650
+ return vertical ? within(box.x + radius, box.x + box.width - radius) : within(middle, middle)
651
+ }
652
+ case 'cylinder': {
653
+ if (vertical) return within(middle, middle)
654
+ const cap = cylinderCaps(box).cap
655
+ return within(box.y + cap, box.y + box.height - cap)
656
+ }
657
+ default: {
658
+ // A rectangle, and a subroutine's doubled side, keep the margins they have always had; `corner` relaxes them
659
+ // because a side that is crowded uses its corners.
660
+ const inset = corner ? 2 : vertical ? PORT_INSET : 8
661
+ return vertical ? within(box.x + inset, box.x + box.width - inset) : within(box.y + inset, box.y + box.height - inset)
662
+ }
663
+ }
664
+ }
665
+ const clampTo = (boxId: string, side: Side, value: number, corner: boolean): number => {
666
+ const span = spanOf(boxId, side, corner)
667
+ return Math.min(span.high, Math.max(span.low, value))
668
+ }
669
+ /**
670
+ * The boundary point a coordinate along one side takes.
671
+ *
672
+ * The box's rectangle is the point for every symbol whose side is the box's side. A cylinder's top and bottom are
673
+ * arcs, so a port there sits on the cap curve's apex - `cap / 4` inside the box, not on the box's own edge.
674
+ */
675
+ const pointOn = (boxId: string, side: Side, along: number): Point => {
676
+ const box = endBox(boxId) as Box
677
+ if (side === 'left') return { x: box.x, y: along }
678
+ if (side === 'right') return { x: box.x + box.width, y: along }
679
+ if (side === 'top') return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'top').y : box.y }
680
+ return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'bottom').y : box.y + box.height }
681
+ }
682
+ /** The side each edge end's port is on, where the port is a point rather than a place along a run. */
683
+ const claimed = new Map<string, Side>()
684
+ const claimedAt = new Map<string, Array<{ index: number; side: Side }>>()
685
+ const claim = (id: string, index: number, end: 'exit' | 'entry', side: Side): void => {
686
+ claimed.set(endKey(index, end), side)
687
+ claimedAt.set(id, [...(claimedAt.get(id) ?? []), { index, side }])
688
+ }
689
+ /** The sides of one node another edge already holds. An edge is not crowded by its own other end. */
690
+ const takenSides = (id: string, except: number): Side[] =>
691
+ (claimedAt.get(id) ?? []).filter((entry) => entry.index !== except).map((entry) => entry.side)
692
+
693
+ /** The middle of one side: the coordinate that side's point sits at when nothing has moved it. */
694
+ const sideMiddle = (box: Box, side: Side): number => (isVerticalSide(side) ? box.x + box.width / 2 : box.y + box.height / 2)
695
+
696
+ /** The point a side offers at its middle: a symbol's own point, or the middle of the run it spreads along. */
697
+ const sidePoint = (boxId: string, side: Side): Point => pointOn(boxId, side, sideMiddle(endBox(boxId) as Box, side))
698
+
699
+ /** Does this side face away from the box at the other end? A route leaving it has to come back round its own box. */
700
+ const facesAway = (box: Box, other: Box, side: Side): boolean => {
701
+ const out = normalOf(side)
702
+ const dx = other.x + other.width / 2 - (box.x + box.width / 2)
703
+ const dy = other.y + other.height / 2 - (box.y + box.height / 2)
704
+ return out.x * dx + out.y * dy < 0
705
+ }
706
+
707
+ /** The coordinate a free side takes when the other end is pinned: the pinned port's stub, read on the free axis. */
708
+ const stubAlong = (pinnedId: string, pinnedSide: Side, freeSide: Side): number => {
709
+ const point = sidePoint(pinnedId, pinnedSide)
710
+ const out = normalOf(pinnedSide)
711
+ return isVerticalSide(freeSide) ? point.x + out.x * ROUTE_STUB : point.y + out.y * ROUTE_STUB
712
+ }
713
+
714
+ /** Can this side put a port at `value`? A point-side offers exactly one coordinate; a run offers an interval. */
715
+ const offers = (boxId: string, side: Side, value: number): boolean => {
716
+ const span = spanOf(boxId, side, true)
717
+ return value >= span.low - 0.01 && value <= span.high + 0.01
718
+ }
719
+
720
+ /**
721
+ * The bend budget a candidate (exit side, entry side) pair spends on the orthogonal route it implies, before a lane
722
+ * or a router is consulted. The numbers, and what each one is:
723
+ *
724
+ * 0 the two stubs share a line: both on vertical sides whose x can be the same, or both on horizontal sides
725
+ * whose y can be the same, with each stub pointing the way the edge travels. One straight run, no corner.
726
+ * 1 an L: the two sides are perpendicular and the one that can still move can be placed exactly where the pinned
727
+ * side's stub ends, so the route turns once. It is the rung between the straight run and the Z: an L never
728
+ * beats a run that shares a line, and always beats a Z.
729
+ * 2 a Z: the stubs are perpendicular but cannot meet at one corner, or they are parallel and offset. Out, across,
730
+ * in: two corners.
731
+ * 4 a U: a side faces away from the box at the other end, so the route has to wrap a box - out, round, back
732
+ * in. The canonical U has both sides facing away; one is already enough to force the wrap, and a pair that
733
+ * has to come back the way it went is never worth a pair that does not.
734
+ *
735
+ * "Can be the same" is the symbol's own answer (`spanOf`): a rectangle offers its whole side, a diamond one point, a
736
+ * cylinder's top the apex of its cap. Two rectangles facing each other therefore share a line wherever they sit; two
737
+ * diamonds only when their vertices line up. This is a budget, not the final polyline: the router still adds the
738
+ * length an obstacle demands, and the channel stage still decides which lanes exist.
739
+ *
740
+ * A side another edge already holds is charged for the L it costs: the packer has to move this pair's port at least
741
+ * a port separation along the side, which turns the one corner the pair counted on into a jog. A Z is not charged -
742
+ * it was already off the line, and a port that moves along its side does not add a corner to it.
743
+ */
744
+ const bendBudget = (fromId: string, toId: string, exit: Side, entry: Side, index: number): number => {
745
+ const from = endBox(fromId) as Box
746
+ const to = endBox(toId) as Box
747
+ if (facesAway(from, to, exit) || facesAway(to, from, entry)) return 4
748
+ if (isVerticalSide(exit) === isVerticalSide(entry)) {
749
+ // Parallel: a straight run only when the two stubs travel the same way and the sides can meet on one line.
750
+ const out = normalOf(exit)
751
+ const into = normalOf(entry)
752
+ const sameWay = out.x === -into.x && out.y === -into.y
753
+ const fromSpan = spanOf(fromId, exit, true)
754
+ const toSpan = spanOf(toId, entry, true)
755
+ const meet = fromSpan.low <= toSpan.high + 0.01 && toSpan.low <= fromSpan.high + 0.01
756
+ return sameWay && meet ? 0 : 2
757
+ }
758
+ // Perpendicular: one corner when the vertical side can be placed at the horizontal side's stub end.
759
+ const horizontalId = isVerticalSide(exit) ? toId : fromId
760
+ const horizontalSide = isVerticalSide(exit) ? entry : exit
761
+ const verticalId = isVerticalSide(exit) ? fromId : toId
762
+ const verticalSide = isVerticalSide(exit) ? exit : entry
763
+ if (!offers(verticalId, verticalSide, stubAlong(horizontalId, horizontalSide, verticalSide))) return 2
764
+ const crowded = takenSides(fromId, index).includes(exit) || takenSides(toId, index).includes(entry)
765
+ return crowded ? 2 : 1
766
+ }
767
+
768
+ /**
769
+ * The side a discrete port takes: the point whose pair with the edge's other end bends least.
770
+ *
771
+ * `partner` is the side the other end holds - the side it was claimed, or, until it is claimed, the side the route
772
+ * approaches by. The bend budget comes first. Equal budgets fall back to the rule that was here before: the cosine
773
+ * of the outward normal against the direction to the other box, then the point nearest that box, then the ground
774
+ * between the two points - the small length term. So a case that already read well does not move, and a point that
775
+ * costs an extra corner loses even when it faces the target more squarely.
776
+ */
777
+ const bestSide = (id: string, otherId: string, end: 'exit' | 'entry', partner: Side, index: number): Side | undefined => {
778
+ const box = endBox(id) as Box
779
+ const other = endBox(otherId) as Box
780
+ const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
781
+ const toward = { x: other.x + other.width / 2 - centre.x, y: other.y + other.height / 2 - centre.y }
782
+ const reach = Math.hypot(toward.x, toward.y)
783
+ const taken = takenSides(id, index)
784
+ let best: { side: Side; bends: number; score: number; near: number; ground: number } | undefined
785
+ for (const side of PORT_SIDES) {
786
+ if (taken.includes(side)) continue
787
+ const bends = end === 'exit' ? bendBudget(id, otherId, side, partner, index) : bendBudget(otherId, id, partner, side, index)
788
+ const normal = normalOf(side)
789
+ const score = reach === 0 ? 0 : (normal.x * toward.x + normal.y * toward.y) / reach
790
+ // How far the point is from the other box's rectangle: the nearest port is the one the edge reaches first.
791
+ const point = anchor(box, side)
792
+ const near = Math.hypot(
793
+ Math.max(other.x - point.x, 0, point.x - (other.x + other.width)),
794
+ Math.max(other.y - point.y, 0, point.y - (other.y + other.height)),
795
+ )
796
+ const partnerPoint = anchor(other, partner)
797
+ const ground = Math.abs(point.x - partnerPoint.x) + Math.abs(point.y - partnerPoint.y)
798
+ const better =
799
+ best === undefined ||
800
+ bends < best.bends ||
801
+ (bends === best.bends &&
802
+ (score > best.score + 1e-9 ||
803
+ (Math.abs(score - best.score) <= 1e-9 &&
804
+ (near < best.near - 1e-9 || (Math.abs(near - best.near) <= 1e-9 && ground < best.ground - 1e-9)))))
805
+ if (better) best = { side, bends, score, near, ground }
806
+ }
807
+ return best?.side
808
+ }
809
+
810
+ /**
811
+ * The sides an edge leaves and enters by when no claimed point decides either end: the pair the budget likes best.
812
+ *
813
+ * Only the two same-kind pairs are offered. Both ends here spread their ports along a side, so the choice is which
814
+ * side of the box the edge travels on; a pair that turns the corner one side further is the case `bestEnd` decides,
815
+ * where a claimed point has already fixed the near end. A pair strictly cheaper than the one the route approaches by
816
+ * wins; an equal pair keeps the approach, which leaves a drawing that was already right exactly as it was.
817
+ */
818
+ const bestPair = (fromId: string, toId: string, index: number): { exit: Side; entry: Side } => {
819
+ const approach = approachSides(endBox(fromId) as Box, endBox(toId) as Box)
820
+ const pairs: Array<{ exit: Side; entry: Side }> = [
821
+ { exit: 'bottom', entry: 'top' },
822
+ { exit: 'top', entry: 'bottom' },
823
+ { exit: 'right', entry: 'left' },
824
+ { exit: 'left', entry: 'right' },
825
+ ]
826
+ let best = approach
827
+ let bestBends = bendBudget(fromId, toId, approach.exit, approach.entry, index)
828
+ for (const pair of pairs) {
829
+ const bends = bendBudget(fromId, toId, pair.exit, pair.entry, index)
830
+ if (bends >= bestBends) continue
831
+ best = pair
832
+ bestBends = bends
833
+ }
834
+ return best
835
+ }
836
+
837
+ /**
838
+ * The side one end takes when the other end has already been decided: the cheapest of the four.
839
+ *
840
+ * The side the route approaches by wins a tie, for the same reason as `bestPair` - a drawing that already read well
841
+ * does not move for a pair the budget cannot tell apart.
842
+ */
843
+ const bestEnd = (id: string, otherId: string, end: 'exit' | 'entry', partner: Side, index: number): Side => {
844
+ const approach =
845
+ end === 'exit'
846
+ ? approachSides(endBox(id) as Box, endBox(otherId) as Box).exit
847
+ : approachSides(endBox(otherId) as Box, endBox(id) as Box).entry
848
+ let best = approach
849
+ let bestBends = end === 'exit' ? bendBudget(id, otherId, approach, partner, index) : bendBudget(otherId, id, partner, approach, index)
850
+ for (const side of PORT_SIDES) {
851
+ const bends = end === 'exit' ? bendBudget(id, otherId, side, partner, index) : bendBudget(otherId, id, partner, side, index)
852
+ if (bends >= bestBends) continue
853
+ best = side
854
+ bestBends = bends
855
+ }
856
+ return best
857
+ }
858
+
859
+ // A side the drawing has already decided is claimed first, because a point there would point the route away from
860
+ // the corridor it has to run in: a long edge leaves by the side its lane starts from, and an edge read across two
861
+ // wrapped columns leaves and arrives on the sides the reading order turns at.
862
+ diagram.edges.forEach((edge, index) => {
863
+ if (edge.from === edge.to) return
864
+ const source = endBox(edge.from)
865
+ const target = endBox(edge.to)
866
+ if (source === undefined || target === undefined) return
867
+ if (placement.across.has(index)) {
868
+ claim(edge.from, index, 'exit', 'right')
869
+ claim(edge.to, index, 'entry', 'left')
870
+ return
871
+ }
872
+ const lane = placement.lanes.get(index)
873
+ if (lane === undefined || lane.points.length === 0) return
874
+ const down = target.y > source.y
875
+ claim(edge.from, index, 'exit', down ? 'bottom' : 'top')
876
+ claim(edge.to, index, 'entry', down ? 'top' : 'bottom')
877
+ })
878
+
879
+ // Then a symbol with four legal points gives every edge end a point of its own, in source order, until the four are
880
+ // gone. Only a node that needs one port, or one whose points are all spoken for, keeps the old answer - the side the
881
+ // route approaches by - and then two edges share that side's single point and fan apart later. That is the fallback,
882
+ // and it is why a decision with two labelled branches no longer draws both of them from one vertex.
883
+ diagram.edges.forEach((edge, index) => {
884
+ if (edge.from === edge.to) return
885
+ const source = endBox(edge.from)
886
+ const target = endBox(edge.to)
887
+ if (source === undefined || target === undefined) return
888
+ for (const end of ['exit', 'entry'] as const) {
889
+ if (claimed.has(endKey(index, end))) continue
890
+ const id = end === 'exit' ? edge.from : edge.to
891
+ if ((endsAt.get(id) ?? 0) < 2) continue
892
+ if (!hasDiscretePorts(shapeOf(id))) continue
893
+ // The side the other end holds, or will hold if nothing claims it first.
894
+ const approached = approachSides(source, target)
895
+ const partner =
896
+ claimed.get(endKey(index, end === 'exit' ? 'entry' : 'exit')) ??
897
+ (end === 'exit' ? approached.entry : approached.exit)
898
+ const side = bestSide(id, end === 'exit' ? edge.to : edge.from, end, partner, index)
899
+ if (side === undefined) continue
900
+ claim(id, index, end, side)
901
+ }
902
+ })
903
+
904
+ const facing = diagram.edges.map((edge, index): Facing | undefined => {
905
+ const source = endBox(edge.from)
906
+ const target = endBox(edge.to)
907
+ if (source === undefined || target === undefined || edge.from === edge.to) return undefined
908
+ // A wrapped drawing is read in columns: the edge from one column to the next leaves and arrives at the side,
909
+ // which is the direction that reading order travels. A column is always to the right of the one before it.
910
+ if (placement.across.has(index)) return { exit: 'right', entry: 'left' }
911
+ const lane = placement.lanes.get(index)
912
+ // A lane runs down the page, so its ends face the rows above and below wherever the box centres are.
913
+ const down = target.y > source.y
914
+ if (lane !== undefined && lane.points.length > 0) {
915
+ return {
916
+ exit: claimed.get(endKey(index, 'exit')) ?? (down ? 'bottom' : 'top'),
917
+ entry: claimed.get(endKey(index, 'entry')) ?? (down ? 'top' : 'bottom'),
918
+ at: lane.points[0].x,
919
+ }
920
+ }
921
+ // No claimed point and no lane: an end that was claimed keeps its point, the other one takes the side the budget
922
+ // likes best against it; with neither claimed, the pair is chosen together.
923
+ const exit = claimed.get(endKey(index, 'exit'))
924
+ const entry = claimed.get(endKey(index, 'entry'))
925
+ if (exit !== undefined && entry !== undefined) return { exit, entry }
926
+ if (exit !== undefined) return { exit, entry: bestEnd(edge.to, edge.from, 'entry', exit, index) }
927
+ if (entry !== undefined) return { exit: bestEnd(edge.from, edge.to, 'exit', entry, index), entry }
928
+ return bestPair(edge.from, edge.to, index)
929
+ })
930
+ // 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
931
+ // ends. It tries the place that keeps the set evenly spaced first, then its own place moved by the same offset; a
932
+ // move that would cross a box is refused, because an edge through a box is the worse defect. Refused, the edge keeps
933
+ // its lane - the two ports still differ, because every long edge already has a lane of its own.
934
+ const laneClear = (points: Point[], x: number, from: Box, to: Box): boolean => {
935
+ for (let at = 0; at + 1 < points.length; at += 1) {
936
+ for (const box of boxList) {
937
+ if (box === from || box === to) continue
938
+ if (crossesBox(x, points[at].y, x, points[at + 1].y, box)) return false
939
+ }
940
+ }
941
+ return true
942
+ }
943
+ const laneShift = new Map<number, number>()
944
+ for (const list of multiEdgeSets.values()) {
945
+ if (list.length < 2) continue
946
+ const lead = placement.lanes.get(list[0] as number)
947
+ if (lead === undefined || lead.points.length === 0) continue
948
+ const base = (lead.points[0] as Point).x
949
+ const taken = new Set<number>()
950
+ for (const index of list) {
951
+ const lane = placement.lanes.get(index) as { points: Point[] }
952
+ const own = lane.points[0].x
953
+ const offset = fanOf.get(index) ?? 0
954
+ const edge = diagram.edges[index] as DiagramEdge
955
+ const from = endBox(edge.from) as Box
956
+ const to = endBox(edge.to) as Box
957
+ let target = own
958
+ if (offset !== 0) {
959
+ for (const candidate of [base + offset, own + offset]) {
960
+ if (taken.has(candidate) || !laneClear(lane.points, candidate, from, to)) continue
961
+ target = candidate
962
+ break
963
+ }
964
+ }
965
+ taken.add(target)
966
+ if (target !== own) laneShift.set(index, target - own)
967
+ }
968
+ }
969
+
970
+ const wanted = new Map<string, number>()
971
+ const slotKey = (boxId: string, side: Side, index: number): string => boxId + ':' + side + ':' + String(index)
972
+ for (const [index, entry] of facing.entries()) {
973
+ if (entry === undefined) continue
974
+ const edge = diagram.edges[index]
975
+ const from = endBox(edge.from) as Box
976
+ const to = endBox(edge.to) as Box
977
+ const middle = (box: Box, side: Side): number => (isVerticalSide(side) ? box.x + box.width / 2 : box.y + box.height / 2)
978
+ const fromMiddle = middle(from, entry.exit)
979
+ const toMiddle = middle(to, entry.entry)
980
+ // Both ports sit on one line - and the edge is a straight run - when the source's middle falls on the target's
981
+ // side; otherwise the line is the target's middle clamped into the source's side, which is as close to a straight
982
+ // run as a box that does not overlap the other allows. A long edge asks for the point its own lane meets instead,
983
+ // and takes the corner nearest it when the lane runs outside the box: that edge turns anyway, and a straight edge
984
+ // made to give up its port would gain two bends for nothing.
985
+ const corner = entry.at !== undefined
986
+ // 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
987
+ // offset is added to the side's own coordinate, which is x for a vertical run and y for a horizontal one. A long
988
+ // edge carries the offset in its lane instead, so the whole edge moves rather than gaining two bends.
989
+ const offset = fanOf.get(index) ?? 0
990
+ let exitWanted: number
991
+ let entryWanted: number
992
+ if (entry.at !== undefined) {
993
+ const chosen = entry.at + (laneShift.get(index) ?? 0)
994
+ exitWanted = clampTo(edge.from, entry.exit, chosen, corner)
995
+ entryWanted = clampTo(edge.to, entry.entry, chosen, corner)
996
+ } else if (isVerticalSide(entry.exit) === isVerticalSide(entry.entry)) {
997
+ // Two ports on sides of the same kind: one coordinate serves both, as it always has.
998
+ const target = spanOf(edge.to, entry.entry, false)
999
+ const chosen = (fromMiddle >= target.low && fromMiddle <= target.high ? fromMiddle : clampTo(edge.from, entry.exit, toMiddle, false)) + offset
1000
+ exitWanted = clampTo(edge.from, entry.exit, chosen, corner)
1001
+ entryWanted = clampTo(edge.to, entry.entry, chosen, corner)
1002
+ } else {
1003
+ // The two ports sit on sides of different kinds, which a discrete point causes. The end that is fixed on its
1004
+ // symbol's point keeps that point's coordinate; the other one sits where the route actually arrives - one step
1005
+ // out of the fixed port along that port's own normal, read on the free side's axis - so the two legs of the
1006
+ // route meet in one corner instead of jogging sideways to reach a middle the route never passes.
1007
+ exitWanted = hasDiscretePorts(shapeOf(edge.from))
1008
+ ? fromMiddle
1009
+ : clampTo(edge.from, entry.exit, stubAlong(edge.to, entry.entry, entry.exit), false) + offset
1010
+ entryWanted = hasDiscretePorts(shapeOf(edge.to))
1011
+ ? toMiddle
1012
+ : clampTo(edge.to, entry.entry, stubAlong(edge.from, entry.exit, entry.entry), false) + offset
1013
+ }
1014
+ wanted.set(slotKey(edge.from, entry.exit, index), exitWanted)
1015
+ wanted.set(slotKey(edge.to, entry.entry, index), entryWanted)
1016
+ }
1017
+ const sides = new Map<string, Array<{ index: number; wanted: number }>>()
1018
+ for (const [index, entry] of facing.entries()) {
1019
+ if (entry === undefined) continue
1020
+ const edge = diagram.edges[index]
1021
+ sides.set(edge.from + ':' + entry.exit, [
1022
+ ...(sides.get(edge.from + ':' + entry.exit) ?? []),
1023
+ { index, wanted: wanted.get(slotKey(edge.from, entry.exit, index)) as number },
1024
+ ])
1025
+ sides.set(edge.to + ':' + entry.entry, [
1026
+ ...(sides.get(edge.to + ':' + entry.entry) ?? []),
1027
+ { index, wanted: wanted.get(slotKey(edge.to, entry.entry, index)) as number },
1028
+ ])
1029
+ }
1030
+ // Pack every side, then hand each entry the position its exit was given: a pair that faced each other sat on one x
1031
+ // before packing, and an edge the packing had to move should keep its straight run rather than gain two bends.
1032
+ let placedPort = new Map<string, number>()
1033
+ for (let round = 0; round < 3; round += 1) {
1034
+ placedPort = new Map<string, number>()
1035
+ for (const [key, list] of sides) {
1036
+ const [boxId, side] = key.split(':') as [string, Side]
1037
+ const span = spanOf(boxId, side, true)
1038
+ const order = [...list].sort((left, right) => left.wanted - right.wanted || left.index - right.index)
1039
+ const packed = packPorts(
1040
+ order.map((entry) => clampTo(boxId, side, entry.wanted, true)),
1041
+ span.low,
1042
+ span.high,
1043
+ )
1044
+ order.forEach((entry, at) => placedPort.set(slotKey(boxId, side, entry.index), packed[at]))
1045
+ }
1046
+ let moved = false
1047
+ for (const [index, entry] of facing.entries()) {
1048
+ if (entry === undefined) continue
1049
+ const edge = diagram.edges[index]
1050
+ const vertical = entry.exit === 'bottom' || entry.exit === 'top'
1051
+ if (!vertical) continue
1052
+ const exit = placedPort.get(slotKey(edge.from, entry.exit, index)) as number
1053
+ if (Math.abs(exit - (placedPort.get(slotKey(edge.to, entry.entry, index)) as number)) < 0.01) continue
1054
+ // Only an entry that can reach the exit's x asks for it: a box that does not span it keeps its own point.
1055
+ if (Math.abs(clampTo(edge.to, entry.entry, exit, true) - exit) > 0.01) continue
1056
+ for (const slot of sides.get(edge.to + ':' + entry.entry) as Array<{ index: number; wanted: number }>) {
1057
+ if (slot.index !== index) continue
1058
+ slot.wanted = exit
1059
+ moved = true
1060
+ }
1061
+ }
1062
+ if (!moved) break
1063
+ }
1064
+
1065
+ /**
1066
+ * A batch of arrows into one box from one side is one bundle, not a comb: which ends may merge is the planner's
1067
+ * answer (`trunk.ts` carries the rules), and this module supplies what only it knows - which edges have a run of
1068
+ * their own, and where each port landed.
1069
+ *
1070
+ * The plan is made twice. The first pass, from the ports the packing gave, says which ends merge; the centring rule
1071
+ * below reads it, because a merged branch is drawn into the bundle's one port and that - not its own entry port -
1072
+ * is the coordinate its exit either shares a line with or does not. The second pass is made from the centred ports
1073
+ * and is the plan the drawing uses: the run it lays out is the run of the ports that are actually drawn.
1074
+ */
1075
+ const trunkEnds: TrunkEnd[] = []
1076
+ diagram.edges.forEach((edge, index) => {
1077
+ const entry = facing[index]
1078
+ if (entry === undefined) return
1079
+ const runY = placement.runs.get(index)
1080
+ trunkEnds.push({
1081
+ index,
1082
+ from: edge.from,
1083
+ to: edge.to,
1084
+ exit: entry.exit,
1085
+ entry: entry.entry,
1086
+ ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
1087
+ ...(runY === undefined ? {} : { runY }),
1088
+ })
1089
+ })
1090
+ const planFor = (exitPortOf: (index: number) => Point | undefined): TrunkPlan =>
1091
+ planTrunks({
1092
+ ends: trunkEnds,
1093
+ // Only a node's own box is a trunk target: an arrow into a boundary points past it, not at it.
1094
+ boxOf: (id) => boxes.get(id),
1095
+ boxes: boxList,
1096
+ exitPortOf,
1097
+ })
1098
+ /** Where an edge's exit port sits before the centring rule has had its say. */
1099
+ const packedExit = (index: number): Point | undefined => {
1100
+ const entry = facing[index]
1101
+ if (entry === undefined) return undefined
1102
+ const edge = diagram.edges[index] as DiagramEdge
1103
+ const coordinate = placedPort.get(slotKey(edge.from, entry.exit, index))
1104
+ return coordinate === undefined ? undefined : pointOn(edge.from, entry.exit, coordinate)
1105
+ }
1106
+ const planned = planFor(packedExit)
1107
+
1108
+ /**
1109
+ * Does the channel stage's lane pin this port to its own coordinate? A port off its lane is a jog added to the run.
1110
+ * A merged branch has a lane the painter never follows - the branch is drawn from its exit to the trunk - so it is
1111
+ * not pinned by it.
1112
+ */
1113
+ const onLane = (index: number, coordinate: number): boolean => {
1114
+ if (planned.groupOf.has(index)) return false
1115
+ const lane = placement.lanes.get(index)
1116
+ if (lane === undefined || lane.points.length === 0) return false
1117
+ return Math.abs((lane.points[0] as Point).x - coordinate) <= 0.5
1118
+ }
1119
+
1120
+ /**
1121
+ * A side's ports as the centring rule reads them: where they are now, and how far the whole row would move to be
1122
+ * centred. The row is the side, so its ports move together and not one by one.
1123
+ */
1124
+ interface PortRow {
1125
+ boxId: string
1126
+ side: Side
1127
+ /** The edge indexes in the row, in the row's own order, and the coordinate each one sits at. */
1128
+ indexes: number[]
1129
+ at: number[]
1130
+ /** Where each index sits in `at`, so a partner port can be read without searching. */
1131
+ position: Map<number, number>
1132
+ /** How far the whole row travels to be centred. Zero when it already is. */
1133
+ shift: number
1134
+ /** Whether it may travel at all: the fixpoint below only ever takes this away. */
1135
+ allowed: boolean
1136
+ }
1137
+
1138
+ const rows: PortRow[] = []
1139
+ const rowOf = new Map<string, PortRow>()
1140
+ for (const [key, list] of sides) {
1141
+ const [boxId, side] = key.split(':') as [string, Side]
1142
+ const span = spanOf(boxId, side, true)
1143
+ const slots = [...list]
1144
+ .map((entry) => ({ index: entry.index, at: placedPort.get(slotKey(boxId, side, entry.index)) as number }))
1145
+ .sort((left, right) => left.at - right.at || left.index - right.index)
1146
+ const centred = centreRun(
1147
+ slots.map((slot) => slot.at),
1148
+ span.low,
1149
+ span.high,
1150
+ )
1151
+ const first = slots[0] as { index: number; at: number }
1152
+ const row: PortRow = {
1153
+ boxId,
1154
+ side,
1155
+ indexes: slots.map((slot) => slot.index),
1156
+ at: slots.map((slot) => slot.at),
1157
+ position: new Map(slots.map((slot, at) => [slot.index, at] as const)),
1158
+ shift: (centred[0] as number) - first.at,
1159
+ allowed: true,
1160
+ }
1161
+ rows.push(row)
1162
+ rowOf.set(key, row)
1163
+ }
1164
+
1165
+ /** Where a port of a row will sit once the rows that may move have moved. */
1166
+ const movedAt = (row: PortRow, at: number): number => (row.at[at] as number) + (row.allowed ? row.shift : 0)
1167
+
1168
+ /**
1169
+ * The coordinate the port at the other end of this edge sits at, and the row that port belongs to.
1170
+ *
1171
+ * Three ends are not a drawn port of a row: the entry of a merged branch (the bundle has one port), the shared
1172
+ * entry itself (the planner's `entryAlong`), and a partner on a side of the other kind (a discrete symbol's point,
1173
+ * on a different axis). Each is a fixed coordinate or nothing, which is all the pin test needs.
1174
+ */
1175
+ const partnerOf = (boxId: string, side: Side, index: number): { coordinate: number; row?: PortRow; at?: number } | undefined => {
1176
+ const entry = facing[index]
1177
+ if (entry === undefined) return undefined
1178
+ const edge = diagram.edges[index] as DiagramEdge
1179
+ const isExit = edge.from === boxId && entry.exit === side
1180
+ const groupId = planned.groupOf.get(index)
1181
+ if (groupId !== undefined) {
1182
+ // A merged branch ends at the bundle's one port, so its own entry port is not drawn and pins nothing. Its exit
1183
+ // is drawn, and the line it may share is the one to that shared port.
1184
+ if (!isExit) return undefined
1185
+ const group = planned.groups[groupId] as TrunkGroup
1186
+ if (isVerticalSide(side) !== isVerticalSide(group.side)) return undefined
1187
+ return { coordinate: group.entryAlong }
1188
+ }
1189
+ const partnerId = isExit ? edge.to : edge.from
1190
+ const partnerSide = isExit ? entry.entry : entry.exit
1191
+ if (isVerticalSide(side) !== isVerticalSide(partnerSide)) return undefined
1192
+ const coordinate = placedPort.get(slotKey(partnerId, partnerSide, index))
1193
+ if (coordinate === undefined) return undefined
1194
+ const row = rowOf.get(partnerId + ':' + partnerSide)
1195
+ return { coordinate, ...(row === undefined ? {} : { row, at: row.position.get(index) as number }) }
1196
+ }
1197
+
1198
+ /**
1199
+ * A side that carries one port is centred on it; a side that carries several keeps its spread, translated as a
1200
+ * whole so its outer ports sit symmetrically about the middle.
1201
+ *
1202
+ * A port is put where the route wants it, and a side that carries one relation has no spread to fit: the only
1203
+ * reason its port is not in the middle is that the route asked for a coordinate near one end - the far end of the
1204
+ * span, the point a lane runs at. That request is real, and the bend budget decides whether it may be overruled.
1205
+ * A port that shares a line with the port at the other end of its edge is the middle of a straight run, and a port
1206
+ * on a lane is the end of a channel: moving either adds two corners. Every other port moves to where its side has
1207
+ * room, which costs nothing.
1208
+ *
1209
+ * So the bend budget is a fixpoint over rows, not a test per port: a row may move unless that breaks a line it
1210
+ * shares with a port that is staying. Two ends of one straight line may both move when they move by the same
1211
+ * amount - which is what centring both sides of one pair does - so the rows are resolved together, and a row that
1212
+ * is refused takes the lines it shares with it.
1213
+ */
1214
+ for (let pass = 0; pass < rows.length; pass += 1) {
1215
+ let refused = false
1216
+ for (const row of rows) {
1217
+ if (!row.allowed || row.shift === 0) continue
1218
+ const pinned = row.indexes.some((index, at) => {
1219
+ if (onLane(index, row.at[at] as number)) return true
1220
+ const partner = partnerOf(row.boxId, row.side, index)
1221
+ if (partner === undefined) return false
1222
+ // Only a line that exists now can be broken: two ports that already differ are a Z, and moving one end of a Z
1223
+ // along its own side changes where its corners are, not how many.
1224
+ if (Math.abs((row.at[at] as number) - partner.coordinate) > 0.5) return false
1225
+ const partnerAt = partner.row === undefined ? partner.coordinate : movedAt(partner.row, partner.at as number)
1226
+ return Math.abs(movedAt(row, at) - partnerAt) > 0.5
1227
+ })
1228
+ if (!pinned) continue
1229
+ row.allowed = false
1230
+ refused = true
1231
+ }
1232
+ if (!refused) break
1233
+ }
1234
+ for (const row of rows) {
1235
+ if (!row.allowed || row.shift === 0) continue
1236
+ row.indexes.forEach((index, at) => placedPort.set(slotKey(row.boxId, row.side, index), movedAt(row, at)))
1237
+ }
1238
+
1239
+ const portOf = (boxId: string, side: Side, edgeIndex: number): Point => {
1240
+ const coordinate = placedPort.get(slotKey(boxId, side, edgeIndex)) as number
1241
+ return pointOn(boxId, side, coordinate)
1242
+ }
1243
+ const anchorsFor = new Map<number, Ports>()
1244
+ for (const [index, entry] of facing.entries()) {
1245
+ if (entry === undefined) continue
1246
+ const edge = diagram.edges[index]
1247
+ anchorsFor.set(index, {
1248
+ exit: portOf(edge.from, entry.exit, index),
1249
+ entry: portOf(edge.to, entry.entry, index),
1250
+ exitSide: entry.exit,
1251
+ entrySide: entry.entry,
1252
+ })
1253
+ }
1254
+
1255
+ const trunks = planFor((index) => anchorsFor.get(index)?.exit)
1256
+ // The one port each accepted bundle enters by: the middle of the target's side, which is the port the planner
1257
+ // centred on `entryAlong`. A merged entry is one port, so the rule for a side with one port applies to it, and the
1258
+ // target carries one relation's worth of ports however many arrows point at it.
1259
+ const sharedEntry = new Map<number, Point>()
1260
+ for (const group of trunks.groups) sharedEntry.set(group.id, pointOn(group.target, group.side, group.entryAlong))
1261
+
1262
+ /** The points a drawn edge passes through, whichever of the two shapes carries it. */
1263
+ const pointsOfRoute = (shape: LinkShape | PathShape): Point[] =>
1264
+ shape.kind === 'path' ? shape.points : [{ x: shape.x1, y: shape.y1 }, { x: shape.x2, y: shape.y2 }]
1265
+
1266
+ /** The straight pieces an edge draws, so a label can tell its own line from every other one. */
1267
+ const segmentsOfRoute = (shape: LinkShape | PathShape): Segment[] => {
1268
+ const points = pointsOfRoute(shape)
1269
+ const out: Segment[] = []
1270
+ for (let index = 0; index + 1 < points.length; index += 1) {
1271
+ const from = points[index]
1272
+ const to = points[index + 1]
1273
+ out.push({ x1: from.x, y1: from.y, x2: to.x, y2: to.y })
1274
+ }
1275
+ return out
1276
+ }
1277
+
1278
+ /** Paint one edge as a polyline. Its label is placed once every edge is drawn; see the pass after the loop. */
1279
+ const paintPath = (edgeIndex: number, edge: DiagramEdge, points: Point[], colour: ColourRole): void => {
1280
+ const shape: PathShape = {
1281
+ kind: 'path',
1282
+ points,
1283
+ label: edge.label,
1284
+ labelX: 0,
1285
+ labelY: 0,
1286
+ colour,
1287
+ ...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
1288
+ ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
1289
+ }
1290
+ shapes.push(shape)
1291
+ routes.push({ shape, edge: edgeIndex, fixed: false })
1292
+ }
1293
+
1294
+ /** Two points are a line and more of them are a polyline; both are an edge carrying a label. */
1295
+ const paintRoute = (edgeIndex: number, edge: DiagramEdge, points: Point[], colour: ColourRole): void => {
1296
+ if (points.length > 2) {
1297
+ paintPath(edgeIndex, edge, points, colour)
1298
+ return
1299
+ }
1300
+ const exit = points[0]
1301
+ const entry = points[points.length - 1]
1302
+ const shape: LinkShape = {
1303
+ kind: 'link',
1304
+ x1: exit.x,
1305
+ y1: exit.y,
1306
+ x2: entry.x,
1307
+ y2: entry.y,
1308
+ label: edge.label,
1309
+ labelX: 0,
1310
+ labelY: 0,
1311
+ colour,
1312
+ ...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
1313
+ ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
1314
+ }
1315
+ shapes.push(shape)
1316
+ routes.push({ shape, edge: edgeIndex, fixed: false })
1317
+ }
1318
+
1319
+ /**
1320
+ * Is this route clear of every box it does not terminate at?
1321
+ *
1322
+ * One test, used by every stage. A run, a lane and a trunk branch are proposals made from the layout; when the
1323
+ * proposal meets a box - which happens when the layout reserved a run between two boundaries while the painter
1324
+ * draws the boxes inside them - the proposal is refused and the router gets the edge.
1325
+ */
1326
+ const clearOfBoxes = (points: Point[], from: Box, to: Box): boolean => boxesCrossedBy(points, from, to, boxList).length === 0
1327
+
1328
+ /**
1329
+ * The polyline through a run: out of one port, across the strip between two rows, and into the other, square to
1330
+ * whatever side each port belongs to.
1331
+ *
1332
+ * Two ports on top and bottom sides come out as the polyline the run always drew. A port on a left or right side
1333
+ * cannot be entered by a vertical drop, so the route steps out of it by one stub first and the strip meets the port
1334
+ * at a right angle: the edge leaves - or arrives - by the side its point belongs to, which is the side the router is
1335
+ * told about too.
1336
+ */
1337
+ const runRoute = (ports: Ports, runY: number): Point[] => {
1338
+ const out = isVerticalSide(ports.exitSide) ? 0 : normalOf(ports.exitSide).x * ROUTE_STUB
1339
+ const into = isVerticalSide(ports.entrySide) ? 0 : normalOf(ports.entrySide).x * ROUTE_STUB
1340
+ // A sideways port and a vertical one meet where the other port already is, if that is the way the stub points: the
1341
+ // stub is the straight run out of the port, and it is worth more than a stub to avoid the point-wide jog that
1342
+ // meeting at the nominal length leaves behind - a jog the edge would be charged a bend for and a reader for
1343
+ // nothing. Only when the other port is on the far side does the stub have to overshoot.
1344
+ const exitRun = out !== 0 && into === 0 && Math.sign(ports.entry.x - ports.exit.x) === Math.sign(out) ? ports.entry.x : ports.exit.x + out
1345
+ const entryRun = into !== 0 && out === 0 && Math.sign(ports.exit.x - ports.entry.x) === Math.sign(into) ? ports.exit.x : ports.entry.x + into
1346
+ const points: Point[] = [ports.exit]
1347
+ if (out !== 0) points.push({ x: exitRun, y: ports.exit.y })
1348
+ points.push({ x: exitRun, y: runY }, { x: entryRun, y: runY })
1349
+ if (into !== 0) points.push({ x: entryRun, y: ports.entry.y })
1350
+ points.push(ports.entry)
1351
+ return simplify(points)
1352
+ }
1353
+
1354
+ /**
1355
+ * Can the run draw this edge at all?
1356
+ *
1357
+ * 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
1358
+ * point that faces away - the last of a diamond's four, when the others are taken - would have the route leave the
1359
+ * port and come straight back through its own box. Such an edge is the router's to draw: going round a box is what
1360
+ * the router does, and it knows the box is there.
1361
+ */
1362
+ const runFits = (ports: Ports, runY: number): boolean => {
1363
+ if (isVerticalSide(ports.exitSide) && Math.abs(runY - ports.exit.y) > 0.5) {
1364
+ if (Math.sign(runY - ports.exit.y) !== Math.sign(normalOf(ports.exitSide).y)) return false
1365
+ }
1366
+ if (isVerticalSide(ports.entrySide) && Math.abs(ports.entry.y - runY) > 0.5) {
1367
+ if (Math.sign(ports.entry.y - runY) !== -Math.sign(normalOf(ports.entrySide).y)) return false
1368
+ }
1369
+ return true
1370
+ }
1371
+
1372
+ /** Where a branch meets its trunk, one per merged edge. They are drawn after every line, so no line covers one. */
1373
+ const junctions: JunctionShape[] = []
1374
+ for (const [edgeIndex, edge] of diagram.edges.entries()) {
1375
+ const source = endBox(edge.from)
1376
+ const target = endBox(edge.to)
1377
+ if (source === undefined || target === undefined) continue
1378
+ const colour = roleById.get(edge.from) ?? 'muted'
1379
+
1380
+ if (edge.from === edge.to) {
1381
+ const x = source.x + source.width
1382
+ const y = source.y + source.height / 2
1383
+ const shape: PathShape = {
1384
+ kind: 'path',
1385
+ points: [
1386
+ { x, y: y - 9 },
1387
+ { x: x + 14, y: y - 9 },
1388
+ { x: x + 14, y: y + 9 },
1389
+ { x, y: y + 9 },
1390
+ ],
1391
+ label: edge.label,
1392
+ // The one label that is not on its own edge: a self loop's longest run is eighteen points, and its name
1393
+ // does not fit on that. It sits beside the loop, where it belongs to the edge and touches nothing.
1394
+ labelX: x + 20 + textWidth(edge.label) / 2,
1395
+ labelY: y,
1396
+ colour,
1397
+ ...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
1398
+ ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
1399
+ }
1400
+ shapes.push(shape)
1401
+ routes.push({ shape, edge: edgeIndex, fixed: true })
1402
+ continue
1403
+ }
1404
+
1405
+ const anchors = anchorsFor.get(edgeIndex)
1406
+
1407
+ // A branch of an accepted bundle: out of its own port, square onto the trunk, along the trunk to the bundle's one
1408
+ // port, and in. The last leg is shared with every other branch of the group, so the target is entered once; the
1409
+ // branch still draws its own head and its own label. The junction is recorded where the branch meets the trunk,
1410
+ // because a reader who is not shown the join reads the two lines as one line that was broken. A branch that would
1411
+ // cross a box is refused here as well: the planner checked it, but the painter is the last stage that knows every
1412
+ // box the drawing actually carries.
1413
+ const groupId = trunks.groupOf.get(edgeIndex)
1414
+ if (groupId !== undefined && anchors !== undefined) {
1415
+ const group = trunks.groups[groupId] as TrunkGroup
1416
+ const entry = sharedEntry.get(groupId) as Point
1417
+ const branch: Point[] = isVerticalSide(group.side)
1418
+ ? [anchors.exit, { x: anchors.exit.x, y: group.at }, { x: entry.x, y: group.at }, entry]
1419
+ : [anchors.exit, { x: group.at, y: anchors.exit.y }, { x: group.at, y: entry.y }, entry]
1420
+ const points = simplify(branch)
1421
+ if (clearOfBoxes(points, source, target)) {
1422
+ paintRoute(edgeIndex, edge, points, colour)
1423
+ // The join itself, read off the two ports rather than off the emitted points: a branch that drops straight
1424
+ // into the shared port is drawn as one segment, and its junction is still where the trunk crosses it.
1425
+ const meet = isVerticalSide(group.side) ? { x: anchors.exit.x, y: group.at } : { x: group.at, y: anchors.exit.y }
1426
+ junctions.push({ kind: 'junction', x: meet.x, y: meet.y, colour, trunk: group.id })
1427
+ continue
1428
+ }
1429
+ }
1430
+
1431
+ // A long edge follows the lane the channel stage gave it: down the exit stub, across to the lane's x on the run
1432
+ // the gap was assigned, down the lane, and across to the entry stub. The spine carries one point per gap, so the
1433
+ // two runs are the places where a port and the lane disagree, and everything between them is one straight drop.
1434
+ const lane = placement.lanes.get(edgeIndex)
1435
+ if (lane !== undefined && lane.points.length > 0 && anchors !== undefined) {
1436
+ const shift = laneShift.get(edgeIndex) ?? 0
1437
+ const ordered = lane.reversed ? [...lane.points].reverse() : lane.points
1438
+ const waypoints = shift === 0 ? ordered : ordered.map((point) => ({ x: point.x + shift, y: point.y }))
1439
+ const points: Point[] = [anchors.exit]
1440
+ let previous = anchors.exit
1441
+ for (const waypoint of waypoints) {
1442
+ 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 })
1443
+ points.push(waypoint)
1444
+ previous = waypoint
1445
+ }
1446
+ 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 })
1447
+ points.push(anchors.entry)
1448
+ const simplified = simplify(points)
1449
+ if (clearOfBoxes(simplified, source, target)) {
1450
+ paintPath(edgeIndex, edge, simplified, colour)
1451
+ continue
1452
+ }
1453
+ }
1454
+
1455
+ // Two rows with nothing between them: the edge is the two-bend run the channel stage stacked. The run's height is
1456
+ // an empty strip only when the two rows it was measured between are the boxes actually drawn: a run reserved
1457
+ // between two boundaries is a strip outside them, while the painter's ports belong to boxes inside them. So the
1458
+ // proposal is checked, not trusted, and an edge whose legs would cross a box is the router's.
1459
+ const runY = placement.runs.get(edgeIndex)
1460
+ if (runY !== undefined && anchors !== undefined && runFits(anchors, runY)) {
1461
+ const points = runRoute(anchors, runY)
1462
+ if (clearOfBoxes(points, source, target)) {
1463
+ paintRoute(edgeIndex, edge, points, colour)
1464
+ continue
1465
+ }
1466
+ }
1467
+
1468
+ // The router is the last stage. Its own straight-line shortcut is already refused when a box is in the way; a
1469
+ // search that cannot finish still returns a line, and the final pass over the assembled drawing reports it.
1470
+ paintRoute(edgeIndex, edge, routeEdge(source, target, boxList, anchors).points, colour)
1471
+ }
1472
+
1473
+ // The dots last of the lines: a dot marks a join, so no line of the drawing may be drawn over it.
1474
+ for (const dot of junctions) shapes.push(dot)
1475
+
1476
+ // The assembled drawing, checked once: every segment of every drawn edge against every box it does not terminate
1477
+ // at. Each candidate route was checked before it was emitted, so this pass is the assertion that no stage slipped a
1478
+ // segment through a box - and the place a router that found no way round is reported rather than hidden.
1479
+ const boxEntries = Array.from(boxes.entries())
1480
+ const routingFailures: RoutingFailure[] = []
1481
+ for (const { shape, edge } of routes) {
1482
+ const diagramEdge = diagram.edges[edge] as DiagramEdge
1483
+ const source = endBox(diagramEdge.from)
1484
+ const target = endBox(diagramEdge.to)
1485
+ if (source === undefined || target === undefined) continue
1486
+ for (const segment of segmentsOfRoute(shape)) {
1487
+ for (const [id, box] of boxEntries) {
1488
+ if (box === source || box === target) continue
1489
+ if (!crossesBox(segment.x1, segment.y1, segment.x2, segment.y2, box)) continue
1490
+ routingFailures.push({
1491
+ edge,
1492
+ from: diagramEdge.from,
1493
+ to: diagramEdge.to,
1494
+ box: id,
1495
+ x1: segment.x1,
1496
+ y1: segment.y1,
1497
+ x2: segment.x2,
1498
+ y2: segment.y2,
1499
+ })
1500
+ }
1501
+ }
1502
+ }
1503
+
1504
+ // Labels last. A label has to clear every other edge, and until the last edge is drawn the painter does not know
1505
+ // where those are: this is why the whole pass exists rather than a decision made edge by edge.
1506
+ const routeSegments = routes.map(({ shape }) => segmentsOfRoute(shape))
1507
+ const placedLabels: LabelSpot[] = []
1508
+ routes.forEach(({ shape, fixed }, at) => {
1509
+ if (fixed) {
1510
+ if (shape.label !== '') {
1511
+ placedLabels.push({ x: shape.labelX, y: shape.labelY, width: textWidth(shape.label), height: LABEL_HEIGHT })
1512
+ }
1513
+ return
1514
+ }
1515
+ if (shape.label === '') return
1516
+ const placement = placeLabel(
1517
+ shape.label,
1518
+ pointsOfRoute(shape),
1519
+ boxList,
1520
+ placedLabels,
1521
+ routeSegments.filter((_, other) => other !== at),
1522
+ )
1523
+ shape.labelX = placement.x
1524
+ shape.labelY = placement.y
1525
+ shape.labelPlacement = {
1526
+ run: placement.run,
1527
+ collisions: placement.collisions,
1528
+ boxes: placement.boxes,
1529
+ labels: placement.labels,
1530
+ edges: placement.edges,
1531
+ }
1532
+ placedLabels.push({ x: placement.x, y: placement.y, width: placement.width, height: placement.height })
1533
+ })
1534
+ const labelCollisions = routes.reduce((total, { shape }) => total + ((shape.labelPlacement?.collisions ?? 0) > 0 ? 1 : 0), 0)
1535
+
1536
+ shapes.push({ kind: 'title', x: MARGIN, y: MARGIN + 12, text: diagram.title, colour: 'text' })
1537
+
1538
+ const contentRight = Math.max(
1539
+ MARGIN,
1540
+ ...Array.from(boxes.values(), (box) => box.x + box.width),
1541
+ ...Array.from(groupRects.values(), (rect) => rect.x + rect.width),
1542
+ )
1543
+ const captionLines = wrap(diagram.caption, CAPTION_CHARS)
1544
+ const captionWidth = Math.max(...captionLines.map((line) => line.length * CAPTION_CHAR_WIDTH)) + MARGIN * 2
1545
+ const width = Math.max(contentRight + MARGIN, captionWidth, 240)
1546
+
1547
+ const contentBottom = Math.max(
1548
+ topOffset,
1549
+ ...Array.from(boxes.values(), (box) => box.y + box.height),
1550
+ ...Array.from(groupRects.values(), (rect) => rect.y + rect.height),
1551
+ )
1552
+ const captionTop = contentBottom + CAPTION_GAP
1553
+ shapes.push({ kind: 'caption', x: MARGIN, y: captionTop, lines: captionLines, colour: captionRole(diagram.caption) })
1554
+ const height = captionTop + captionLines.length * CAPTION_LINE + MARGIN / 2
1555
+
1556
+ // Everything drawn counts, including the parts that leave the box grid: an edge routed beside the drawing and a
1557
+ // label wider than the gap between two boxes were both cut off at the right edge.
1558
+ const extent = drawnExtent(shapes)
1559
+ const canvasWidth = Math.max(width, extent.width + MARGIN)
1560
+ const canvasHeight = Math.max(height, extent.height + MARGIN)
1561
+
1562
+ return {
1563
+ canvas: { width: Math.round(canvasWidth), height: Math.round(canvasHeight), shapes, routingFailures, labelCollisions },
1564
+ colours,
1565
+ }
1566
+ }
1567
+
1568
+ export function paint(diagram: Diagram, overrides: Partial<Palette> = {}): Canvas {
1569
+ return paintWith(diagram, overrides).canvas
1570
+ }