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.
- package/lib/annotation.js +323 -0
- package/lib/client.js +2138 -184
- package/lib/diagram-render-tool.js +13 -1
- package/lib/diagram-review-tool.js +1 -1
- package/lib/diagram.js +33 -0
- package/lib/gate.js +8 -6
- package/lib/render/paint.js +671 -207
- package/lib/render/svg.js +11 -2
- package/lib/render/trunk.js +213 -0
- package/lib/routing.js +12 -0
- package/lib/transcript.js +327 -0
- package/lib/types/annotation.d.ts +128 -0
- package/lib/types/client.d.ts +89 -6
- package/lib/types/diagram.d.ts +7 -0
- package/lib/types/gate.d.ts +8 -6
- package/lib/types/render/paint.d.ts +122 -6
- package/lib/types/render/place.d.ts +8 -3
- package/lib/types/render/trunk.d.ts +104 -0
- package/lib/types/transcript.d.ts +82 -0
- package/package.json +149 -148
- package/skills/model-everything/SKILL.md +2 -1
- package/skills/model-everything-diagram/SKILL.md +22 -1
- package/src/annotation.ts +333 -0
- package/src/client.ts +1841 -307
- package/src/diagram-render-tool.ts +13 -1
- package/src/diagram-review-tool.ts +1 -1
- package/src/diagram.ts +31 -0
- package/src/gate.ts +8 -6
- package/src/render/paint.ts +774 -205
- package/src/render/place.ts +8 -3
- package/src/render/svg.ts +12 -2
- package/src/render/trunk.ts +275 -0
- package/src/routing.ts +12 -0
- package/src/transcript.ts +349 -0
package/lib/render/paint.js
CHANGED
|
@@ -11,23 +11,34 @@
|
|
|
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
|
|
22
|
-
*
|
|
23
|
-
* something is in the way.
|
|
24
|
-
* whole
|
|
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
|
*/
|
|
28
38
|
import { MARGIN, place, textOf } from './place.js';
|
|
29
39
|
import { PORT_INSET } from './channels.js';
|
|
30
40
|
import { anchor, approachSides, crossesBox, normalOf, ROUTE_STUB, routeEdge, simplify } from './route.js';
|
|
41
|
+
import { planTrunks } from './trunk.js';
|
|
31
42
|
import { cylinderApex, cylinderCaps, hasDiscretePorts, hexagonCut, stadiumRadius } from './symbol.js';
|
|
32
43
|
import { textWidth } from './text.js';
|
|
33
44
|
import { palette, roleForVerdict } from './palette.js';
|
|
@@ -78,6 +89,23 @@ function packPorts(wanted, low, high) {
|
|
|
78
89
|
}
|
|
79
90
|
return placed;
|
|
80
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Centre a row of ports on the side it sits on.
|
|
94
|
+
*
|
|
95
|
+
* The row keeps its order, its gaps and its separation, and is translated as a whole so its outer two ports sit
|
|
96
|
+
* symmetrically about the middle of the span: two ports become the middle less and plus half their gap, and a longer
|
|
97
|
+
* row is the same run of places the routing asked for, moved to where the side has room for it. A row that cannot
|
|
98
|
+
* travel the whole way stops where its last port reaches the end of the span - the alternative is a port off the box.
|
|
99
|
+
*/
|
|
100
|
+
function centreRun(placed, low, high) {
|
|
101
|
+
if (placed.length === 0)
|
|
102
|
+
return placed;
|
|
103
|
+
const first = placed[0];
|
|
104
|
+
const last = placed[placed.length - 1];
|
|
105
|
+
const middle = (low + high) / 2;
|
|
106
|
+
const shift = Math.min(high - last, Math.max(low - first, middle - (first + last) / 2));
|
|
107
|
+
return shift === 0 ? placed : placed.map((value) => value + shift);
|
|
108
|
+
}
|
|
81
109
|
/** Which colour a box earns from its role. One mapping, used by every container. */
|
|
82
110
|
export function roleOfNode(node) {
|
|
83
111
|
if (node.role === 'error')
|
|
@@ -127,19 +155,42 @@ export const LABEL_HEIGHT = 12;
|
|
|
127
155
|
const LABEL_PAD = 2;
|
|
128
156
|
/** How far a label steps along its own run while it looks for a clear place to sit. */
|
|
129
157
|
const LABEL_STEP = 3;
|
|
130
|
-
/** The
|
|
158
|
+
/** The runs of one edge, longest first. A label prefers the run with the most room. */
|
|
159
|
+
function runsOf(points) {
|
|
160
|
+
const runs = [];
|
|
161
|
+
for (let index = 0; index + 1 < points.length; index += 1) {
|
|
162
|
+
const from = points[index];
|
|
163
|
+
const to = points[index + 1];
|
|
164
|
+
const length = Math.abs(to.x - from.x) + Math.abs(to.y - from.y);
|
|
165
|
+
if (length < 1)
|
|
166
|
+
continue;
|
|
167
|
+
runs.push({ from, to, length, index });
|
|
168
|
+
}
|
|
169
|
+
return runs.sort((left, right) => right.length - left.length || left.index - right.index);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The point an edge's label is drawn at.
|
|
131
173
|
*
|
|
132
|
-
* A label is not furniture parked beside the line: it names the transition, so it sits on the line - anchored
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* line reads as part of it.
|
|
174
|
+
* A label is not furniture parked beside the line: it names the transition, so it sits on the line - anchored on one
|
|
175
|
+
* of its own straight runs - and the writer masks the stretch of line it covers. Text beside a line can be read as
|
|
176
|
+
* belonging to whatever it happens to sit next to; text that interrupts its own line reads as part of it.
|
|
136
177
|
*
|
|
137
|
-
* The anchor then has to be earned. A mask over a box hides a border, over another label it makes both
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
178
|
+
* The anchor then has to be earned. A mask over a box hides a border, over another label it makes both unreadable,
|
|
179
|
+
* and over a third edge's line it hides that line. So the label walks along a run in small steps, in both directions
|
|
180
|
+
* and bounded so its rectangle stays on that run, and takes the first place that is clear. It tries the longest run
|
|
181
|
+
* first and then the shorter ones: on a dense drawing the longest run can be crowded for its whole length while a
|
|
182
|
+
* shorter run of the same edge is empty, and a label that gives up after one run leaves the drawing with a collision
|
|
183
|
+
* it did not have to accept.
|
|
184
|
+
*
|
|
185
|
+
* When no run offers a clear place, the label takes the place with the fewest collisions - boxes and other labels
|
|
186
|
+
* first, because text over a border hides the border and text over text is unreadable, then other edges, which a mask
|
|
187
|
+
* merely interrupts. It never leaves its own edge: a label moved onto a neighbouring edge is a label that names the
|
|
188
|
+
* wrong thing. The choice is returned with the label, so a crowded drawing can be told from a placement bug.
|
|
189
|
+
*
|
|
190
|
+
* Exported because the choice is pure and worth asserting without a layout: a test can hand it one polyline, one box
|
|
191
|
+
* and one other route, and read which run it took and what it could not clear.
|
|
141
192
|
*/
|
|
142
|
-
function placeLabel(label,
|
|
193
|
+
export function placeLabel(label, points, boxes, placed, otherRoutes) {
|
|
143
194
|
const width = textWidth(label);
|
|
144
195
|
const height = LABEL_HEIGHT;
|
|
145
196
|
const spotAt = (x, y) => ({ x, y, width, height });
|
|
@@ -157,51 +208,63 @@ function placeLabel(label, from, to, boxes, placed, segments) {
|
|
|
157
208
|
spot.x - spot.width / 2 < Math.max(segment.x1, segment.x2) + LABEL_PAD &&
|
|
158
209
|
spot.y + spot.height / 2 > Math.min(segment.y1, segment.y2) - LABEL_PAD &&
|
|
159
210
|
spot.y - spot.height / 2 < Math.max(segment.y1, segment.y2) + LABEL_PAD;
|
|
160
|
-
|
|
211
|
+
/** Everything one candidate place overlaps, counted by the thing it covers rather than by the segment that draws it. */
|
|
212
|
+
const collisionsAt = (spot, run) => {
|
|
213
|
+
let boxHits = 0;
|
|
214
|
+
let labelHits = 0;
|
|
215
|
+
let edgeHits = 0;
|
|
161
216
|
for (const box of boxes)
|
|
162
217
|
if (hitsBox(spot, box))
|
|
163
|
-
|
|
218
|
+
boxHits += 1;
|
|
164
219
|
for (const earlier of placed)
|
|
165
220
|
if (hitsSpot(spot, earlier))
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
221
|
+
labelHits += 1;
|
|
222
|
+
for (const route of otherRoutes) {
|
|
223
|
+
for (const segment of route) {
|
|
224
|
+
if (!hitsSegment(spot, segment))
|
|
225
|
+
continue;
|
|
226
|
+
edgeHits += 1;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { run, collisions: boxHits + labelHits + edgeHits, boxes: boxHits, labels: labelHits, edges: edgeHits };
|
|
174
231
|
};
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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;
|
|
232
|
+
const runs = runsOf(points);
|
|
233
|
+
if (runs.length === 0) {
|
|
234
|
+
// A route with no straight piece has no line to sit on: keep the label at the route's own point rather than move
|
|
235
|
+
// it into the air. Only a degenerate route reaches this, and the writer still draws its mask.
|
|
236
|
+
const point = points[0] ?? { x: 0, y: 0 };
|
|
237
|
+
return { ...spotAt(point.x, point.y), run: -1, collisions: 0, boxes: 0, labels: 0, edges: 0 };
|
|
194
238
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
239
|
+
let best;
|
|
240
|
+
for (const [runIndex, run] of runs.entries()) {
|
|
241
|
+
const dx = run.to.x - run.from.x;
|
|
242
|
+
const dy = run.to.y - run.from.y;
|
|
243
|
+
const ux = dx / run.length;
|
|
244
|
+
const uy = dy / run.length;
|
|
245
|
+
const middle = { x: (run.from.x + run.to.x) / 2, y: (run.from.y + run.to.y) / 2 };
|
|
246
|
+
// The walk is bounded so the label's own rectangle stays on the run that names it: a mask that hangs off the
|
|
247
|
+
// end of its segment would be half over the next thing and would not read as sitting on this edge.
|
|
248
|
+
const along = (Math.abs(ux) * width) / 2 + (Math.abs(uy) * height) / 2;
|
|
249
|
+
const bound = Math.max(0, run.length / 2 - along);
|
|
250
|
+
const walk = [0];
|
|
251
|
+
for (let step = LABEL_STEP; step <= bound + 0.001; step += LABEL_STEP)
|
|
252
|
+
walk.push(step, -step);
|
|
253
|
+
for (const offset of walk) {
|
|
254
|
+
const spot = spotAt(middle.x + ux * offset, middle.y + uy * offset);
|
|
255
|
+
const placement = collisionsAt(spot, runIndex);
|
|
256
|
+
// A clear place is the answer, and the first one is the preferred one: the search order is already the
|
|
257
|
+
// drawing's preference - longest run, own midpoint, then either side.
|
|
258
|
+
if (placement.collisions === 0)
|
|
259
|
+
return { ...spot, ...placement };
|
|
260
|
+
if (best === undefined ||
|
|
261
|
+
placement.collisions < best.collisions ||
|
|
262
|
+
(placement.collisions === best.collisions && placement.boxes + placement.labels < best.boxes + best.labels)) {
|
|
263
|
+
best = { ...spot, ...placement };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
203
266
|
}
|
|
204
|
-
return
|
|
267
|
+
return best;
|
|
205
268
|
}
|
|
206
269
|
/** The furthest right and bottom point any shape reaches. */
|
|
207
270
|
function drawnExtent(shapes) {
|
|
@@ -235,10 +298,35 @@ function drawnExtent(shapes) {
|
|
|
235
298
|
case 'title':
|
|
236
299
|
consider(textWidth(shape.lines?.[0] ?? '') + shape.x, shape.y + 12);
|
|
237
300
|
break;
|
|
301
|
+
case 'junction':
|
|
302
|
+
// A dot sits on a line that already reaches further than it does: counting it would never widen the canvas.
|
|
303
|
+
break;
|
|
238
304
|
}
|
|
239
305
|
}
|
|
240
306
|
return { width, height };
|
|
241
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* The boxes a polyline passes through other than the two it terminates at.
|
|
310
|
+
*
|
|
311
|
+
* This is the drawing's one clearance rule, and it is deliberately written once: a channel run, a lane, a trunk
|
|
312
|
+
* branch, a wrapped column's sideways step and the router's fallback all have to obey the same test. A box is skipped
|
|
313
|
+
* 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)
|
|
314
|
+
* is allowed and a segment that ends inside somebody else's box is not.
|
|
315
|
+
*/
|
|
316
|
+
export function boxesCrossedBy(points, from, to, boxes) {
|
|
317
|
+
const crossed = [];
|
|
318
|
+
for (let index = 0; index + 1 < points.length; index += 1) {
|
|
319
|
+
const a = points[index];
|
|
320
|
+
const b = points[index + 1];
|
|
321
|
+
for (const box of boxes) {
|
|
322
|
+
if (box === from || box === to)
|
|
323
|
+
continue;
|
|
324
|
+
if (crossesBox(a.x, a.y, b.x, b.y, box))
|
|
325
|
+
crossed.push(box);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return crossed;
|
|
329
|
+
}
|
|
242
330
|
export function paintWith(diagram, overrides = {}, options = {}) {
|
|
243
331
|
const colours = palette(overrides, options);
|
|
244
332
|
const shapes = [];
|
|
@@ -278,6 +366,7 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
278
366
|
const text = textOf(node);
|
|
279
367
|
shapes.push({
|
|
280
368
|
kind: 'box',
|
|
369
|
+
id: node.id,
|
|
281
370
|
x: box.x,
|
|
282
371
|
y: box.y,
|
|
283
372
|
width: box.width,
|
|
@@ -335,45 +424,238 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
335
424
|
endsAt.set(edge.from, (endsAt.get(edge.from) ?? 0) + 1);
|
|
336
425
|
endsAt.set(edge.to, (endsAt.get(edge.to) ?? 0) + 1);
|
|
337
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* Where a port may sit on one side of one node, as the interval its coordinate may take.
|
|
429
|
+
*
|
|
430
|
+
* 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
|
|
431
|
+
* where the symbol has to be known. A rectangle spreads a port along the side, and a subroutine does the same. A
|
|
432
|
+
* diamond's side meets its outline at exactly one point - the vertex - so its interval is that point, and a circle's
|
|
433
|
+
* is its cardinal point; a point is all that side has to give, so two edges on one side share it rather than take
|
|
434
|
+
* two points in the air. A hexagon's top and bottom are the flat run between the cut corners, and its left and right
|
|
435
|
+
* are the vertices. A stadium's flat run stops at the end caps, and a cylinder's flat sides are the two vertical
|
|
436
|
+
* runs, never the caps.
|
|
437
|
+
*
|
|
438
|
+
* A zero-width interval is not a failure: the packer keeps every port on it, so a symbol that offers one point on a
|
|
439
|
+
* side hands that point to every edge that wants it. Which is why a symbol with four points is given one edge per
|
|
440
|
+
* side first - the point is only shared when the four are taken.
|
|
441
|
+
*/
|
|
442
|
+
const spanOf = (boxId, side, corner) => {
|
|
443
|
+
const box = endBox(boxId);
|
|
444
|
+
const vertical = side === 'top' || side === 'bottom';
|
|
445
|
+
const middle = vertical ? box.x + box.width / 2 : box.y + box.height / 2;
|
|
446
|
+
const within = (low, high) => ({ low, high: Math.max(low, high) });
|
|
447
|
+
switch (shapeOf(boxId)) {
|
|
448
|
+
// The four vertices of a diamond, and the four cardinal points of an ellipse, are the middles of its sides.
|
|
449
|
+
case 'diamond':
|
|
450
|
+
case 'circle':
|
|
451
|
+
return within(middle, middle);
|
|
452
|
+
case 'hexagon':
|
|
453
|
+
return vertical ? within(box.x + hexagonCut(box.width), box.x + box.width - hexagonCut(box.width)) : within(middle, middle);
|
|
454
|
+
case 'stadium': {
|
|
455
|
+
const radius = stadiumRadius(box);
|
|
456
|
+
return vertical ? within(box.x + radius, box.x + box.width - radius) : within(middle, middle);
|
|
457
|
+
}
|
|
458
|
+
case 'cylinder': {
|
|
459
|
+
if (vertical)
|
|
460
|
+
return within(middle, middle);
|
|
461
|
+
const cap = cylinderCaps(box).cap;
|
|
462
|
+
return within(box.y + cap, box.y + box.height - cap);
|
|
463
|
+
}
|
|
464
|
+
default: {
|
|
465
|
+
// A rectangle, and a subroutine's doubled side, keep the margins they have always had; `corner` relaxes them
|
|
466
|
+
// because a side that is crowded uses its corners.
|
|
467
|
+
const inset = corner ? 2 : vertical ? PORT_INSET : 8;
|
|
468
|
+
return vertical ? within(box.x + inset, box.x + box.width - inset) : within(box.y + inset, box.y + box.height - inset);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
const clampTo = (boxId, side, value, corner) => {
|
|
473
|
+
const span = spanOf(boxId, side, corner);
|
|
474
|
+
return Math.min(span.high, Math.max(span.low, value));
|
|
475
|
+
};
|
|
476
|
+
/**
|
|
477
|
+
* The boundary point a coordinate along one side takes.
|
|
478
|
+
*
|
|
479
|
+
* The box's rectangle is the point for every symbol whose side is the box's side. A cylinder's top and bottom are
|
|
480
|
+
* arcs, so a port there sits on the cap curve's apex - `cap / 4` inside the box, not on the box's own edge.
|
|
481
|
+
*/
|
|
482
|
+
const pointOn = (boxId, side, along) => {
|
|
483
|
+
const box = endBox(boxId);
|
|
484
|
+
if (side === 'left')
|
|
485
|
+
return { x: box.x, y: along };
|
|
486
|
+
if (side === 'right')
|
|
487
|
+
return { x: box.x + box.width, y: along };
|
|
488
|
+
if (side === 'top')
|
|
489
|
+
return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'top').y : box.y };
|
|
490
|
+
return { x: along, y: shapeOf(boxId) === 'cylinder' ? cylinderApex(box, 'bottom').y : box.y + box.height };
|
|
491
|
+
};
|
|
338
492
|
/** The side each edge end's port is on, where the port is a point rather than a place along a run. */
|
|
339
493
|
const claimed = new Map();
|
|
340
494
|
const claimedAt = new Map();
|
|
341
495
|
const claim = (id, index, end, side) => {
|
|
342
496
|
claimed.set(endKey(index, end), side);
|
|
343
|
-
claimedAt.set(id, [...(claimedAt.get(id) ?? []), side]);
|
|
497
|
+
claimedAt.set(id, [...(claimedAt.get(id) ?? []), { index, side }]);
|
|
498
|
+
};
|
|
499
|
+
/** The sides of one node another edge already holds. An edge is not crowded by its own other end. */
|
|
500
|
+
const takenSides = (id, except) => (claimedAt.get(id) ?? []).filter((entry) => entry.index !== except).map((entry) => entry.side);
|
|
501
|
+
/** The middle of one side: the coordinate that side's point sits at when nothing has moved it. */
|
|
502
|
+
const sideMiddle = (box, side) => (isVerticalSide(side) ? box.x + box.width / 2 : box.y + box.height / 2);
|
|
503
|
+
/** The point a side offers at its middle: a symbol's own point, or the middle of the run it spreads along. */
|
|
504
|
+
const sidePoint = (boxId, side) => pointOn(boxId, side, sideMiddle(endBox(boxId), side));
|
|
505
|
+
/** Does this side face away from the box at the other end? A route leaving it has to come back round its own box. */
|
|
506
|
+
const facesAway = (box, other, side) => {
|
|
507
|
+
const out = normalOf(side);
|
|
508
|
+
const dx = other.x + other.width / 2 - (box.x + box.width / 2);
|
|
509
|
+
const dy = other.y + other.height / 2 - (box.y + box.height / 2);
|
|
510
|
+
return out.x * dx + out.y * dy < 0;
|
|
511
|
+
};
|
|
512
|
+
/** The coordinate a free side takes when the other end is pinned: the pinned port's stub, read on the free axis. */
|
|
513
|
+
const stubAlong = (pinnedId, pinnedSide, freeSide) => {
|
|
514
|
+
const point = sidePoint(pinnedId, pinnedSide);
|
|
515
|
+
const out = normalOf(pinnedSide);
|
|
516
|
+
return isVerticalSide(freeSide) ? point.x + out.x * ROUTE_STUB : point.y + out.y * ROUTE_STUB;
|
|
517
|
+
};
|
|
518
|
+
/** Can this side put a port at `value`? A point-side offers exactly one coordinate; a run offers an interval. */
|
|
519
|
+
const offers = (boxId, side, value) => {
|
|
520
|
+
const span = spanOf(boxId, side, true);
|
|
521
|
+
return value >= span.low - 0.01 && value <= span.high + 0.01;
|
|
344
522
|
};
|
|
345
523
|
/**
|
|
346
|
-
* The
|
|
524
|
+
* The bend budget a candidate (exit side, entry side) pair spends on the orthogonal route it implies, before a lane
|
|
525
|
+
* or a router is consulted. The numbers, and what each one is:
|
|
526
|
+
*
|
|
527
|
+
* 0 the two stubs share a line: both on vertical sides whose x can be the same, or both on horizontal sides
|
|
528
|
+
* whose y can be the same, with each stub pointing the way the edge travels. One straight run, no corner.
|
|
529
|
+
* 1 an L: the two sides are perpendicular and the one that can still move can be placed exactly where the pinned
|
|
530
|
+
* side's stub ends, so the route turns once. It is the rung between the straight run and the Z: an L never
|
|
531
|
+
* beats a run that shares a line, and always beats a Z.
|
|
532
|
+
* 2 a Z: the stubs are perpendicular but cannot meet at one corner, or they are parallel and offset. Out, across,
|
|
533
|
+
* in: two corners.
|
|
534
|
+
* 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
|
|
535
|
+
* in. The canonical U has both sides facing away; one is already enough to force the wrap, and a pair that
|
|
536
|
+
* has to come back the way it went is never worth a pair that does not.
|
|
347
537
|
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
538
|
+
* "Can be the same" is the symbol's own answer (`spanOf`): a rectangle offers its whole side, a diamond one point, a
|
|
539
|
+
* cylinder's top the apex of its cap. Two rectangles facing each other therefore share a line wherever they sit; two
|
|
540
|
+
* diamonds only when their vertices line up. This is a budget, not the final polyline: the router still adds the
|
|
541
|
+
* length an obstacle demands, and the channel stage still decides which lanes exist.
|
|
542
|
+
*
|
|
543
|
+
* A side another edge already holds is charged for the L it costs: the packer has to move this pair's port at least
|
|
544
|
+
* a port separation along the side, which turns the one corner the pair counted on into a jog. A Z is not charged -
|
|
545
|
+
* it was already off the line, and a port that moves along its side does not add a corner to it.
|
|
353
546
|
*/
|
|
354
|
-
const
|
|
547
|
+
const bendBudget = (fromId, toId, exit, entry, index) => {
|
|
548
|
+
const from = endBox(fromId);
|
|
549
|
+
const to = endBox(toId);
|
|
550
|
+
if (facesAway(from, to, exit) || facesAway(to, from, entry))
|
|
551
|
+
return 4;
|
|
552
|
+
if (isVerticalSide(exit) === isVerticalSide(entry)) {
|
|
553
|
+
// Parallel: a straight run only when the two stubs travel the same way and the sides can meet on one line.
|
|
554
|
+
const out = normalOf(exit);
|
|
555
|
+
const into = normalOf(entry);
|
|
556
|
+
const sameWay = out.x === -into.x && out.y === -into.y;
|
|
557
|
+
const fromSpan = spanOf(fromId, exit, true);
|
|
558
|
+
const toSpan = spanOf(toId, entry, true);
|
|
559
|
+
const meet = fromSpan.low <= toSpan.high + 0.01 && toSpan.low <= fromSpan.high + 0.01;
|
|
560
|
+
return sameWay && meet ? 0 : 2;
|
|
561
|
+
}
|
|
562
|
+
// Perpendicular: one corner when the vertical side can be placed at the horizontal side's stub end.
|
|
563
|
+
const horizontalId = isVerticalSide(exit) ? toId : fromId;
|
|
564
|
+
const horizontalSide = isVerticalSide(exit) ? entry : exit;
|
|
565
|
+
const verticalId = isVerticalSide(exit) ? fromId : toId;
|
|
566
|
+
const verticalSide = isVerticalSide(exit) ? exit : entry;
|
|
567
|
+
if (!offers(verticalId, verticalSide, stubAlong(horizontalId, horizontalSide, verticalSide)))
|
|
568
|
+
return 2;
|
|
569
|
+
const crowded = takenSides(fromId, index).includes(exit) || takenSides(toId, index).includes(entry);
|
|
570
|
+
return crowded ? 2 : 1;
|
|
571
|
+
};
|
|
572
|
+
/**
|
|
573
|
+
* The side a discrete port takes: the point whose pair with the edge's other end bends least.
|
|
574
|
+
*
|
|
575
|
+
* `partner` is the side the other end holds - the side it was claimed, or, until it is claimed, the side the route
|
|
576
|
+
* approaches by. The bend budget comes first. Equal budgets fall back to the rule that was here before: the cosine
|
|
577
|
+
* of the outward normal against the direction to the other box, then the point nearest that box, then the ground
|
|
578
|
+
* between the two points - the small length term. So a case that already read well does not move, and a point that
|
|
579
|
+
* costs an extra corner loses even when it faces the target more squarely.
|
|
580
|
+
*/
|
|
581
|
+
const bestSide = (id, otherId, end, partner, index) => {
|
|
355
582
|
const box = endBox(id);
|
|
583
|
+
const other = endBox(otherId);
|
|
356
584
|
const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
357
585
|
const toward = { x: other.x + other.width / 2 - centre.x, y: other.y + other.height / 2 - centre.y };
|
|
358
586
|
const reach = Math.hypot(toward.x, toward.y);
|
|
359
|
-
const taken =
|
|
587
|
+
const taken = takenSides(id, index);
|
|
360
588
|
let best;
|
|
361
589
|
for (const side of PORT_SIDES) {
|
|
362
590
|
if (taken.includes(side))
|
|
363
591
|
continue;
|
|
592
|
+
const bends = end === 'exit' ? bendBudget(id, otherId, side, partner, index) : bendBudget(otherId, id, partner, side, index);
|
|
364
593
|
const normal = normalOf(side);
|
|
365
594
|
const score = reach === 0 ? 0 : (normal.x * toward.x + normal.y * toward.y) / reach;
|
|
366
595
|
// How far the point is from the other box's rectangle: the nearest port is the one the edge reaches first.
|
|
367
596
|
const point = anchor(box, side);
|
|
368
597
|
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)));
|
|
598
|
+
const partnerPoint = anchor(other, partner);
|
|
599
|
+
const ground = Math.abs(point.x - partnerPoint.x) + Math.abs(point.y - partnerPoint.y);
|
|
369
600
|
const better = best === undefined ||
|
|
370
|
-
|
|
371
|
-
(
|
|
601
|
+
bends < best.bends ||
|
|
602
|
+
(bends === best.bends &&
|
|
603
|
+
(score > best.score + 1e-9 ||
|
|
604
|
+
(Math.abs(score - best.score) <= 1e-9 &&
|
|
605
|
+
(near < best.near - 1e-9 || (Math.abs(near - best.near) <= 1e-9 && ground < best.ground - 1e-9)))));
|
|
372
606
|
if (better)
|
|
373
|
-
best = { side, score, near };
|
|
607
|
+
best = { side, bends, score, near, ground };
|
|
374
608
|
}
|
|
375
609
|
return best?.side;
|
|
376
610
|
};
|
|
611
|
+
/**
|
|
612
|
+
* The sides an edge leaves and enters by when no claimed point decides either end: the pair the budget likes best.
|
|
613
|
+
*
|
|
614
|
+
* Only the two same-kind pairs are offered. Both ends here spread their ports along a side, so the choice is which
|
|
615
|
+
* side of the box the edge travels on; a pair that turns the corner one side further is the case `bestEnd` decides,
|
|
616
|
+
* where a claimed point has already fixed the near end. A pair strictly cheaper than the one the route approaches by
|
|
617
|
+
* wins; an equal pair keeps the approach, which leaves a drawing that was already right exactly as it was.
|
|
618
|
+
*/
|
|
619
|
+
const bestPair = (fromId, toId, index) => {
|
|
620
|
+
const approach = approachSides(endBox(fromId), endBox(toId));
|
|
621
|
+
const pairs = [
|
|
622
|
+
{ exit: 'bottom', entry: 'top' },
|
|
623
|
+
{ exit: 'top', entry: 'bottom' },
|
|
624
|
+
{ exit: 'right', entry: 'left' },
|
|
625
|
+
{ exit: 'left', entry: 'right' },
|
|
626
|
+
];
|
|
627
|
+
let best = approach;
|
|
628
|
+
let bestBends = bendBudget(fromId, toId, approach.exit, approach.entry, index);
|
|
629
|
+
for (const pair of pairs) {
|
|
630
|
+
const bends = bendBudget(fromId, toId, pair.exit, pair.entry, index);
|
|
631
|
+
if (bends >= bestBends)
|
|
632
|
+
continue;
|
|
633
|
+
best = pair;
|
|
634
|
+
bestBends = bends;
|
|
635
|
+
}
|
|
636
|
+
return best;
|
|
637
|
+
};
|
|
638
|
+
/**
|
|
639
|
+
* The side one end takes when the other end has already been decided: the cheapest of the four.
|
|
640
|
+
*
|
|
641
|
+
* The side the route approaches by wins a tie, for the same reason as `bestPair` - a drawing that already read well
|
|
642
|
+
* does not move for a pair the budget cannot tell apart.
|
|
643
|
+
*/
|
|
644
|
+
const bestEnd = (id, otherId, end, partner, index) => {
|
|
645
|
+
const approach = end === 'exit'
|
|
646
|
+
? approachSides(endBox(id), endBox(otherId)).exit
|
|
647
|
+
: approachSides(endBox(otherId), endBox(id)).entry;
|
|
648
|
+
let best = approach;
|
|
649
|
+
let bestBends = end === 'exit' ? bendBudget(id, otherId, approach, partner, index) : bendBudget(otherId, id, partner, approach, index);
|
|
650
|
+
for (const side of PORT_SIDES) {
|
|
651
|
+
const bends = end === 'exit' ? bendBudget(id, otherId, side, partner, index) : bendBudget(otherId, id, partner, side, index);
|
|
652
|
+
if (bends >= bestBends)
|
|
653
|
+
continue;
|
|
654
|
+
best = side;
|
|
655
|
+
bestBends = bends;
|
|
656
|
+
}
|
|
657
|
+
return best;
|
|
658
|
+
};
|
|
377
659
|
// A side the drawing has already decided is claimed first, because a point there would point the route away from
|
|
378
660
|
// 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
661
|
// wrapped columns leaves and arrives on the sides the reading order turns at.
|
|
@@ -415,7 +697,11 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
415
697
|
continue;
|
|
416
698
|
if (!hasDiscretePorts(shapeOf(id)))
|
|
417
699
|
continue;
|
|
418
|
-
|
|
700
|
+
// The side the other end holds, or will hold if nothing claims it first.
|
|
701
|
+
const approached = approachSides(source, target);
|
|
702
|
+
const partner = claimed.get(endKey(index, end === 'exit' ? 'entry' : 'exit')) ??
|
|
703
|
+
(end === 'exit' ? approached.entry : approached.exit);
|
|
704
|
+
const side = bestSide(id, end === 'exit' ? edge.to : edge.from, end, partner, index);
|
|
419
705
|
if (side === undefined)
|
|
420
706
|
continue;
|
|
421
707
|
claim(id, index, end, side);
|
|
@@ -428,23 +714,29 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
428
714
|
return undefined;
|
|
429
715
|
// A wrapped drawing is read in columns: the edge from one column to the next leaves and arrives at the side,
|
|
430
716
|
// which is the direction that reading order travels. A column is always to the right of the one before it.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
: approachSides(source, target);
|
|
717
|
+
if (placement.across.has(index))
|
|
718
|
+
return { exit: 'right', entry: 'left' };
|
|
434
719
|
const lane = placement.lanes.get(index);
|
|
435
|
-
|
|
720
|
+
// A lane runs down the page, so its ends face the rows above and below wherever the box centres are.
|
|
721
|
+
const down = target.y > source.y;
|
|
722
|
+
if (lane !== undefined && lane.points.length > 0) {
|
|
436
723
|
return {
|
|
437
|
-
exit: claimed.get(endKey(index, 'exit')) ??
|
|
438
|
-
entry: claimed.get(endKey(index, 'entry')) ??
|
|
724
|
+
exit: claimed.get(endKey(index, 'exit')) ?? (down ? 'bottom' : 'top'),
|
|
725
|
+
entry: claimed.get(endKey(index, 'entry')) ?? (down ? 'top' : 'bottom'),
|
|
726
|
+
at: lane.points[0].x,
|
|
439
727
|
};
|
|
440
728
|
}
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
729
|
+
// No claimed point and no lane: an end that was claimed keeps its point, the other one takes the side the budget
|
|
730
|
+
// likes best against it; with neither claimed, the pair is chosen together.
|
|
731
|
+
const exit = claimed.get(endKey(index, 'exit'));
|
|
732
|
+
const entry = claimed.get(endKey(index, 'entry'));
|
|
733
|
+
if (exit !== undefined && entry !== undefined)
|
|
734
|
+
return { exit, entry };
|
|
735
|
+
if (exit !== undefined)
|
|
736
|
+
return { exit, entry: bestEnd(edge.to, edge.from, 'entry', exit, index) };
|
|
737
|
+
if (entry !== undefined)
|
|
738
|
+
return { exit: bestEnd(edge.from, edge.to, 'exit', entry, index), entry };
|
|
739
|
+
return bestPair(edge.from, edge.to, index);
|
|
448
740
|
});
|
|
449
741
|
// 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
742
|
// ends. It tries the place that keeps the set evenly spaced first, then its own place moved by the same offset; a
|
|
@@ -493,71 +785,6 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
493
785
|
}
|
|
494
786
|
const wanted = new Map();
|
|
495
787
|
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
788
|
for (const [index, entry] of facing.entries()) {
|
|
562
789
|
if (entry === undefined)
|
|
563
790
|
continue;
|
|
@@ -592,16 +819,16 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
592
819
|
entryWanted = clampTo(edge.to, entry.entry, chosen, corner);
|
|
593
820
|
}
|
|
594
821
|
else {
|
|
595
|
-
// The two ports sit on sides of different kinds, which
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
// instead of jogging sideways to reach a middle
|
|
822
|
+
// The two ports sit on sides of different kinds, which a discrete point causes. The end that is fixed on its
|
|
823
|
+
// symbol's point keeps that point's coordinate; the other one sits where the route actually arrives - one step
|
|
824
|
+
// out of the fixed port along that port's own normal, read on the free side's axis - so the two legs of the
|
|
825
|
+
// route meet in one corner instead of jogging sideways to reach a middle the route never passes.
|
|
599
826
|
exitWanted = hasDiscretePorts(shapeOf(edge.from))
|
|
600
827
|
? fromMiddle
|
|
601
|
-
: clampTo(edge.from, entry.exit,
|
|
828
|
+
: clampTo(edge.from, entry.exit, stubAlong(edge.to, entry.entry, entry.exit), false) + offset;
|
|
602
829
|
entryWanted = hasDiscretePorts(shapeOf(edge.to))
|
|
603
830
|
? toMiddle
|
|
604
|
-
: clampTo(edge.to, entry.entry,
|
|
831
|
+
: clampTo(edge.to, entry.entry, stubAlong(edge.from, entry.exit, entry.entry), false) + offset;
|
|
605
832
|
}
|
|
606
833
|
wanted.set(slotKey(edge.from, entry.exit, index), exitWanted);
|
|
607
834
|
wanted.set(slotKey(edge.to, entry.entry, index), entryWanted);
|
|
@@ -656,6 +883,167 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
656
883
|
if (!moved)
|
|
657
884
|
break;
|
|
658
885
|
}
|
|
886
|
+
/**
|
|
887
|
+
* A batch of arrows into one box from one side is one bundle, not a comb: which ends may merge is the planner's
|
|
888
|
+
* answer (`trunk.ts` carries the rules), and this module supplies what only it knows - which edges have a run of
|
|
889
|
+
* their own, and where each port landed.
|
|
890
|
+
*
|
|
891
|
+
* The plan is made twice. The first pass, from the ports the packing gave, says which ends merge; the centring rule
|
|
892
|
+
* below reads it, because a merged branch is drawn into the bundle's one port and that - not its own entry port -
|
|
893
|
+
* is the coordinate its exit either shares a line with or does not. The second pass is made from the centred ports
|
|
894
|
+
* and is the plan the drawing uses: the run it lays out is the run of the ports that are actually drawn.
|
|
895
|
+
*/
|
|
896
|
+
const trunkEnds = [];
|
|
897
|
+
diagram.edges.forEach((edge, index) => {
|
|
898
|
+
const entry = facing[index];
|
|
899
|
+
if (entry === undefined)
|
|
900
|
+
return;
|
|
901
|
+
const runY = placement.runs.get(index);
|
|
902
|
+
trunkEnds.push({
|
|
903
|
+
index,
|
|
904
|
+
from: edge.from,
|
|
905
|
+
to: edge.to,
|
|
906
|
+
exit: entry.exit,
|
|
907
|
+
entry: entry.entry,
|
|
908
|
+
...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
|
|
909
|
+
...(runY === undefined ? {} : { runY }),
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
const planFor = (exitPortOf) => planTrunks({
|
|
913
|
+
ends: trunkEnds,
|
|
914
|
+
// Only a node's own box is a trunk target: an arrow into a boundary points past it, not at it.
|
|
915
|
+
boxOf: (id) => boxes.get(id),
|
|
916
|
+
boxes: boxList,
|
|
917
|
+
exitPortOf,
|
|
918
|
+
});
|
|
919
|
+
/** Where an edge's exit port sits before the centring rule has had its say. */
|
|
920
|
+
const packedExit = (index) => {
|
|
921
|
+
const entry = facing[index];
|
|
922
|
+
if (entry === undefined)
|
|
923
|
+
return undefined;
|
|
924
|
+
const edge = diagram.edges[index];
|
|
925
|
+
const coordinate = placedPort.get(slotKey(edge.from, entry.exit, index));
|
|
926
|
+
return coordinate === undefined ? undefined : pointOn(edge.from, entry.exit, coordinate);
|
|
927
|
+
};
|
|
928
|
+
const planned = planFor(packedExit);
|
|
929
|
+
/**
|
|
930
|
+
* 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.
|
|
931
|
+
* A merged branch has a lane the painter never follows - the branch is drawn from its exit to the trunk - so it is
|
|
932
|
+
* not pinned by it.
|
|
933
|
+
*/
|
|
934
|
+
const onLane = (index, coordinate) => {
|
|
935
|
+
if (planned.groupOf.has(index))
|
|
936
|
+
return false;
|
|
937
|
+
const lane = placement.lanes.get(index);
|
|
938
|
+
if (lane === undefined || lane.points.length === 0)
|
|
939
|
+
return false;
|
|
940
|
+
return Math.abs(lane.points[0].x - coordinate) <= 0.5;
|
|
941
|
+
};
|
|
942
|
+
const rows = [];
|
|
943
|
+
const rowOf = new Map();
|
|
944
|
+
for (const [key, list] of sides) {
|
|
945
|
+
const [boxId, side] = key.split(':');
|
|
946
|
+
const span = spanOf(boxId, side, true);
|
|
947
|
+
const slots = [...list]
|
|
948
|
+
.map((entry) => ({ index: entry.index, at: placedPort.get(slotKey(boxId, side, entry.index)) }))
|
|
949
|
+
.sort((left, right) => left.at - right.at || left.index - right.index);
|
|
950
|
+
const centred = centreRun(slots.map((slot) => slot.at), span.low, span.high);
|
|
951
|
+
const first = slots[0];
|
|
952
|
+
const row = {
|
|
953
|
+
boxId,
|
|
954
|
+
side,
|
|
955
|
+
indexes: slots.map((slot) => slot.index),
|
|
956
|
+
at: slots.map((slot) => slot.at),
|
|
957
|
+
position: new Map(slots.map((slot, at) => [slot.index, at])),
|
|
958
|
+
shift: centred[0] - first.at,
|
|
959
|
+
allowed: true,
|
|
960
|
+
};
|
|
961
|
+
rows.push(row);
|
|
962
|
+
rowOf.set(key, row);
|
|
963
|
+
}
|
|
964
|
+
/** Where a port of a row will sit once the rows that may move have moved. */
|
|
965
|
+
const movedAt = (row, at) => row.at[at] + (row.allowed ? row.shift : 0);
|
|
966
|
+
/**
|
|
967
|
+
* The coordinate the port at the other end of this edge sits at, and the row that port belongs to.
|
|
968
|
+
*
|
|
969
|
+
* Three ends are not a drawn port of a row: the entry of a merged branch (the bundle has one port), the shared
|
|
970
|
+
* entry itself (the planner's `entryAlong`), and a partner on a side of the other kind (a discrete symbol's point,
|
|
971
|
+
* on a different axis). Each is a fixed coordinate or nothing, which is all the pin test needs.
|
|
972
|
+
*/
|
|
973
|
+
const partnerOf = (boxId, side, index) => {
|
|
974
|
+
const entry = facing[index];
|
|
975
|
+
if (entry === undefined)
|
|
976
|
+
return undefined;
|
|
977
|
+
const edge = diagram.edges[index];
|
|
978
|
+
const isExit = edge.from === boxId && entry.exit === side;
|
|
979
|
+
const groupId = planned.groupOf.get(index);
|
|
980
|
+
if (groupId !== undefined) {
|
|
981
|
+
// A merged branch ends at the bundle's one port, so its own entry port is not drawn and pins nothing. Its exit
|
|
982
|
+
// is drawn, and the line it may share is the one to that shared port.
|
|
983
|
+
if (!isExit)
|
|
984
|
+
return undefined;
|
|
985
|
+
const group = planned.groups[groupId];
|
|
986
|
+
if (isVerticalSide(side) !== isVerticalSide(group.side))
|
|
987
|
+
return undefined;
|
|
988
|
+
return { coordinate: group.entryAlong };
|
|
989
|
+
}
|
|
990
|
+
const partnerId = isExit ? edge.to : edge.from;
|
|
991
|
+
const partnerSide = isExit ? entry.entry : entry.exit;
|
|
992
|
+
if (isVerticalSide(side) !== isVerticalSide(partnerSide))
|
|
993
|
+
return undefined;
|
|
994
|
+
const coordinate = placedPort.get(slotKey(partnerId, partnerSide, index));
|
|
995
|
+
if (coordinate === undefined)
|
|
996
|
+
return undefined;
|
|
997
|
+
const row = rowOf.get(partnerId + ':' + partnerSide);
|
|
998
|
+
return { coordinate, ...(row === undefined ? {} : { row, at: row.position.get(index) }) };
|
|
999
|
+
};
|
|
1000
|
+
/**
|
|
1001
|
+
* A side that carries one port is centred on it; a side that carries several keeps its spread, translated as a
|
|
1002
|
+
* whole so its outer ports sit symmetrically about the middle.
|
|
1003
|
+
*
|
|
1004
|
+
* A port is put where the route wants it, and a side that carries one relation has no spread to fit: the only
|
|
1005
|
+
* reason its port is not in the middle is that the route asked for a coordinate near one end - the far end of the
|
|
1006
|
+
* span, the point a lane runs at. That request is real, and the bend budget decides whether it may be overruled.
|
|
1007
|
+
* 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
|
|
1008
|
+
* on a lane is the end of a channel: moving either adds two corners. Every other port moves to where its side has
|
|
1009
|
+
* room, which costs nothing.
|
|
1010
|
+
*
|
|
1011
|
+
* So the bend budget is a fixpoint over rows, not a test per port: a row may move unless that breaks a line it
|
|
1012
|
+
* shares with a port that is staying. Two ends of one straight line may both move when they move by the same
|
|
1013
|
+
* amount - which is what centring both sides of one pair does - so the rows are resolved together, and a row that
|
|
1014
|
+
* is refused takes the lines it shares with it.
|
|
1015
|
+
*/
|
|
1016
|
+
for (let pass = 0; pass < rows.length; pass += 1) {
|
|
1017
|
+
let refused = false;
|
|
1018
|
+
for (const row of rows) {
|
|
1019
|
+
if (!row.allowed || row.shift === 0)
|
|
1020
|
+
continue;
|
|
1021
|
+
const pinned = row.indexes.some((index, at) => {
|
|
1022
|
+
if (onLane(index, row.at[at]))
|
|
1023
|
+
return true;
|
|
1024
|
+
const partner = partnerOf(row.boxId, row.side, index);
|
|
1025
|
+
if (partner === undefined)
|
|
1026
|
+
return false;
|
|
1027
|
+
// Only a line that exists now can be broken: two ports that already differ are a Z, and moving one end of a Z
|
|
1028
|
+
// along its own side changes where its corners are, not how many.
|
|
1029
|
+
if (Math.abs(row.at[at] - partner.coordinate) > 0.5)
|
|
1030
|
+
return false;
|
|
1031
|
+
const partnerAt = partner.row === undefined ? partner.coordinate : movedAt(partner.row, partner.at);
|
|
1032
|
+
return Math.abs(movedAt(row, at) - partnerAt) > 0.5;
|
|
1033
|
+
});
|
|
1034
|
+
if (!pinned)
|
|
1035
|
+
continue;
|
|
1036
|
+
row.allowed = false;
|
|
1037
|
+
refused = true;
|
|
1038
|
+
}
|
|
1039
|
+
if (!refused)
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
for (const row of rows) {
|
|
1043
|
+
if (!row.allowed || row.shift === 0)
|
|
1044
|
+
continue;
|
|
1045
|
+
row.indexes.forEach((index, at) => placedPort.set(slotKey(row.boxId, row.side, index), movedAt(row, at)));
|
|
1046
|
+
}
|
|
659
1047
|
const portOf = (boxId, side, edgeIndex) => {
|
|
660
1048
|
const coordinate = placedPort.get(slotKey(boxId, side, edgeIndex));
|
|
661
1049
|
return pointOn(boxId, side, coordinate);
|
|
@@ -672,18 +1060,13 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
672
1060
|
entrySide: entry.entry,
|
|
673
1061
|
});
|
|
674
1062
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
if (length > best.length)
|
|
683
|
-
best = { from, to, length };
|
|
684
|
-
}
|
|
685
|
-
return best;
|
|
686
|
-
};
|
|
1063
|
+
const trunks = planFor((index) => anchorsFor.get(index)?.exit);
|
|
1064
|
+
// The one port each accepted bundle enters by: the middle of the target's side, which is the port the planner
|
|
1065
|
+
// centred on `entryAlong`. A merged entry is one port, so the rule for a side with one port applies to it, and the
|
|
1066
|
+
// target carries one relation's worth of ports however many arrows point at it.
|
|
1067
|
+
const sharedEntry = new Map();
|
|
1068
|
+
for (const group of trunks.groups)
|
|
1069
|
+
sharedEntry.set(group.id, pointOn(group.target, group.side, group.entryAlong));
|
|
687
1070
|
/** The points a drawn edge passes through, whichever of the two shapes carries it. */
|
|
688
1071
|
const pointsOfRoute = (shape) => shape.kind === 'path' ? shape.points : [{ x: shape.x1, y: shape.y1 }, { x: shape.x2, y: shape.y2 }];
|
|
689
1072
|
/** The straight pieces an edge draws, so a label can tell its own line from every other one. */
|
|
@@ -698,7 +1081,7 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
698
1081
|
return out;
|
|
699
1082
|
};
|
|
700
1083
|
/** 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) => {
|
|
1084
|
+
const paintPath = (edgeIndex, edge, points, colour) => {
|
|
702
1085
|
const shape = {
|
|
703
1086
|
kind: 'path',
|
|
704
1087
|
points,
|
|
@@ -710,12 +1093,12 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
710
1093
|
...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
|
|
711
1094
|
};
|
|
712
1095
|
shapes.push(shape);
|
|
713
|
-
routes.push(shape);
|
|
1096
|
+
routes.push({ shape, edge: edgeIndex, fixed: false });
|
|
714
1097
|
};
|
|
715
1098
|
/** 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) => {
|
|
1099
|
+
const paintRoute = (edgeIndex, edge, points, colour) => {
|
|
717
1100
|
if (points.length > 2) {
|
|
718
|
-
paintPath(edge, points, colour);
|
|
1101
|
+
paintPath(edgeIndex, edge, points, colour);
|
|
719
1102
|
return;
|
|
720
1103
|
}
|
|
721
1104
|
const exit = points[0];
|
|
@@ -734,8 +1117,16 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
734
1117
|
...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
|
|
735
1118
|
};
|
|
736
1119
|
shapes.push(shape);
|
|
737
|
-
routes.push(shape);
|
|
1120
|
+
routes.push({ shape, edge: edgeIndex, fixed: false });
|
|
738
1121
|
};
|
|
1122
|
+
/**
|
|
1123
|
+
* Is this route clear of every box it does not terminate at?
|
|
1124
|
+
*
|
|
1125
|
+
* One test, used by every stage. A run, a lane and a trunk branch are proposals made from the layout; when the
|
|
1126
|
+
* proposal meets a box - which happens when the layout reserved a run between two boundaries while the painter
|
|
1127
|
+
* draws the boxes inside them - the proposal is refused and the router gets the edge.
|
|
1128
|
+
*/
|
|
1129
|
+
const clearOfBoxes = (points, from, to) => boxesCrossedBy(points, from, to, boxList).length === 0;
|
|
739
1130
|
/**
|
|
740
1131
|
* The polyline through a run: out of one port, across the strip between two rows, and into the other, square to
|
|
741
1132
|
* whatever side each port belongs to.
|
|
@@ -748,12 +1139,18 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
748
1139
|
const runRoute = (ports, runY) => {
|
|
749
1140
|
const out = isVerticalSide(ports.exitSide) ? 0 : normalOf(ports.exitSide).x * ROUTE_STUB;
|
|
750
1141
|
const into = isVerticalSide(ports.entrySide) ? 0 : normalOf(ports.entrySide).x * ROUTE_STUB;
|
|
1142
|
+
// A sideways port and a vertical one meet where the other port already is, if that is the way the stub points: the
|
|
1143
|
+
// stub is the straight run out of the port, and it is worth more than a stub to avoid the point-wide jog that
|
|
1144
|
+
// meeting at the nominal length leaves behind - a jog the edge would be charged a bend for and a reader for
|
|
1145
|
+
// nothing. Only when the other port is on the far side does the stub have to overshoot.
|
|
1146
|
+
const exitRun = out !== 0 && into === 0 && Math.sign(ports.entry.x - ports.exit.x) === Math.sign(out) ? ports.entry.x : ports.exit.x + out;
|
|
1147
|
+
const entryRun = into !== 0 && out === 0 && Math.sign(ports.exit.x - ports.entry.x) === Math.sign(into) ? ports.exit.x : ports.entry.x + into;
|
|
751
1148
|
const points = [ports.exit];
|
|
752
1149
|
if (out !== 0)
|
|
753
|
-
points.push({ x:
|
|
754
|
-
points.push({ x:
|
|
1150
|
+
points.push({ x: exitRun, y: ports.exit.y });
|
|
1151
|
+
points.push({ x: exitRun, y: runY }, { x: entryRun, y: runY });
|
|
755
1152
|
if (into !== 0)
|
|
756
|
-
points.push({ x:
|
|
1153
|
+
points.push({ x: entryRun, y: ports.entry.y });
|
|
757
1154
|
points.push(ports.entry);
|
|
758
1155
|
return simplify(points);
|
|
759
1156
|
};
|
|
@@ -776,20 +1173,8 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
776
1173
|
}
|
|
777
1174
|
return true;
|
|
778
1175
|
};
|
|
779
|
-
/**
|
|
780
|
-
const
|
|
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
|
-
};
|
|
1176
|
+
/** Where a branch meets its trunk, one per merged edge. They are drawn after every line, so no line covers one. */
|
|
1177
|
+
const junctions = [];
|
|
793
1178
|
for (const [edgeIndex, edge] of diagram.edges.entries()) {
|
|
794
1179
|
const source = endBox(edge.from);
|
|
795
1180
|
const target = endBox(edge.to);
|
|
@@ -799,7 +1184,7 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
799
1184
|
if (edge.from === edge.to) {
|
|
800
1185
|
const x = source.x + source.width;
|
|
801
1186
|
const y = source.y + source.height / 2;
|
|
802
|
-
|
|
1187
|
+
const shape = {
|
|
803
1188
|
kind: 'path',
|
|
804
1189
|
points: [
|
|
805
1190
|
{ x, y: y - 9 },
|
|
@@ -815,10 +1200,35 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
815
1200
|
colour,
|
|
816
1201
|
...(edge.dashed === undefined ? {} : { dashed: edge.dashed }),
|
|
817
1202
|
...(edge.arrow === undefined ? {} : { arrow: edge.arrow }),
|
|
818
|
-
}
|
|
1203
|
+
};
|
|
1204
|
+
shapes.push(shape);
|
|
1205
|
+
routes.push({ shape, edge: edgeIndex, fixed: true });
|
|
819
1206
|
continue;
|
|
820
1207
|
}
|
|
821
1208
|
const anchors = anchorsFor.get(edgeIndex);
|
|
1209
|
+
// A branch of an accepted bundle: out of its own port, square onto the trunk, along the trunk to the bundle's one
|
|
1210
|
+
// port, and in. The last leg is shared with every other branch of the group, so the target is entered once; the
|
|
1211
|
+
// branch still draws its own head and its own label. The junction is recorded where the branch meets the trunk,
|
|
1212
|
+
// because a reader who is not shown the join reads the two lines as one line that was broken. A branch that would
|
|
1213
|
+
// cross a box is refused here as well: the planner checked it, but the painter is the last stage that knows every
|
|
1214
|
+
// box the drawing actually carries.
|
|
1215
|
+
const groupId = trunks.groupOf.get(edgeIndex);
|
|
1216
|
+
if (groupId !== undefined && anchors !== undefined) {
|
|
1217
|
+
const group = trunks.groups[groupId];
|
|
1218
|
+
const entry = sharedEntry.get(groupId);
|
|
1219
|
+
const branch = isVerticalSide(group.side)
|
|
1220
|
+
? [anchors.exit, { x: anchors.exit.x, y: group.at }, { x: entry.x, y: group.at }, entry]
|
|
1221
|
+
: [anchors.exit, { x: group.at, y: anchors.exit.y }, { x: group.at, y: entry.y }, entry];
|
|
1222
|
+
const points = simplify(branch);
|
|
1223
|
+
if (clearOfBoxes(points, source, target)) {
|
|
1224
|
+
paintRoute(edgeIndex, edge, points, colour);
|
|
1225
|
+
// The join itself, read off the two ports rather than off the emitted points: a branch that drops straight
|
|
1226
|
+
// into the shared port is drawn as one segment, and its junction is still where the trunk crosses it.
|
|
1227
|
+
const meet = isVerticalSide(group.side) ? { x: anchors.exit.x, y: group.at } : { x: group.at, y: anchors.exit.y };
|
|
1228
|
+
junctions.push({ kind: 'junction', x: meet.x, y: meet.y, colour, trunk: group.id });
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
822
1232
|
// 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
1233
|
// the gap was assigned, down the lane, and across to the entry stub. The spine carries one point per gap, so the
|
|
824
1234
|
// two runs are the places where a port and the lane disagree, and everything between them is one straight drop.
|
|
@@ -838,38 +1248,87 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
838
1248
|
if (Math.abs(previous.x - anchors.entry.x) > 0.5 && Math.abs(previous.y - anchors.entry.y) > 0.5)
|
|
839
1249
|
points.push({ x: anchors.entry.x, y: previous.y });
|
|
840
1250
|
points.push(anchors.entry);
|
|
841
|
-
|
|
842
|
-
|
|
1251
|
+
const simplified = simplify(points);
|
|
1252
|
+
if (clearOfBoxes(simplified, source, target)) {
|
|
1253
|
+
paintPath(edgeIndex, edge, simplified, colour);
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
843
1256
|
}
|
|
844
|
-
// Two rows with nothing between them: the edge is the two-bend run the channel stage stacked.
|
|
845
|
-
// empty strip
|
|
846
|
-
//
|
|
1257
|
+
// Two rows with nothing between them: the edge is the two-bend run the channel stage stacked. The run's height is
|
|
1258
|
+
// an empty strip only when the two rows it was measured between are the boxes actually drawn: a run reserved
|
|
1259
|
+
// between two boundaries is a strip outside them, while the painter's ports belong to boxes inside them. So the
|
|
1260
|
+
// proposal is checked, not trusted, and an edge whose legs would cross a box is the router's.
|
|
847
1261
|
const runY = placement.runs.get(edgeIndex);
|
|
848
1262
|
if (runY !== undefined && anchors !== undefined && runFits(anchors, runY)) {
|
|
849
1263
|
const points = runRoute(anchors, runY);
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
paintRoute(edge, points, colour);
|
|
1264
|
+
if (clearOfBoxes(points, source, target)) {
|
|
1265
|
+
paintRoute(edgeIndex, edge, points, colour);
|
|
853
1266
|
continue;
|
|
854
1267
|
}
|
|
855
1268
|
}
|
|
856
|
-
|
|
1269
|
+
// The router is the last stage. Its own straight-line shortcut is already refused when a box is in the way; a
|
|
1270
|
+
// search that cannot finish still returns a line, and the final pass over the assembled drawing reports it.
|
|
1271
|
+
paintRoute(edgeIndex, edge, routeEdge(source, target, boxList, anchors).points, colour);
|
|
1272
|
+
}
|
|
1273
|
+
// The dots last of the lines: a dot marks a join, so no line of the drawing may be drawn over it.
|
|
1274
|
+
for (const dot of junctions)
|
|
1275
|
+
shapes.push(dot);
|
|
1276
|
+
// The assembled drawing, checked once: every segment of every drawn edge against every box it does not terminate
|
|
1277
|
+
// at. Each candidate route was checked before it was emitted, so this pass is the assertion that no stage slipped a
|
|
1278
|
+
// segment through a box - and the place a router that found no way round is reported rather than hidden.
|
|
1279
|
+
const boxEntries = Array.from(boxes.entries());
|
|
1280
|
+
const routingFailures = [];
|
|
1281
|
+
for (const { shape, edge } of routes) {
|
|
1282
|
+
const diagramEdge = diagram.edges[edge];
|
|
1283
|
+
const source = endBox(diagramEdge.from);
|
|
1284
|
+
const target = endBox(diagramEdge.to);
|
|
1285
|
+
if (source === undefined || target === undefined)
|
|
1286
|
+
continue;
|
|
1287
|
+
for (const segment of segmentsOfRoute(shape)) {
|
|
1288
|
+
for (const [id, box] of boxEntries) {
|
|
1289
|
+
if (box === source || box === target)
|
|
1290
|
+
continue;
|
|
1291
|
+
if (!crossesBox(segment.x1, segment.y1, segment.x2, segment.y2, box))
|
|
1292
|
+
continue;
|
|
1293
|
+
routingFailures.push({
|
|
1294
|
+
edge,
|
|
1295
|
+
from: diagramEdge.from,
|
|
1296
|
+
to: diagramEdge.to,
|
|
1297
|
+
box: id,
|
|
1298
|
+
x1: segment.x1,
|
|
1299
|
+
y1: segment.y1,
|
|
1300
|
+
x2: segment.x2,
|
|
1301
|
+
y2: segment.y2,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
857
1305
|
}
|
|
858
1306
|
// Labels last. A label has to clear every other edge, and until the last edge is drawn the painter does not know
|
|
859
1307
|
// 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));
|
|
1308
|
+
const routeSegments = routes.map(({ shape }) => segmentsOfRoute(shape));
|
|
861
1309
|
const placedLabels = [];
|
|
862
|
-
routes.forEach((shape,
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
shape.
|
|
1310
|
+
routes.forEach(({ shape, fixed }, at) => {
|
|
1311
|
+
if (fixed) {
|
|
1312
|
+
if (shape.label !== '') {
|
|
1313
|
+
placedLabels.push({ x: shape.labelX, y: shape.labelY, width: textWidth(shape.label), height: LABEL_HEIGHT });
|
|
1314
|
+
}
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (shape.label === '')
|
|
1318
|
+
return;
|
|
1319
|
+
const placement = placeLabel(shape.label, pointsOfRoute(shape), boxList, placedLabels, routeSegments.filter((_, other) => other !== at));
|
|
1320
|
+
shape.labelX = placement.x;
|
|
1321
|
+
shape.labelY = placement.y;
|
|
1322
|
+
shape.labelPlacement = {
|
|
1323
|
+
run: placement.run,
|
|
1324
|
+
collisions: placement.collisions,
|
|
1325
|
+
boxes: placement.boxes,
|
|
1326
|
+
labels: placement.labels,
|
|
1327
|
+
edges: placement.edges,
|
|
1328
|
+
};
|
|
1329
|
+
placedLabels.push({ x: placement.x, y: placement.y, width: placement.width, height: placement.height });
|
|
872
1330
|
});
|
|
1331
|
+
const labelCollisions = routes.reduce((total, { shape }) => total + ((shape.labelPlacement?.collisions ?? 0) > 0 ? 1 : 0), 0);
|
|
873
1332
|
shapes.push({ kind: 'title', x: MARGIN, y: MARGIN + 12, text: diagram.title, colour: 'text' });
|
|
874
1333
|
const contentRight = Math.max(MARGIN, ...Array.from(boxes.values(), (box) => box.x + box.width), ...Array.from(groupRects.values(), (rect) => rect.x + rect.width));
|
|
875
1334
|
const captionLines = wrap(diagram.caption, CAPTION_CHARS);
|
|
@@ -877,6 +1336,8 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
877
1336
|
const width = Math.max(contentRight + MARGIN, captionWidth, 240);
|
|
878
1337
|
const contentBottom = Math.max(topOffset, ...Array.from(boxes.values(), (box) => box.y + box.height), ...Array.from(groupRects.values(), (rect) => rect.y + rect.height));
|
|
879
1338
|
const captionTop = contentBottom + CAPTION_GAP;
|
|
1339
|
+
// Anchored at the left margin, where the title is. The writer starts the text there: centred on a left-edge x,
|
|
1340
|
+
// half of a long caption fell outside the drawing and was clipped at the bottom edge of the picture.
|
|
880
1341
|
shapes.push({ kind: 'caption', x: MARGIN, y: captionTop, lines: captionLines, colour: captionRole(diagram.caption) });
|
|
881
1342
|
const height = captionTop + captionLines.length * CAPTION_LINE + MARGIN / 2;
|
|
882
1343
|
// Everything drawn counts, including the parts that leave the box grid: an edge routed beside the drawing and a
|
|
@@ -884,7 +1345,10 @@ export function paintWith(diagram, overrides = {}, options = {}) {
|
|
|
884
1345
|
const extent = drawnExtent(shapes);
|
|
885
1346
|
const canvasWidth = Math.max(width, extent.width + MARGIN);
|
|
886
1347
|
const canvasHeight = Math.max(height, extent.height + MARGIN);
|
|
887
|
-
return {
|
|
1348
|
+
return {
|
|
1349
|
+
canvas: { width: Math.round(canvasWidth), height: Math.round(canvasHeight), shapes, routingFailures, labelCollisions },
|
|
1350
|
+
colours,
|
|
1351
|
+
};
|
|
888
1352
|
}
|
|
889
1353
|
export function paint(diagram, overrides = {}) {
|
|
890
1354
|
return paintWith(diagram, overrides).canvas;
|