dsh-model-everything 0.5.1 → 0.6.0

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.
@@ -11,17 +11,27 @@
11
11
  * route, not picked by comparing the boxes' centres: a route between two rows arrives vertically, so the endpoint
12
12
  * is snapped onto the top or bottom perimeter in the direction the route approaches. Where along that side it sits
13
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.
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.
15
17
  *
16
18
  * A symbol with discrete points gives each of its edges a point of its own: the point whose outward normal faces the
17
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
18
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
19
21
  * points at all. Sharing is the fallback, not the rule: two labelled branches of a decision leave by two vertices.
20
22
  *
21
- * An edge's name is drawn on the edge, not beside it: it is anchored at the midpoint of the edge's longest straight
22
- * run, over a rectangle in the page colour that masks the line behind the text, and it slides along that run when
23
- * something is in the way. Labels are placed after every edge is drawn, because what a label has to avoid is the
24
- * whole picture.
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.
25
35
  *
26
36
  * Pure: a diagram in, shapes out, every decision assertable.
27
37
  */
@@ -29,6 +39,7 @@
29
39
  import { MARGIN, place, textOf, type Box } from './place.js'
30
40
  import { PORT_INSET } from './channels.js'
31
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'
32
43
  import { cylinderApex, cylinderCaps, hasDiscretePorts, hexagonCut, stadiumRadius } from './symbol.js'
33
44
  import { textWidth } from './text.js'
34
45
  import { palette, roleForVerdict, type ColourRole, type Palette, type PaletteOptions } from './palette.js'
