dsh-model-everything 0.5.0 → 0.5.1

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