@@ -41,6 +52,8 @@ export interface Point {
41
52
 
42
53
  export interface BoxShape {
43
54
  kind: 'box'
55
+ /** Which node this box is. It travels into the markup, so a reader can target one node by delegation. */
56
+ id: string
44
57
  x: number
45
58
  y: number
46
59
  width: number
@@ -54,6 +67,23 @@ export interface BoxShape {
54
67
  initial: boolean
55
68
  }
56
69
 
70
+ /**
71
+ * Why a label sits where it does.
72
+ *
73
+ * A clear label records `collisions: 0` and the run it chose. A label that could clear nothing records the collisions
74
+ * it could not avoid, so a reader of the drawing (or a test) can tell a crowded drawing from a placement bug. The
75
+ * label never leaves its own edge to earn any of these numbers.
76
+ */
77
+ export interface LabelPlacement {
78
+ /** The straight run it sits on, 0 being the edge's longest. */
79
+ run: number
80
+ /** Everything its mask overlaps: the other labels, boxes and edges it would have had to move off its line to clear. */
81
+ collisions: number
82
+ boxes: number
83
+ labels: number
84
+ edges: number
85
+ }
86
+
57
87
  export interface LinkShape {
58
88
  kind: 'link'
59
89
  x1: number
@@ -69,6 +99,8 @@ export interface LinkShape {
69
99
  dashed?: boolean
70
100
  /** The arrow the source edge stated. Undefined means the plain arrow every edge has been drawn with. */
71
101
  arrow?: EdgeArrow
102
+ /** Why the label sits where it does. Undefined on an edge with no label. */
103
+ labelPlacement?: LabelPlacement
72
104
  }
73
105
 
74
106
  export interface PathShape {
@@ -81,6 +113,23 @@ export interface PathShape {
81
113
  colour: ColourRole
82
114
  dashed?: boolean
83
115
  arrow?: EdgeArrow
116
+ /** Why the label sits where it does. Undefined on an edge with no label. */
117
+ labelPlacement?: LabelPlacement
118
+ }
119
+
120
+ /**
121
+ * A junction dot: the place one branch of a merged bundle meets the bundle's trunk.
122
+ *
123
+ * The dot is not decoration. Two lines that meet at a right angle read as a crossing unless one of them is marked as
124
+ * joining the other, and a bundle whose branches are not marked reads as a set of broken lines rather than a merge.
125
+ */
126
+ export interface JunctionShape {
127
+ kind: 'junction'
128
+ x: number
129
+ y: number
130
+ colour: ColourRole
131
+ /** The trunk the dot belongs to: one dot per branch, one trunk id per bundle. */
132
+ trunk: number
84
133
  }
85
134
 
86
135
  /** A boundary around several states: a package, a layer, a subgraph. */
@@ -112,7 +161,7 @@ export interface TitleShape {
112
161
  colour: ColourRole
113
162
  }
114
163
 
115
- export type Shape = GroupShape | BoxShape | LinkShape | PathShape | CaptionShape | TitleShape
164
+ export type Shape = GroupShape | BoxShape | LinkShape | PathShape | JunctionShape | CaptionShape | TitleShape
116
165
 
117
166
  /** Which side of each box an edge leaves by and arrives at, and, for a long edge, the lane x it meets there. */
118
167
  interface Facing {
@@ -121,10 +170,33 @@ interface Facing {
121
170
  at?: number
122
171
  }
123
172
 
173
+ /** One segment of a drawn route that could not be kept out of a box it does not terminate at. */
174
+ export interface RoutingFailure {
175
+ /** The edge's index in the diagram. */
176
+ edge: number
177
+ from: string
178
+ to: string
179
+ /** The box the segment passes through. */
180
+ box: string
181
+ x1: number
182
+ y1: number
183
+ x2: number
184
+ y2: number
185
+ }
186
+
124
187
  export interface Canvas {
125
188
  width: number
126
189
  height: number
127
190
  shapes: Shape[]
191
+ /**
192
+ * Every place a drawn segment still passes through a box it does not terminate at, after every route was checked.
193
+ *
194
+ * A drawing should carry none. The field exists for the case where no stage and no router could find a way around:
195
+ * a route that cannot be drawn honestly is reported here rather than silently drawn through a box.
196
+ */
197
+ routingFailures: RoutingFailure[]
198
+ /** How many edge labels could not be placed clear of every box, label and other edge. */
199
+ labelCollisions: number
128
200
  }
129
201
 
130
202
  /** Geometry constants in one place: a container with different proportions changes these, not the rules. */
@@ -179,6 +251,23 @@ function packPorts(wanted: number[], low: number, high: number): number[] {
179
251
  return placed
180
252
  }
181
253
 
254
+ /**
255
+ * Centre a row of ports on the side it sits on.
256
+ *
257
+ * The row keeps its order, its gaps and its separation, and is translated as a whole so its outer two ports sit
258
+ * symmetrically about the middle of the span: two ports become the middle less and plus half their gap, and a longer
259
+ * row is the same run of places the routing asked for, moved to where the side has room for it. A row that cannot
260
+ * travel the whole way stops where its last port reaches the end of the span - the alternative is a port off the box.
261
+ */
262
+ function centreRun(placed: number[], low: number, high: number): number[] {
263
+ if (placed.length === 0) return placed
264
+ const first = placed[0] as number
265
+ const last = placed[placed.length - 1] as number
266
+ const middle = (low + high) / 2
267
+ const shift = Math.min(high - last, Math.max(low - first, middle - (first + last) / 2))
268
+ return shift === 0 ? placed : placed.map((value) => value + shift)
269
+ }
270
+
182
271
  /** Which colour a box earns from its role. One mapping, used by every container. */
183
272
  export function roleOfNode(node: DiagramNode): ColourRole {
184
273
  if (node.role === 'error') return 'fail'
@@ -246,26 +335,58 @@ interface LabelSpot {
246
335
  height: number
247
336
  }
248
337
 
249
- /** The point an edge's label is drawn at.
338
+ /** A straight run of an edge: one of the pieces a label may sit on. */
339
+ interface Run {
340
+ from: Point
341
+ to: Point
342
+ /** The run is axis-aligned, so its Manhattan length is its length. */
343
+ length: number
344
+ /** Where the run sits in the route, so two equal runs keep the order they are drawn in. */
345
+ index: number
346
+ }
347
+
348
+ /** The runs of one edge, longest first. A label prefers the run with the most room. */
349
+ function runsOf(points: Point[]): Run[] {
350
+ const runs: Run[] = []
351
+ for (let index = 0; index + 1 < points.length; index += 1) {
352
+ const from = points[index]
353
+ const to = points[index + 1]
354
+ const length = Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
355
+ if (length < 1) continue
356
+ runs.push({ from, to, length, index })
357
+ }
358
+ return runs.sort((left, right) => right.length - left.length || left.index - right.index)
359
+ }
360
+
361
+ /**
362
+ * The point an edge's label is drawn at.
363
+ *
364
+ * A label is not furniture parked beside the line: it names the transition, so it sits on the line - anchored on one
365
+ * of its own straight runs - and the writer masks the stretch of line it covers. Text beside a line can be read as
366
+ * belonging to whatever it happens to sit next to; text that interrupts its own line reads as part of it.
250
367
  *
251
- * A label is not furniture parked beside the line: it names the transition, so it sits on the line - anchored at
252
- * the middle of the longest straight run of its own edge - and the writer masks the stretch of line it covers.
253
- * Text beside a line can be read as belonging to whatever it happens to sit next to; text that interrupts its own
254
- * line reads as part of it.
368
+ * The anchor then has to be earned. A mask over a box hides a border, over another label it makes both unreadable,
369
+ * and over a third edge's line it hides that line. So the label walks along a run in small steps, in both directions
370
+ * and bounded so its rectangle stays on that run, and takes the first place that is clear. It tries the longest run
371
+ * first and then the shorter ones: on a dense drawing the longest run can be crowded for its whole length while a
372
+ * shorter run of the same edge is empty, and a label that gives up after one run leaves the drawing with a collision
373
+ * it did not have to accept.
255
374
  *
256
- * The anchor then has to be earned. A mask over a box hides a border, over another label it makes both
257
- * unreadable, and over a third edge's line it hides that line. So the label walks along its own run in small
258
- * steps, in both directions and bounded so its rectangle stays on that run, and takes the first place that is
259
- * clear. It never leaves the run: a label moved onto a neighbouring edge is a label that names the wrong thing.
375
+ * When no run offers a clear place, the label takes the place with the fewest collisions - boxes and other labels
376
+ * first, because text over a border hides the border and text over text is unreadable, then other edges, which a mask
377
+ * merely interrupts. It never leaves its own edge: a label moved onto a neighbouring edge is a label that names the
378
+ * wrong thing. The choice is returned with the label, so a crowded drawing can be told from a placement bug.
379
+ *
380
+ * Exported because the choice is pure and worth asserting without a layout: a test can hand it one polyline, one box
381
+ * and one other route, and read which run it took and what it could not clear.
260
382
  */
261
- function placeLabel(
383
+ export function placeLabel(
262
384
  label: string,
263
- from: Point,
264
- to: Point,
385
+ points: Point[],
265
386
  boxes: Iterable<Box>,
266
387
  placed: LabelSpot[],
267
- segments: Segment[],
268
- ): LabelSpot {
388
+ otherRoutes: Segment[][],
389
+ ): LabelPlacement & LabelSpot {
269
390
  const width = textWidth(label)
270
391
  const height = LABEL_HEIGHT
271
392
  const spotAt = (x: number, y: number): LabelSpot => ({ x, y, width, height })
@@ -286,42 +407,60 @@ function placeLabel(
286
407
  spot.x - spot.width / 2 < Math.max(segment.x1, segment.x2) + LABEL_PAD &&
287
408
  spot.y + spot.height / 2 > Math.min(segment.y1, segment.y2) - LABEL_PAD &&
288
409
  spot.y - spot.height / 2 < Math.max(segment.y1, segment.y2) + LABEL_PAD
289
- const clearOfBoxes = (spot: LabelSpot): boolean => {
290
- for (const box of boxes) if (hitsBox(spot, box)) return false
291
- for (const earlier of placed) if (hitsSpot(spot, earlier)) return false
292
- return true
410
+ /** Everything one candidate place overlaps, counted by the thing it covers rather than by the segment that draws it. */
411
+ const collisionsAt = (spot: LabelSpot, run: number): LabelPlacement => {
412
+ let boxHits = 0
413
+ let labelHits = 0
414
+ let edgeHits = 0
415
+ for (const box of boxes) if (hitsBox(spot, box)) boxHits += 1
416
+ for (const earlier of placed) if (hitsSpot(spot, earlier)) labelHits += 1
417
+ for (const route of otherRoutes) {
418
+ for (const segment of route) {
419
+ if (!hitsSegment(spot, segment)) continue
420
+ edgeHits += 1
421
+ break
422
+ }
423
+ }
424
+ return { run, collisions: boxHits + labelHits + edgeHits, boxes: boxHits, labels: labelHits, edges: edgeHits }
293
425
  }
294
- const clearOfEdges = (spot: LabelSpot): boolean => {
295
- for (const segment of segments) if (hitsSegment(spot, segment)) return false
296
- return true
426
+
427
+ const runs = runsOf(points)
428
+ if (runs.length === 0) {
429
+ // A route with no straight piece has no line to sit on: keep the label at the route's own point rather than move
430
+ // it into the air. Only a degenerate route reaches this, and the writer still draws its mask.
431
+ const point = points[0] ?? { x: 0, y: 0 }
432
+ return { ...spotAt(point.x, point.y), run: -1, collisions: 0, boxes: 0, labels: 0, edges: 0 }
433
+ }
434
+
435
+ let best: (LabelPlacement & LabelSpot) | undefined
436
+ for (const [runIndex, run] of runs.entries()) {
437
+ const dx = run.to.x - run.from.x
438
+ const dy = run.to.y - run.from.y
439
+ const ux = dx / run.length
440
+ const uy = dy / run.length
441
+ const middle = { x: (run.from.x + run.to.x) / 2, y: (run.from.y + run.to.y) / 2 }
442
+ // The walk is bounded so the label's own rectangle stays on the run that names it: a mask that hangs off the
443
+ // end of its segment would be half over the next thing and would not read as sitting on this edge.
444
+ const along = (Math.abs(ux) * width) / 2 + (Math.abs(uy) * height) / 2
445
+ const bound = Math.max(0, run.length / 2 - along)
446
+ const walk: number[] = [0]
447
+ for (let step = LABEL_STEP; step <= bound + 0.001; step += LABEL_STEP) walk.push(step, -step)
448
+ for (const offset of walk) {
449
+ const spot = spotAt(middle.x + ux * offset, middle.y + uy * offset)
450
+ const placement = collisionsAt(spot, runIndex)
451
+ // A clear place is the answer, and the first one is the preferred one: the search order is already the
452
+ // drawing's preference - longest run, own midpoint, then either side.
453
+ if (placement.collisions === 0) return { ...spot, ...placement }
454
+ if (
455
+ best === undefined ||
456
+ placement.collisions < best.collisions ||
457
+ (placement.collisions === best.collisions && placement.boxes + placement.labels < best.boxes + best.labels)
458
+ ) {
459
+ best = { ...spot, ...placement }
460
+ }
461
+ }
297
462
  }
298
- const middle = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }
299
- // The run is axis-aligned, so its Manhattan length is its length.
300
- const dx = to.x - from.x
301
- const dy = to.y - from.y
302
- const length = Math.abs(dx) + Math.abs(dy)
303
- const ux = dx / length
304
- const uy = dy / length
305
- // The walk is bounded so the label's own rectangle stays on the run that names it: a mask that hangs off the
306
- // end of its segment would be half over the next thing and would not read as sitting on this edge.
307
- const along = (Math.abs(ux) * width) / 2 + (Math.abs(uy) * height) / 2
308
- const bound = Math.max(0, length / 2 - along)
309
- const walk: number[] = [0]
310
- for (let step = LABEL_STEP; step <= bound + 0.001; step += LABEL_STEP) walk.push(step, -step)
311
- const at = (offset: number): LabelSpot => spotAt(middle.x + ux * offset, middle.y + uy * offset)
312
- for (const offset of walk) {
313
- const spot = at(offset)
314
- if (clearOfBoxes(spot) && clearOfEdges(spot)) return spot
315
- }
316
- // Nothing on the run is clear of every other edge - two runs may lie side by side for their whole length, and
317
- // then no place on one is clear of the other. Covering a neighbour's line is the least of the three: text over
318
- // a line still reads, while text over a box hides the box and text over text is unreadable. The label stays
319
- // on its own edge, which is the one thing it may not give up.
320
- for (const offset of walk) {
321
- const spot = at(offset)
322
- if (clearOfBoxes(spot)) return spot
323
- }
324
- return at(0)
463
+ return best as LabelPlacement & LabelSpot
325
464
  }
326
465
 
327
466
 
@@ -352,11 +491,35 @@ function drawnExtent(shapes: Shape[]): { width: number; height: number } {
352
491
  case 'title':
353
492
  consider(textWidth((shape as { lines?: string[]; text?: string }).lines?.[0] ?? '') + shape.x, shape.y + 12)
354
493
  break
494
+ case 'junction':
495
+ // A dot sits on a line that already reaches further than it does: counting it would never widen the canvas.
496
+ break
355
497
  }
356
498
  }
357
499
  return { width, height }
358
500
  }
359
501
 
502
+ /**
503
+ * The boxes a polyline passes through other than the two it terminates at.
504
+ *
505
+ * This is the drawing's one clearance rule, and it is deliberately written once: a channel run, a lane, a trunk
506
+ * branch, a wrapped column's sideways step and the router's fallback all have to obey the same test. A box is skipped
507
+ * 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)
508
+ * is allowed and a segment that ends inside somebody else's box is not.
509
+ */
510
+ export function boxesCrossedBy(points: Point[], from: Box, to: Box, boxes: Iterable<Box>): Box[] {
511
+ const crossed: Box[] = []
512
+ for (let index = 0; index + 1 < points.length; index += 1) {
513
+ const a = points[index]
514
+ const b = points[index + 1]
515
+ for (const box of boxes) {
516
+ if (box === from || box === to) continue
517
+ if (crossesBox(a.x, a.y, b.x, b.y, box)) crossed.push(box)
518
+ }
519
+ }
520
+ return crossed
521
+ }
522
+
360
523
  export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, options: PaletteOptions = {}): { canvas: Canvas; colours: Palette } {
361
524
  const colours = palette(overrides, options)
362
525
  const shapes: Shape[] = []
@@ -387,8 +550,15 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
387
550
  const drawn = new Set<string>()
388
551
  // A Map iterator is consumed by the first pass: collision checks need a list.
389
552
  const boxList = Array.from(boxes.values())
553
+ /** One drawn edge: the shape, its index in the diagram, and whether its label was fixed rather than placed. */
554
+ interface DrawnRoute {
555
+ shape: LinkShape | PathShape
556
+ edge: number
557
+ /** A self loop's label is drawn beside the loop by design; the placement pass leaves it alone. */
558
+ fixed: boolean
559
+ }
390
560
  /** The edges drawn as lines, in paint order: their labels are placed once every one of them is known. */
391
- const routes: Array<LinkShape | PathShape> = []
561
+ const routes: DrawnRoute[] = []
392
562
  for (const node of diagram.nodes) {
393
563
  // A state id may repeat in a malformed model: the first placement wins and the repeat is skipped.
394
564
  if (drawn.has(node.id)) continue
@@ -397,6 +567,7 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
397
567
  const text = textOf(node)
398
568
  shapes.push({
399
569
  kind: 'box',
570
+ id: node.id,
400
571
  x: box.x,
401
572
  y: box.y,
402
573
  width: box.width,
@@ -450,32 +621,173 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
450
621
  endsAt.set(edge.to, (endsAt.get(edge.to) ?? 0) + 1)
451
622
  }
452
623
 
624
+ /**
625
+ * Where a port may sit on one side of one node, as the interval its coordinate may take.
626
+ *
627
+ * 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
628
+ * where the symbol has to be known. A rectangle spreads a port along the side, and a subroutine does the same. A
629
+ * diamond's side meets its outline at exactly one point - the vertex - so its interval is that point, and a circle's
630
+ * is its cardinal point; a point is all that side has to give, so two edges on one side share it rather than take
631
+ * two points in the air. A hexagon's top and bottom are the flat run between the cut corners, and its left and right
632
+ * are the vertices. A stadium's flat run stops at the end caps, and a cylinder's flat sides are the two vertical
633
+ * runs, never the caps.
634
+ *
635
+ * A zero-width interval is not a failure: the packer keeps every port on it, so a symbol that offers one point on a
636
+ * side hands that point to every edge that wants it. Which is why a symbol with four points is given one edge per
637
+ * side first - the point is only shared when the four are taken.
638
+ */
639
+ const spanOf = (boxId: string, side: Side, corner: boolean): { low: number; high: number } => {
640
+ const box = endBox(boxId) as Box
641
+ const vertical = side === 'top' || side === 'bottom'
642
+ const middle = vertical ? box.x + box.width / 2 : box.y + box.height / 2
643
+ const within = (low: number, high: number): { low: number; high: number } => ({ low, high: Math.max(low, high) })
644
+ switch (shapeOf(boxId)) {
645
+ // The four vertices of a diamond, and the four cardinal points of an ellipse, are the middles of its sides.
646
+ case 'diamond':
647
+ case 'circle':
648
+ return within(middle, middle)
649
+ case 'hexagon':
650
+ return vertical ? within(box.x + hexagonCut(box.width), box.x + box.width - hexagonCut(box.width)) : within(middle, middle)
651
+ case 'stadium': {
652
+ const radius = stadiumRadius(box)
653
+ return vertical ? within(box.x + radius, box.x + box.width - radius) : within(middle, middle)
654
+ }
655
+ case 'cylinder': {
656
+ if (vertical) return within(middle, middle)
657
+ const cap = cylinderCaps(box).cap
658
+ return within(box.y + cap, box.y + box.height - cap)
659
+ }
660
+ default: {
661
+ // A rectangle, and a subroutine's doubled side, keep the margins they have always had; `corner` relaxes them
662
+ // because a side that is crowded uses its corners.
663
+ const inset = corner ? 2 : vertical ? PORT_INSET : 8
664
+ return vertical ? within(box.x + inset, box.x + box.width - inset) : within(box.y + inset, box.y + box.height - inset)
665
+ }
666
+ }
667
+ }
668
+ const clampTo = (boxId: string, side: Side, value: number, corner: boolean): number => {
669
+ const span = spanOf(boxId, side, corner)
670
+ return Math.min(span.high, Math.max(span.low, value))
671
+ }
672
+ /**
673
+ * The boundary point a coordinate along one side takes.
674
+ *
675
+ * The box's rectangle is the point for every symbol whose side is the box's side. A cylinder's top and bottom are
676
+ * arcs, so a port there sits on the cap curve's apex - `cap / 4` inside the box, not on the box's own edge.
677
+ */
678
+ const pointOn = (boxId: string, side: Side, along: number): Point => {
679
+ const box = endBox(boxId) as Box
680
+ if (side === 'left') return { x: box.x, y: along }
681
+ if (side === 'right') return { x: box.x + box.width, y: along }
682
+ if (side === 'top') return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'top').y : box.y }
683
+ return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'bottom').y : box.y + box.height }
684
+ }
453
685
  /** The side each edge end's port is on, where the port is a point rather than a place along a run. */
454
686
  const claimed = new Map<string, Side>()
455
- const claimedAt = new Map<string, Side[]>()
687
+ const claimedAt = new Map<string, Array<{ index: number; side: Side }>>()
456
688
  const claim = (id: string, index: number, end: 'exit' | 'entry', side: Side): void => {
457
689
  claimed.set(endKey(index, end), side)
458
- claimedAt.set(id, [...(claimedAt.get(id) ?? []), side])
690
+ claimedAt.set(id, [...(claimedAt.get(id) ?? []), { index, side }])
691
+ }
692
+ /** The sides of one node another edge already holds. An edge is not crowded by its own other end. */
693
+ const takenSides = (id: string, except: number): Side[] =>
694
+ (claimedAt.get(id) ?? []).filter((entry) => entry.index !== except).map((entry) => entry.side)
695
+
696
+ /** The middle of one side: the coordinate that side's point sits at when nothing has moved it. */
697
+ const sideMiddle = (box: Box, side: Side): number => (isVerticalSide(side) ? box.x + box.width / 2 : box.y + box.height / 2)
698
+
699
+ /** The point a side offers at its middle: a symbol's own point, or the middle of the run it spreads along. */
700
+ const sidePoint = (boxId: string, side: Side): Point => pointOn(boxId, side, sideMiddle(endBox(boxId) as Box, side))
701
+
702
+ /** Does this side face away from the box at the other end? A route leaving it has to come back round its own box. */
703
+ const facesAway = (box: Box, other: Box, side: Side): boolean => {
704
+ const out = normalOf(side)
705
+ const dx = other.x + other.width / 2 - (box.x + box.width / 2)
706
+ const dy = other.y + other.height / 2 - (box.y + box.height / 2)
707
+ return out.x * dx + out.y * dy < 0
708
+ }
709
+
710
+ /** The coordinate a free side takes when the other end is pinned: the pinned port's stub, read on the free axis. */
711
+ const stubAlong = (pinnedId: string, pinnedSide: Side, freeSide: Side): number => {
712
+ const point = sidePoint(pinnedId, pinnedSide)
713
+ const out = normalOf(pinnedSide)
714
+ return isVerticalSide(freeSide) ? point.x + out.x * ROUTE_STUB : point.y + out.y * ROUTE_STUB
715
+ }
716
+
717
+ /** Can this side put a port at `value`? A point-side offers exactly one coordinate; a run offers an interval. */
718
+ const offers = (boxId: string, side: Side, value: number): boolean => {
719
+ const span = spanOf(boxId, side, true)
720
+ return value >= span.low - 0.01 && value <= span.high + 0.01
721
+ }
722
+
723
+ /**
724
+ * The bend budget a candidate (exit side, entry side) pair spends on the orthogonal route it implies, before a lane
725
+ * or a router is consulted. The numbers, and what each one is:
726
+ *
727
+ * 0 the two stubs share a line: both on vertical sides whose x can be the same, or both on horizontal sides
728
+ * whose y can be the same, with each stub pointing the way the edge travels. One straight run, no corner.
729
+ * 1 an L: the two sides are perpendicular and the one that can still move can be placed exactly where the pinned
730
+ * side's stub ends, so the route turns once. It is the rung between the straight run and the Z: an L never
731
+ * beats a run that shares a line, and always beats a Z.
732
+ * 2 a Z: the stubs are perpendicular but cannot meet at one corner, or they are parallel and offset. Out, across,
733
+ * in: two corners.
734
+ * 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
735
+ * in. The canonical U has both sides facing away; one is already enough to force the wrap, and a pair that
736
+ * has to come back the way it went is never worth a pair that does not.
737
+ *
738
+ * "Can be the same" is the symbol's own answer (`spanOf`): a rectangle offers its whole side, a diamond one point, a
739
+ * cylinder's top the apex of its cap. Two rectangles facing each other therefore share a line wherever they sit; two
740
+ * diamonds only when their vertices line up. This is a budget, not the final polyline: the router still adds the
741
+ * length an obstacle demands, and the channel stage still decides which lanes exist.
742
+ *
743
+ * A side another edge already holds is charged for the L it costs: the packer has to move this pair's port at least
744
+ * a port separation along the side, which turns the one corner the pair counted on into a jog. A Z is not charged -
745
+ * it was already off the line, and a port that moves along its side does not add a corner to it.
746
+ */
747
+ const bendBudget = (fromId: string, toId: string, exit: Side, entry: Side, index: number): number => {
748
+ const from = endBox(fromId) as Box
749
+ const to = endBox(toId) as Box
750
+ if (facesAway(from, to, exit) || facesAway(to, from, entry)) return 4
751
+ if (isVerticalSide(exit) === isVerticalSide(entry)) {
752
+ // Parallel: a straight run only when the two stubs travel the same way and the sides can meet on one line.
753
+ const out = normalOf(exit)
754
+ const into = normalOf(entry)
755
+ const sameWay = out.x === -into.x && out.y === -into.y
756
+ const fromSpan = spanOf(fromId, exit, true)
757
+ const toSpan = spanOf(toId, entry, true)
758
+ const meet = fromSpan.low <= toSpan.high + 0.01 && toSpan.low <= fromSpan.high + 0.01
759
+ return sameWay && meet ? 0 : 2
760
+ }
761
+ // Perpendicular: one corner when the vertical side can be placed at the horizontal side's stub end.
762
+ const horizontalId = isVerticalSide(exit) ? toId : fromId
763
+ const horizontalSide = isVerticalSide(exit) ? entry : exit
764
+ const verticalId = isVerticalSide(exit) ? fromId : toId
765
+ const verticalSide = isVerticalSide(exit) ? exit : entry
766
+ if (!offers(verticalId, verticalSide, stubAlong(horizontalId, horizontalSide, verticalSide))) return 2
767
+ const crowded = takenSides(fromId, index).includes(exit) || takenSides(toId, index).includes(entry)
768
+ return crowded ? 2 : 1
459
769
  }
460
770
 
461
771
  /**
462
- * The side a discrete port takes: the one whose outward normal points most nearly at the other box.
772
+ * The side a discrete port takes: the point whose pair with the edge's other end bends least.
463
773
  *
464
- * The score is the cosine of the angle between the normal and the direction to the other box, so 1 is dead ahead
465
- * and the four sides of a diamond rank by how much of the way to the target each one faces. Equal scores are broken
466
- * by the point nearest the other box - the port the route has least ground to cover from - and then by the order the
467
- * sides are drawn in. That last tie-break is what "the earlier edge keeps the earlier point" means in practice: the
468
- * edges are walked in source order, and each one takes the best side still free.
774
+ * `partner` is the side the other end holds - the side it was claimed, or, until it is claimed, the side the route
775
+ * approaches by. The bend budget comes first. Equal budgets fall back to the rule that was here before: the cosine
776
+ * of the outward normal against the direction to the other box, then the point nearest that box, then the ground
777
+ * between the two points - the small length term. So a case that already read well does not move, and a point that
778
+ * costs an extra corner loses even when it faces the target more squarely.
469
779
  */
470
- const bestSide = (id: string, other: Box): Side | undefined => {
780
+ const bestSide = (id: string, otherId: string, end: 'exit' | 'entry', partner: Side, index: number): Side | undefined => {
471
781
  const box = endBox(id) as Box
782
+ const other = endBox(otherId) as Box
472
783
  const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
473
784
  const toward = { x: other.x + other.width / 2 - centre.x, y: other.y + other.height / 2 - centre.y }
474
785
  const reach = Math.hypot(toward.x, toward.y)
475
- const taken = claimedAt.get(id) ?? []
476
- let best: { side: Side; score: number; near: number } | undefined
786
+ const taken = takenSides(id, index)
787
+ let best: { side: Side; bends: number; score: number; near: number; ground: number } | undefined
477
788
  for (const side of PORT_SIDES) {
478
789
  if (taken.includes(side)) continue
790
+ const bends = end === 'exit' ? bendBudget(id, otherId, side, partner, index) : bendBudget(otherId, id, partner, side, index)
479
791
  const normal = normalOf(side)
480
792
  const score = reach === 0 ? 0 : (normal.x * toward.x + normal.y * toward.y) / reach
481
793
  // How far the point is from the other box's rectangle: the nearest port is the one the edge reaches first.
@@ -484,15 +796,69 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
484
796
  Math.max(other.x - point.x, 0, point.x - (other.x + other.width)),
485
797
  Math.max(other.y - point.y, 0, point.y - (other.y + other.height)),
486
798
  )
799
+ const partnerPoint = anchor(other, partner)
800
+ const ground = Math.abs(point.x - partnerPoint.x) + Math.abs(point.y - partnerPoint.y)
487
801
  const better =
488
802
  best === undefined ||
489
- score > best.score + 1e-9 ||
490
- (Math.abs(score - best.score) <= 1e-9 && near < best.near - 1e-9)
491
- if (better) best = { side, score, near }
803
+ bends < best.bends ||
804
+ (bends === best.bends &&
805
+ (score > best.score + 1e-9 ||
806
+ (Math.abs(score - best.score) <= 1e-9 &&
807
+ (near < best.near - 1e-9 || (Math.abs(near - best.near) <= 1e-9 && ground < best.ground - 1e-9)))))
808
+ if (better) best = { side, bends, score, near, ground }
492
809
  }
493
810
  return best?.side
494
811
  }
495
812
 
813
+ /**
814
+ * The sides an edge leaves and enters by when no claimed point decides either end: the pair the budget likes best.
815
+ *
816
+ * Only the two same-kind pairs are offered. Both ends here spread their ports along a side, so the choice is which
817
+ * side of the box the edge travels on; a pair that turns the corner one side further is the case `bestEnd` decides,
818
+ * where a claimed point has already fixed the near end. A pair strictly cheaper than the one the route approaches by
819
+ * wins; an equal pair keeps the approach, which leaves a drawing that was already right exactly as it was.
820
+ */
821
+ const bestPair = (fromId: string, toId: string, index: number): { exit: Side; entry: Side } => {
822
+ const approach = approachSides(endBox(fromId) as Box, endBox(toId) as Box)
823
+ const pairs: Array<{ exit: Side; entry: Side }> = [
824
+ { exit: 'bottom', entry: 'top' },
825
+ { exit: 'top', entry: 'bottom' },
826
+ { exit: 'right', entry: 'left' },
827
+ { exit: 'left', entry: 'right' },
828
+ ]
829
+ let best = approach
830
+ let bestBends = bendBudget(fromId, toId, approach.exit, approach.entry, index)
831
+ for (const pair of pairs) {
832
+ const bends = bendBudget(fromId, toId, pair.exit, pair.entry, index)
833
+ if (bends >= bestBends) continue
834
+ best = pair
835
+ bestBends = bends
836
+ }
837
+ return best
838
+ }
839
+
840
+ /**
841
+ * The side one end takes when the other end has already been decided: the cheapest of the four.
842
+ *
843
+ * The side the route approaches by wins a tie, for the same reason as `bestPair` - a drawing that already read well
844
+ * does not move for a pair the budget cannot tell apart.
845
+ */
846
+ const bestEnd = (id: string, otherId: string, end: 'exit' | 'entry', partner: Side, index: number): Side => {
847
+ const approach =
848
+ end === 'exit'
849
+ ? approachSides(endBox(id) as Box, endBox(otherId) as Box).exit
850
+ : approachSides(endBox(otherId) as Box, endBox(id) as Box).entry
851
+ let best = approach
852
+ let bestBends = end === 'exit' ? bendBudget(id, otherId, approach, partner, index) : bendBudget(otherId, id, partner, approach, index)
853
+ for (const side of PORT_SIDES) {
854
+ const bends = end === 'exit' ? bendBudget(id, otherId, side, partner, index) : bendBudget(otherId, id, partner, side, index)
855
+ if (bends >= bestBends) continue
856
+ best = side
857
+ bestBends = bends
858
+ }
859
+ return best
860
+ }
861
+
496
862
  // A side the drawing has already decided is claimed first, because a point there would point the route away from
497
863
  // the corridor it has to run in: a long edge leaves by the side its lane starts from, and an edge read across two
498
864
  // wrapped columns leaves and arrives on the sides the reading order turns at.
@@ -527,7 +893,12 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
527
893
  const id = end === 'exit' ? edge.from : edge.to
528
894
  if ((endsAt.get(id) ?? 0) < 2) continue
529
895
  if (!hasDiscretePorts(shapeOf(id))) continue
530
- const side = bestSide(id, end === 'exit' ? target : source)
896
+ // The side the other end holds, or will hold if nothing claims it first.
897
+ const approached = approachSides(source, target)
898
+ const partner =
899
+ claimed.get(endKey(index, end === 'exit' ? 'entry' : 'exit')) ??
900
+ (end === 'exit' ? approached.entry : approached.exit)
901
+ const side = bestSide(id, end === 'exit' ? edge.to : edge.from, end, partner, index)
531
902
  if (side === undefined) continue
532
903
  claim(id, index, end, side)
533
904
  }
@@ -539,23 +910,25 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
539
910
  if (source === undefined || target === undefined || edge.from === edge.to) return undefined
540
911
  // A wrapped drawing is read in columns: the edge from one column to the next leaves and arrives at the side,
541
912
  // which is the direction that reading order travels. A column is always to the right of the one before it.
542
- const sides = placement.across.has(index)
543
- ? { exit: 'right' as Side, entry: 'left' as Side }
544
- : approachSides(source, target)
913
+ if (placement.across.has(index)) return { exit: 'right', entry: 'left' }
545
914
  const lane = placement.lanes.get(index)
546
- if (lane === undefined || lane.points.length === 0) {
547
- return {
548
- exit: claimed.get(endKey(index, 'exit')) ?? sides.exit,
549
- entry: claimed.get(endKey(index, 'entry')) ?? sides.entry,
550
- }
551
- }
552
915
  // A lane runs down the page, so its ends face the rows above and below wherever the box centres are.
553
916
  const down = target.y > source.y
554
- return {
555
- exit: claimed.get(endKey(index, 'exit')) ?? (down ? 'bottom' : 'top'),
556
- entry: claimed.get(endKey(index, 'entry')) ?? (down ? 'top' : 'bottom'),
557
- at: lane.points[0].x,
917
+ if (lane !== undefined && lane.points.length > 0) {
918
+ return {
919
+ exit: claimed.get(endKey(index, 'exit')) ?? (down ? 'bottom' : 'top'),
920
+ entry: claimed.get(endKey(index, 'entry')) ?? (down ? 'top' : 'bottom'),
921
+ at: lane.points[0].x,
922
+ }
558
923
  }
924
+ // No claimed point and no lane: an end that was claimed keeps its point, the other one takes the side the budget
925
+ // likes best against it; with neither claimed, the pair is chosen together.
926
+ const exit = claimed.get(endKey(index, 'exit'))
927
+ const entry = claimed.get(endKey(index, 'entry'))
928
+ if (exit !== undefined && entry !== undefined) return { exit, entry }
929
+ if (exit !== undefined) return { exit, entry: bestEnd(edge.to, edge.from, 'entry', exit, index) }
930
+ if (entry !== undefined) return { exit: bestEnd(edge.from, edge.to, 'exit', entry, index), entry }
931
+ return bestPair(edge.from, edge.to, index)
559
932
  })
560
933
  // A long edge's lane is where the whole edge runs, so a fan moves the lane instead of bending the edge twice at its
561
934
  // ends. It tries the place that keeps the set evenly spaced first, then its own place moved by the same offset; a
@@ -599,67 +972,6 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
599
972
 
600
973
  const wanted = new Map<string, number>()
601
974
  const slotKey = (boxId: string, side: Side, index: number): string => boxId + ':' + side + ':' + String(index)
602
- /**
603
- * Where a port may sit on one side of one node, as the interval its coordinate may take.
604
- *
605
- * This is the per-symbol rule, and it is why the rule lives here: a port slot is chosen in this module, so this is
606
- * where the symbol has to be known. A rectangle spreads a port along the side, and a subroutine does the same. A
607
- * diamond's side meets its outline at exactly one point - the vertex - so its interval is that point, and a circle's
608
- * is its cardinal point; a point is all that side has to give, so two edges on one side share it rather than take
609
- * two points in the air. A hexagon's top and bottom are the flat run between the cut corners, and its left and right
610
- * are the vertices. A stadium's flat run stops at the end caps, and a cylinder's flat sides are the two vertical
611
- * runs, never the caps.
612
- *
613
- * A zero-width interval is not a failure: the packer keeps every port on it, so a symbol that offers one point on a
614
- * side hands that point to every edge that wants it. Which is why a symbol with four points is given one edge per
615
- * side first - the point is only shared when the four are taken.
616
- */
617
- const spanOf = (boxId: string, side: Side, corner: boolean): { low: number; high: number } => {
618
- const box = endBox(boxId) as Box
619
- const vertical = side === 'top' || side === 'bottom'
620
- const middle = vertical ? box.x + box.width / 2 : box.y + box.height / 2
621
- const within = (low: number, high: number): { low: number; high: number } => ({ low, high: Math.max(low, high) })
622
- switch (shapeOf(boxId)) {
623
- // The four vertices of a diamond, and the four cardinal points of an ellipse, are the middles of its sides.
624
- case 'diamond':
625
- case 'circle':
626
- return within(middle, middle)
627
- case 'hexagon':
628
- return vertical ? within(box.x + hexagonCut(box.width), box.x + box.width - hexagonCut(box.width)) : within(middle, middle)
629
- case 'stadium': {
630
- const radius = stadiumRadius(box)
631
- return vertical ? within(box.x + radius, box.x + box.width - radius) : within(middle, middle)
632
- }
633
- case 'cylinder': {
634
- if (vertical) return within(middle, middle)
635
- const cap = cylinderCaps(box).cap
636
- return within(box.y + cap, box.y + box.height - cap)
637
- }
638
- default: {
639
- // A rectangle, and a subroutine's doubled side, keep the margins they have always had; `corner` relaxes them
640
- // because a side that is crowded uses its corners.
641
- const inset = corner ? 2 : vertical ? PORT_INSET : 8
642
- return vertical ? within(box.x + inset, box.x + box.width - inset) : within(box.y + inset, box.y + box.height - inset)
643
- }
644
- }
645
- }
646
- const clampTo = (boxId: string, side: Side, value: number, corner: boolean): number => {
647
- const span = spanOf(boxId, side, corner)
648
- return Math.min(span.high, Math.max(span.low, value))
649
- }
650
- /**
651
- * The boundary point a coordinate along one side takes.
652
- *
653
- * The box's rectangle is the point for every symbol whose side is the box's side. A cylinder's top and bottom are
654
- * arcs, so a port there sits on the cap curve's apex - `cap / 4` inside the box, not on the box's own edge.
655
- */
656
- const pointOn = (boxId: string, side: Side, along: number): Point => {
657
- const box = endBox(boxId) as Box
658
- if (side === 'left') return { x: box.x, y: along }
659
- if (side === 'right') return { x: box.x + box.width, y: along }
660
- if (side === 'top') return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'top').y : box.y }
661
- return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'bottom').y : box.y + box.height }
662
- }
663
975
  for (const [index, entry] of facing.entries()) {
664
976
  if (entry === undefined) continue
665
977
  const edge = diagram.edges[index]
@@ -691,16 +1003,16 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
691
1003
  exitWanted = clampTo(edge.from, entry.exit, chosen, corner)
692
1004
  entryWanted = clampTo(edge.to, entry.entry, chosen, corner)
693
1005
  } else {
694
- // The two ports sit on sides of different kinds, which only a discrete point causes. The end that is fixed on
695
- // its symbol's point keeps that point's coordinate; the other one sits where the route actually arrives - one
696
- // step out of the fixed port along its own normal - so the two legs of the route meet in a straight drop
697
- // instead of jogging sideways to reach a middle that the route never passes.
1006
+ // The two ports sit on sides of different kinds, which a discrete point causes. The end that is fixed on its
1007
+ // symbol's point keeps that point's coordinate; the other one sits where the route actually arrives - one step
1008
+ // out of the fixed port along that port's own normal, read on the free side's axis - so the two legs of the
1009
+ // route meet in one corner instead of jogging sideways to reach a middle the route never passes.
698
1010
  exitWanted = hasDiscretePorts(shapeOf(edge.from))
699
1011
  ? fromMiddle
700
- : clampTo(edge.from, entry.exit, anchor(to, entry.entry).x + normalOf(entry.entry).x * ROUTE_STUB, false) + offset
1012
+ : clampTo(edge.from, entry.exit, stubAlong(edge.to, entry.entry, entry.exit), false) + offset
701
1013
  entryWanted = hasDiscretePorts(shapeOf(edge.to))
702
1014
  ? toMiddle
703
- : clampTo(edge.to, entry.entry, anchor(from, entry.exit).x + normalOf(entry.exit).x * ROUTE_STUB, false) + offset
1015
+ : clampTo(edge.to, entry.entry, stubAlong(edge.from, entry.exit, entry.entry), false) + offset
704
1016
  }
705
1017
  wanted.set(slotKey(edge.from, entry.exit, index), exitWanted)
706
1018
  wanted.set(slotKey(edge.to, entry.entry, index), entryWanted)
@@ -752,6 +1064,181 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
752
1064
  }
753
1065
  if (!moved) break
754
1066
  }
1067
+
1068
+ /**
1069
+ * A batch of arrows into one box from one side is one bundle, not a comb: which ends may merge is the planner's
1070
+ * answer (`trunk.ts` carries the rules), and this module supplies what only it knows - which edges have a run of
1071
+ * their own, and where each port landed.
1072
+ *
1073
+ * The plan is made twice. The first pass, from the ports the packing gave, says which ends merge; the centring rule
1074
+ * below reads it, because a merged branch is drawn into the bundle's one port and that - not its own entry port -
1075
+ * is the coordinate its exit either shares a line with or does not. The second pass is made from the centred ports
1076
+ * and is the plan the drawing uses: the run it lays out is the run of the ports that are actually drawn.
1077
+ */
1078
+ const trunkEnds: TrunkEnd[] = []
1079
+ diagram.edges.forEach((edge, index) => {
1080
+ const entry = facing[index]
1081
+ if (entry === undefined) return
1082
+ const runY = placement.runs.get(index)
1083
+ trunkEnds.push({
1084
+ index,
1085
+ from: edge.from,
1086
+ to: edge.to,
1087
+ exit: entry.exit,
1088
+ entry: entry.entry,
1089
+ ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
1090
+ ...(runY === undefined ? {} : { runY }),
1091
+ })
1092
+ })
1093
+ const planFor = (exitPortOf: (index: number) => Point | undefined): TrunkPlan =>
1094
+ planTrunks({
1095
+ ends: trunkEnds,
1096
+ // Only a node's own box is a trunk target: an arrow into a boundary points past it, not at it.
1097
+ boxOf: (id) => boxes.get(id),
1098
+ boxes: boxList,
1099
+ exitPortOf,
1100
+ })
1101
+ /** Where an edge's exit port sits before the centring rule has had its say. */
1102
+ const packedExit = (index: number): Point | undefined => {
1103
+ const entry = facing[index]
1104
+ if (entry === undefined) return undefined
1105
+ const edge = diagram.edges[index] as DiagramEdge
1106
+ const coordinate = placedPort.get(slotKey(edge.from, entry.exit, index))
1107
+ return coordinate === undefined ? undefined : pointOn(edge.from, entry.exit, coordinate)
1108
+ }
1109
+ const planned = planFor(packedExit)
1110
+
1111
+ /**
1112
+ * 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.
1113
+ * A merged branch has a lane the painter never follows - the branch is drawn from its exit to the trunk - so it is
1114
+ * not pinned by it.
1115
+ */
1116
+ const onLane = (index: number, coordinate: number): boolean => {
1117
+ if (planned.groupOf.has(index)) return false
1118
+ const lane = placement.lanes.get(index)
1119
+ if (lane === undefined || lane.points.length === 0) return false
1120
+ return Math.abs((lane.points[0] as Point).x - coordinate) <= 0.5
1121
+ }
1122
+
1123
+ /**
1124
+ * A side's ports as the centring rule reads them: where they are now, and how far the whole row would move to be
1125
+ * centred. The row is the side, so its ports move together and not one by one.
1126
+ */
1127
+ interface PortRow {
1128
+ boxId: string
1129
+ side: Side
1130
+ /** The edge indexes in the row, in the row's own order, and the coordinate each one sits at. */
1131
+ indexes: number[]
1132
+ at: number[]
1133
+ /** Where each index sits in `at`, so a partner port can be read without searching. */
1134
+ position: Map<number, number>
1135
+ /** How far the whole row travels to be centred. Zero when it already is. */
1136
+ shift: number
1137
+ /** Whether it may travel at all: the fixpoint below only ever takes this away. */
1138
+ allowed: boolean
1139
+ }
1140
+
1141
+ const rows: PortRow[] = []
1142
+ const rowOf = new Map<string, PortRow>()
1143
+ for (const [key, list] of sides) {
1144
+ const [boxId, side] = key.split(':') as [string, Side]
1145
+ const span = spanOf(boxId, side, true)
1146
+ const slots = [...list]
1147
+ .map((entry) => ({ index: entry.index, at: placedPort.get(slotKey(boxId, side, entry.index)) as number }))
1148
+ .sort((left, right) => left.at - right.at || left.index - right.index)
1149
+ const centred = centreRun(
1150
+ slots.map((slot) => slot.at),
1151
+ span.low,
1152
+ span.high,
1153
+ )
1154
+ const first = slots[0] as { index: number; at: number }
1155
+ const row: PortRow = {
1156
+ boxId,
1157
+ side,
1158
+ indexes: slots.map((slot) => slot.index),
1159
+ at: slots.map((slot) => slot.at),
1160
+ position: new Map(slots.map((slot, at) => [slot.index, at] as const)),
1161
+ shift: (centred[0] as number) - first.at,
1162
+ allowed: true,
1163
+ }
1164
+ rows.push(row)
1165
+ rowOf.set(key, row)
1166
+ }
1167
+
1168
+ /** Where a port of a row will sit once the rows that may move have moved. */
1169
+ const movedAt = (row: PortRow, at: number): number => (row.at[at] as number) + (row.allowed ? row.shift : 0)
1170
+
1171
+ /**
1172
+ * The coordinate the port at the other end of this edge sits at, and the row that port belongs to.
1173
+ *
1174
+ * Three ends are not a drawn port of a row: the entry of a merged branch (the bundle has one port), the shared
1175
+ * entry itself (the planner's `entryAlong`), and a partner on a side of the other kind (a discrete symbol's point,
1176
+ * on a different axis). Each is a fixed coordinate or nothing, which is all the pin test needs.
1177
+ */
1178
+ const partnerOf = (boxId: string, side: Side, index: number): { coordinate: number; row?: PortRow; at?: number } | undefined => {
1179
+ const entry = facing[index]
1180
+ if (entry === undefined) return undefined
1181
+ const edge = diagram.edges[index] as DiagramEdge
1182
+ const isExit = edge.from === boxId && entry.exit === side
1183
+ const groupId = planned.groupOf.get(index)
1184
+ if (groupId !== undefined) {
1185
+ // A merged branch ends at the bundle's one port, so its own entry port is not drawn and pins nothing. Its exit
1186
+ // is drawn, and the line it may share is the one to that shared port.
1187
+ if (!isExit) return undefined
1188
+ const group = planned.groups[groupId] as TrunkGroup
1189
+ if (isVerticalSide(side) !== isVerticalSide(group.side)) return undefined
1190
+ return { coordinate: group.entryAlong }
1191
+ }
1192
+ const partnerId = isExit ? edge.to : edge.from
1193
+ const partnerSide = isExit ? entry.entry : entry.exit
1194
+ if (isVerticalSide(side) !== isVerticalSide(partnerSide)) return undefined
1195
+ const coordinate = placedPort.get(slotKey(partnerId, partnerSide, index))
1196
+ if (coordinate === undefined) return undefined
1197
+ const row = rowOf.get(partnerId + ':' + partnerSide)
1198
+ return { coordinate, ...(row === undefined ? {} : { row, at: row.position.get(index) as number }) }
1199
+ }
1200
+
1201
+ /**
1202
+ * A side that carries one port is centred on it; a side that carries several keeps its spread, translated as a
1203
+ * whole so its outer ports sit symmetrically about the middle.
1204
+ *
1205
+ * A port is put where the route wants it, and a side that carries one relation has no spread to fit: the only
1206
+ * reason its port is not in the middle is that the route asked for a coordinate near one end - the far end of the
1207
+ * span, the point a lane runs at. That request is real, and the bend budget decides whether it may be overruled.
1208
+ * 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
1209
+ * on a lane is the end of a channel: moving either adds two corners. Every other port moves to where its side has
1210
+ * room, which costs nothing.
1211
+ *
1212
+ * So the bend budget is a fixpoint over rows, not a test per port: a row may move unless that breaks a line it
1213
+ * shares with a port that is staying. Two ends of one straight line may both move when they move by the same
1214
+ * amount - which is what centring both sides of one pair does - so the rows are resolved together, and a row that
1215
+ * is refused takes the lines it shares with it.
1216
+ */
1217
+ for (let pass = 0; pass < rows.length; pass += 1) {
1218
+ let refused = false
1219
+ for (const row of rows) {
1220
+ if (!row.allowed || row.shift === 0) continue
1221
+ const pinned = row.indexes.some((index, at) => {
1222
+ if (onLane(index, row.at[at] as number)) return true
1223
+ const partner = partnerOf(row.boxId, row.side, index)
1224
+ if (partner === undefined) return false
1225
+ // Only a line that exists now can be broken: two ports that already differ are a Z, and moving one end of a Z
1226
+ // along its own side changes where its corners are, not how many.
1227
+ if (Math.abs((row.at[at] as number) - partner.coordinate) > 0.5) return false
1228
+ const partnerAt = partner.row === undefined ? partner.coordinate : movedAt(partner.row, partner.at as number)
1229
+ return Math.abs(movedAt(row, at) - partnerAt) > 0.5
1230
+ })
1231
+ if (!pinned) continue
1232
+ row.allowed = false
1233
+ refused = true
1234
+ }
1235
+ if (!refused) break
1236
+ }
1237
+ for (const row of rows) {
1238
+ if (!row.allowed || row.shift === 0) continue
1239
+ row.indexes.forEach((index, at) => placedPort.set(slotKey(row.boxId, row.side, index), movedAt(row, at)))
1240
+ }
1241
+
755
1242
  const portOf = (boxId: string, side: Side, edgeIndex: number): Point => {
756
1243
  const coordinate = placedPort.get(slotKey(boxId, side, edgeIndex)) as number
757
1244
  return pointOn(boxId, side, coordinate)
@@ -768,17 +1255,12 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
768
1255
  })
769
1256
  }
770
1257
 
771
- /** The longest straight run of a polyline: where a label has the most room. */
772
- const longestRun = (points: Point[]): { from: Point; to: Point } => {
773
- let best = { from: points[0], to: points[points.length - 1], length: -1 }
774
- for (let index = 0; index + 1 < points.length; index += 1) {
775
- const from = points[index]
776
- const to = points[index + 1]
777
- const length = Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
778
- if (length > best.length) best = { from, to, length }
779
- }
780
- return best
781
- }
1258
+ const trunks = planFor((index) => anchorsFor.get(index)?.exit)
1259
+ // The one port each accepted bundle enters by: the middle of the target's side, which is the port the planner
1260
+ // centred on `entryAlong`. A merged entry is one port, so the rule for a side with one port applies to it, and the
1261
+ // target carries one relation's worth of ports however many arrows point at it.
1262
+ const sharedEntry = new Map<number, Point>()
1263
+ for (const group of trunks.groups) sharedEntry.set(group.id, pointOn(group.target, group.side, group.entryAlong))
782
1264
 
783
1265
  /** The points a drawn edge passes through, whichever of the two shapes carries it. */
784
1266
  const pointsOfRoute = (shape: LinkShape | PathShape): Point[] =>
@@ -797,7 +1279,7 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
797
1279
  }
798
1280
 
799
1281
  /** Paint one edge as a polyline. Its label is placed once every edge is drawn; see the pass after the loop. */
800
- const paintPath = (edge: DiagramEdge, points: Point[], colour: ColourRole): void => {
1282
+ const paintPath = (edgeIndex: number, edge: DiagramEdge, points: Point[], colour: ColourRole): void => {
801
1283
  const shape: PathShape = {
802
1284
  kind: 'path',
803
1285
  points,
@@ -809,13 +1291,13 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
809
1291
  ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
810
1292
  }
811
1293
  shapes.push(shape)
812
- routes.push(shape)
1294
+ routes.push({ shape, edge: edgeIndex, fixed: false })
813
1295
  }
814
1296
 
815
1297
  /** Two points are a line and more of them are a polyline; both are an edge carrying a label. */
816
- const paintRoute = (edge: DiagramEdge, points: Point[], colour: ColourRole): void => {
1298
+ const paintRoute = (edgeIndex: number, edge: DiagramEdge, points: Point[], colour: ColourRole): void => {
817
1299
  if (points.length > 2) {
818
- paintPath(edge, points, colour)
1300
+ paintPath(edgeIndex, edge, points, colour)
819
1301
  return
820
1302
  }
821
1303
  const exit = points[0]
@@ -834,9 +1316,18 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
834
1316
  ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
835
1317
  }
836
1318
  shapes.push(shape)
837
- routes.push(shape)
1319
+ routes.push({ shape, edge: edgeIndex, fixed: false })
838
1320
  }
839
1321
 
1322
+ /**
1323
+ * Is this route clear of every box it does not terminate at?
1324
+ *
1325
+ * One test, used by every stage. A run, a lane and a trunk branch are proposals made from the layout; when the
1326
+ * proposal meets a box - which happens when the layout reserved a run between two boundaries while the painter
1327
+ * draws the boxes inside them - the proposal is refused and the router gets the edge.
1328
+ */
1329
+ const clearOfBoxes = (points: Point[], from: Box, to: Box): boolean => boxesCrossedBy(points, from, to, boxList).length === 0
1330
+
840
1331
  /**
841
1332
  * The polyline through a run: out of one port, across the strip between two rows, and into the other, square to
842
1333
  * whatever side each port belongs to.
@@ -849,10 +1340,16 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
849
1340
  const runRoute = (ports: Ports, runY: number): Point[] => {
850
1341
  const out = isVerticalSide(ports.exitSide) ? 0 : normalOf(ports.exitSide).x * ROUTE_STUB
851
1342
  const into = isVerticalSide(ports.entrySide) ? 0 : normalOf(ports.entrySide).x * ROUTE_STUB
1343
+ // A sideways port and a vertical one meet where the other port already is, if that is the way the stub points: the
1344
+ // stub is the straight run out of the port, and it is worth more than a stub to avoid the point-wide jog that
1345
+ // meeting at the nominal length leaves behind - a jog the edge would be charged a bend for and a reader for
1346
+ // nothing. Only when the other port is on the far side does the stub have to overshoot.
1347
+ const exitRun = out !== 0 && into === 0 && Math.sign(ports.entry.x - ports.exit.x) === Math.sign(out) ? ports.entry.x : ports.exit.x + out
1348
+ const entryRun = into !== 0 && out === 0 && Math.sign(ports.exit.x - ports.entry.x) === Math.sign(into) ? ports.exit.x : ports.entry.x + into
852
1349
  const points: Point[] = [ports.exit]
853
- if (out !== 0) points.push({ x: ports.exit.x + out, y: ports.exit.y })
854
- points.push({ x: ports.exit.x + out, y: runY }, { x: ports.entry.x + into, y: runY })
855
- if (into !== 0) points.push({ x: ports.entry.x + into, y: ports.entry.y })
1350
+ if (out !== 0) points.push({ x: exitRun, y: ports.exit.y })
1351
+ points.push({ x: exitRun, y: runY }, { x: entryRun, y: runY })
1352
+ if (into !== 0) points.push({ x: entryRun, y: ports.entry.y })
856
1353
  points.push(ports.entry)
857
1354
  return simplify(points)
858
1355
  }
@@ -875,19 +1372,8 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
875
1372
  return true
876
1373
  }
877
1374
 
878
- /** Does the route pass through a box it does not attach to? A run is an empty strip; a step out of a side may not be. */
879
- const clearsBoxes = (points: Point[], from: Box, to: Box): boolean => {
880
- for (let at = 0; at + 1 < points.length; at += 1) {
881
- const a = points[at]
882
- const b = points[at + 1]
883
- for (const box of boxList) {
884
- if (box === from || box === to) continue
885
- if (crossesBox(a.x, a.y, b.x, b.y, box)) return false
886
- }
887
- }
888
- return true
889
- }
890
-
1375
+ /** Where a branch meets its trunk, one per merged edge. They are drawn after every line, so no line covers one. */
1376
+ const junctions: JunctionShape[] = []
891
1377
  for (const [edgeIndex, edge] of diagram.edges.entries()) {
892
1378
  const source = endBox(edge.from)
893
1379
  const target = endBox(edge.to)
@@ -897,7 +1383,7 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
897
1383
  if (edge.from === edge.to) {
898
1384
  const x = source.x + source.width
899
1385
  const y = source.y + source.height / 2
900
- shapes.push({
1386
+ const shape: PathShape = {
901
1387
  kind: 'path',
902
1388
  points: [
903
1389
  { x, y: y - 9 },
@@ -913,12 +1399,38 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
913
1399
  colour,
914
1400
  ...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
915
1401
  ...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
916
- })
1402
+ }
1403
+ shapes.push(shape)
1404
+ routes.push({ shape, edge: edgeIndex, fixed: true })
917
1405
  continue
918
1406
  }
919
1407
 
920
1408
  const anchors = anchorsFor.get(edgeIndex)
921
1409
 
1410
+ // A branch of an accepted bundle: out of its own port, square onto the trunk, along the trunk to the bundle's one
1411
+ // port, and in. The last leg is shared with every other branch of the group, so the target is entered once; the
1412
+ // branch still draws its own head and its own label. The junction is recorded where the branch meets the trunk,
1413
+ // because a reader who is not shown the join reads the two lines as one line that was broken. A branch that would
1414
+ // cross a box is refused here as well: the planner checked it, but the painter is the last stage that knows every
1415
+ // box the drawing actually carries.
1416
+ const groupId = trunks.groupOf.get(edgeIndex)
1417
+ if (groupId !== undefined && anchors !== undefined) {
1418
+ const group = trunks.groups[groupId] as TrunkGroup
1419
+ const entry = sharedEntry.get(groupId) as Point
1420
+ const branch: Point[] = isVerticalSide(group.side)
1421
+ ? [anchors.exit, { x: anchors.exit.x, y: group.at }, { x: entry.x, y: group.at }, entry]
1422
+ : [anchors.exit, { x: group.at, y: anchors.exit.y }, { x: group.at, y: entry.y }, entry]
1423
+ const points = simplify(branch)
1424
+ if (clearOfBoxes(points, source, target)) {
1425
+ paintRoute(edgeIndex, edge, points, colour)
1426
+ // The join itself, read off the two ports rather than off the emitted points: a branch that drops straight
1427
+ // into the shared port is drawn as one segment, and its junction is still where the trunk crosses it.
1428
+ const meet = isVerticalSide(group.side) ? { x: anchors.exit.x, y: group.at } : { x: group.at, y: anchors.exit.y }
1429
+ junctions.push({ kind: 'junction', x: meet.x, y: meet.y, colour, trunk: group.id })
1430
+ continue
1431
+ }
1432
+ }
1433
+
922
1434
  // A long edge follows the lane the channel stage gave it: down the exit stub, across to the lane's x on the run
923
1435
  // the gap was assigned, down the lane, and across to the entry stub. The spine carries one point per gap, so the
924
1436
  // two runs are the places where a port and the lane disagree, and everything between them is one straight drop.
@@ -936,41 +1448,93 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
936
1448
  }
937
1449
  if (Math.abs(previous.x - anchors.entry.x) > 0.5 && Math.abs(previous.y - anchors.entry.y) > 0.5) points.push({ x: anchors.entry.x, y: previous.y })
938
1450
  points.push(anchors.entry)
939
- paintPath(edge, simplify(points), colour)
940
- continue
1451
+ const simplified = simplify(points)
1452
+ if (clearOfBoxes(simplified, source, target)) {
1453
+ paintPath(edgeIndex, edge, simplified, colour)
1454
+ continue
1455
+ }
941
1456
  }
942
1457
 
943
- // Two rows with nothing between them: the edge is the two-bend run the channel stage stacked. A run sits in the
944
- // empty strip between its own two rows and inside its own component's column, so it cannot meet a box - unless a
945
- // port on a side steps out of it, and then the step is checked and a route that would meet a box is the router's.
1458
+ // Two rows with nothing between them: the edge is the two-bend run the channel stage stacked. The run's height is
1459
+ // an empty strip only when the two rows it was measured between are the boxes actually drawn: a run reserved
1460
+ // between two boundaries is a strip outside them, while the painter's ports belong to boxes inside them. So the
1461
+ // proposal is checked, not trusted, and an edge whose legs would cross a box is the router's.
946
1462
  const runY = placement.runs.get(edgeIndex)
947
1463
  if (runY !== undefined && anchors !== undefined && runFits(anchors, runY)) {
948
1464
  const points = runRoute(anchors, runY)
949
- const mixed = isVerticalSide(anchors.exitSide) !== isVerticalSide(anchors.entrySide)
950
- if (!mixed || clearsBoxes(points, source, target)) {
951
- paintRoute(edge, points, colour)
1465
+ if (clearOfBoxes(points, source, target)) {
1466
+ paintRoute(edgeIndex, edge, points, colour)
952
1467
  continue
953
1468
  }
954
1469
  }
955
1470
 
956
- paintRoute(edge, routeEdge(source, target, boxList, anchors).points, colour)
1471
+ // The router is the last stage. Its own straight-line shortcut is already refused when a box is in the way; a
1472
+ // search that cannot finish still returns a line, and the final pass over the assembled drawing reports it.
1473
+ paintRoute(edgeIndex, edge, routeEdge(source, target, boxList, anchors).points, colour)
1474
+ }
1475
+
1476
+ // The dots last of the lines: a dot marks a join, so no line of the drawing may be drawn over it.
1477
+ for (const dot of junctions) shapes.push(dot)
1478
+
1479
+ // The assembled drawing, checked once: every segment of every drawn edge against every box it does not terminate
1480
+ // at. Each candidate route was checked before it was emitted, so this pass is the assertion that no stage slipped a
1481
+ // segment through a box - and the place a router that found no way round is reported rather than hidden.
1482
+ const boxEntries = Array.from(boxes.entries())
1483
+ const routingFailures: RoutingFailure[] = []
1484
+ for (const { shape, edge } of routes) {
1485
+ const diagramEdge = diagram.edges[edge] as DiagramEdge
1486
+ const source = endBox(diagramEdge.from)
1487
+ const target = endBox(diagramEdge.to)
1488
+ if (source === undefined || target === undefined) continue
1489
+ for (const segment of segmentsOfRoute(shape)) {
1490
+ for (const [id, box] of boxEntries) {
1491
+ if (box === source || box === target) continue
1492
+ if (!crossesBox(segment.x1, segment.y1, segment.x2, segment.y2, box)) continue
1493
+ routingFailures.push({
1494
+ edge,
1495
+ from: diagramEdge.from,
1496
+ to: diagramEdge.to,
1497
+ box: id,
1498
+ x1: segment.x1,
1499
+ y1: segment.y1,
1500
+ x2: segment.x2,
1501
+ y2: segment.y2,
1502
+ })
1503
+ }
1504
+ }
957
1505
  }
958
1506
 
959
1507
  // Labels last. A label has to clear every other edge, and until the last edge is drawn the painter does not know
960
1508
  // where those are: this is why the whole pass exists rather than a decision made edge by edge.
961
- const routeSegments = routes.map((shape) => segmentsOfRoute(shape))
1509
+ const routeSegments = routes.map(({ shape }) => segmentsOfRoute(shape))
962
1510
  const placedLabels: LabelSpot[] = []
963
- routes.forEach((shape, index) => {
964
- const run = longestRun(pointsOfRoute(shape))
965
- // The middle of the longest run is where the label belongs; a label with nothing to avoid stays exactly there.
966
- let spot: LabelSpot = { x: (run.from.x + run.to.x) / 2, y: (run.from.y + run.to.y) / 2, width: textWidth(shape.label), height: LABEL_HEIGHT }
967
- if (shape.label !== '') {
968
- spot = placeLabel(shape.label, run.from, run.to, boxList, placedLabels, routeSegments.flatMap((list, at) => (at === index ? [] : list)))
969
- placedLabels.push(spot)
970
- }
971
- shape.labelX = spot.x
972
- shape.labelY = spot.y
1511
+ routes.forEach(({ shape, fixed }, at) => {
1512
+ if (fixed) {
1513
+ if (shape.label !== '') {
1514
+ placedLabels.push({ x: shape.labelX, y: shape.labelY, width: textWidth(shape.label), height: LABEL_HEIGHT })
1515
+ }
1516
+ return
1517
+ }
1518
+ if (shape.label === '') return
1519
+ const placement = placeLabel(
1520
+ shape.label,
1521
+ pointsOfRoute(shape),
1522
+ boxList,
1523
+ placedLabels,
1524
+ routeSegments.filter((_, other) => other !== at),
1525
+ )
1526
+ shape.labelX = placement.x
1527
+ shape.labelY = placement.y
1528
+ shape.labelPlacement = {
1529
+ run: placement.run,
1530
+ collisions: placement.collisions,
1531
+ boxes: placement.boxes,
1532
+ labels: placement.labels,
1533
+ edges: placement.edges,
1534
+ }
1535
+ placedLabels.push({ x: placement.x, y: placement.y, width: placement.width, height: placement.height })
973
1536
  })
1537
+ const labelCollisions = routes.reduce((total, { shape }) => total + ((shape.labelPlacement?.collisions ?? 0) > 0 ? 1 : 0), 0)
974
1538
 
975
1539
  shapes.push({ kind: 'title', x: MARGIN, y: MARGIN + 12, text: diagram.title, colour: 'text' })
976
1540
 
@@ -989,6 +1553,8 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
989
1553
  ...Array.from(groupRects.values(), (rect) => rect.y + rect.height),
990
1554
  )
991
1555
  const captionTop = contentBottom + CAPTION_GAP
1556
+ // Anchored at the left margin, where the title is. The writer starts the text there: centred on a left-edge x,
1557
+ // half of a long caption fell outside the drawing and was clipped at the bottom edge of the picture.
992
1558
  shapes.push({ kind: 'caption', x: MARGIN, y: captionTop, lines: captionLines, colour: captionRole(diagram.caption) })
993
1559
  const height = captionTop + captionLines.length * CAPTION_LINE + MARGIN / 2
994
1560
 
@@ -998,7 +1564,10 @@ export function paintWith(diagram: Diagram, overrides: Partial<Palette> = {}, op
998
1564
  const canvasWidth = Math.max(width, extent.width + MARGIN)
999
1565
  const canvasHeight = Math.max(height, extent.height + MARGIN)
1000
1566
 
1001
- return { canvas: { width: Math.round(canvasWidth), height: Math.round(canvasHeight), shapes }, colours }
1567
+ return {
1568
+ canvas: { width: Math.round(canvasWidth), height: Math.round(canvasHeight), shapes, routingFailures, labelCollisions },
1569
+ colours,
1570
+ }
1002
1571
  }
1003
1572
 
1004
1573
  export function paint(diagram: Diagram, overrides: Partial<Palette> = {}): Canvas {