@adecore/diagram 0.0.1 → 0.17.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/layout.ts ADDED
@@ -0,0 +1,778 @@
1
+ import type { DiagramDocument, DiagramEdge, DiagramNode, DiagramShape } from './protocol.ts';
2
+ import { centerOf, intersects, roundPoint, type Point, type Rect } from '@adecore/drawing';
3
+ import { placeAcross } from './across.ts';
4
+ import { DUMMY_MARGIN, NODE_MARGIN, bandCode, type Band, type Hop, type Unit } from './graph.ts';
5
+ import { orderLayers } from './order.ts';
6
+ import { routeChannel, type ChannelHop, type ChannelLabel } from './route.ts';
7
+ import { LABEL_LINE, LABEL_SIZE, SUB_LINE, SUB_SIZE, estimateTextWidth, widestLine, wrapText } from './text.ts';
8
+
9
+ export { LABEL_LINE, LABEL_SIZE, SUB_LINE, SUB_SIZE, estimateTextWidth, wrapText } from './text.ts';
10
+
11
+ export type { Point, Rect } from '@adecore/drawing';
12
+
13
+ export interface NodeBox extends Rect {
14
+ id: string;
15
+ /* How far down the longest path from a node without incoming edges this node sits. */
16
+ layer: number;
17
+ /* Placed where a person dragged it rather than where the layout would have. */
18
+ pinned: boolean;
19
+ /* The label and the line under it, wrapped the way the box was sized for. */
20
+ label: string[];
21
+ sub: string[];
22
+ }
23
+
24
+ export interface GroupBox extends Rect {
25
+ id: string;
26
+ /* Where the group's label is drawn, estimated like every other text. */
27
+ labelBox: Rect;
28
+ }
29
+
30
+ export interface EdgeLabelBox extends Rect {
31
+ lines: string[];
32
+ }
33
+
34
+ export interface EdgeRoute {
35
+ /* The edge's index in the file, since two edges may join the same two nodes. */
36
+ index: number;
37
+ from: string;
38
+ to: string;
39
+ /* Whole-number corners, first to last, with the head at the last one. */
40
+ points: Point[];
41
+ /* Where the label goes, or null for an edge without one. */
42
+ label: EdgeLabelBox | null;
43
+ }
44
+
45
+ export interface DiagramLayout {
46
+ /* In file order. */
47
+ nodes: NodeBox[];
48
+ /* In file order; a group that wraps nothing has no box and is left out. */
49
+ groups: GroupBox[];
50
+ /* In file order. */
51
+ edges: EdgeRoute[];
52
+ /* Everything drawn, groups, labels and edge corners included; all zero for an empty diagram. */
53
+ bounds: Rect;
54
+ }
55
+
56
+ const NODE_PADDING_X = 16;
57
+ const NODE_PADDING_Y = 13;
58
+ const NODE_MIN_WIDTH = 120;
59
+ export const NODE_MAX_WIDTH = 280;
60
+ const TEXT_MAX_WIDTH = NODE_MAX_WIDTH - NODE_PADDING_X * 2;
61
+ /* A diamond holds its text in the middle of its box, so its lines are shorter and the box is twice their size. */
62
+ const DIAMOND_TEXT_MAX_WIDTH = 160;
63
+ const DIAMOND_MIN = { w: 168, h: 72 };
64
+ /* The extra height of a cylinder, for the lid its text stays under. */
65
+ export const CYLINDER_LID = 10;
66
+ export const EDGE_LABEL_MAX_WIDTH = 160;
67
+ export const EDGE_LABEL_PADDING = { x: 4, y: 2 };
68
+ export const GROUP_PADDING = 20;
69
+ /* The band on top of a group where its label sits. */
70
+ export const GROUP_LABEL_BAND = 24;
71
+ export const GROUP_LABEL_INSET = 12;
72
+ /* Room between a layer's widest box and the first thing in the channel after it. */
73
+ const EDGE_CLEAR = 20;
74
+ /* How far a loop on one node reaches out of its box. */
75
+ const LOOP = 16;
76
+ /* Ports on one side of a box keep this far apart, and this far from its corners. */
77
+ const PORT_GAP = 12;
78
+ const PORT_INSET = 10;
79
+ /* A hop whose two ends are this close across the flow is drawn straight when one end has room to move. */
80
+ const SNAP = 6;
81
+
82
+ function even(value: number): number {
83
+ return Math.ceil(value / 2) * 2;
84
+ }
85
+
86
+ export interface NodeSize {
87
+ w: number;
88
+ h: number;
89
+ label: string[];
90
+ sub: string[];
91
+ }
92
+
93
+ /* The size of a node's box, from what it says wrapped to the widest a box gets, and the shape it wears. */
94
+ export function sizeOfNode(node: Pick<DiagramNode, 'label' | 'sub' | 'shape'>): NodeSize {
95
+ const diamond = node.shape === 'diamond';
96
+ const maxWidth = diamond ? DIAMOND_TEXT_MAX_WIDTH : TEXT_MAX_WIDTH;
97
+ const label = wrapText(node.label, LABEL_SIZE, true, maxWidth);
98
+ const sub = node.sub ? wrapText(node.sub, SUB_SIZE, false, maxWidth) : [];
99
+ const textWidth = Math.max(widestLine(label, LABEL_SIZE, true), widestLine(sub, SUB_SIZE, false));
100
+ const textHeight = label.length * LABEL_LINE + sub.length * SUB_LINE;
101
+ if (diamond) {
102
+ // A rhombus twice the size of a rectangle holds that rectangle with its corners on the outline.
103
+ return { w: even(Math.max(DIAMOND_MIN.w, (textWidth + 16) * 2)), h: even(Math.max(DIAMOND_MIN.h, (textHeight + 8) * 2)), label, sub };
104
+ }
105
+ let h = textHeight + NODE_PADDING_Y * 2;
106
+ // The round ends of a pill eat into the room of its outer lines.
107
+ const paddingX = node.shape === 'pill' ? Math.max(NODE_PADDING_X, Math.round(h / 2) - 4) : NODE_PADDING_X;
108
+ if (node.shape === 'cylinder') {
109
+ h += CYLINDER_LID;
110
+ }
111
+ return { w: even(Math.max(NODE_MIN_WIDTH, textWidth + paddingX * 2)), h: even(h), label, sub };
112
+ }
113
+
114
+ /* The box of an edge's label before it is placed: wrapped narrower than a node, on a padding of its own. */
115
+ export function sizeOfEdgeLabel(text: string): { w: number; h: number; lines: string[] } {
116
+ const lines = wrapText(text, SUB_SIZE, false, EDGE_LABEL_MAX_WIDTH);
117
+ return {
118
+ w: even(widestLine(lines, SUB_SIZE, false) + EDGE_LABEL_PADDING.x * 2),
119
+ h: lines.length * SUB_LINE + EDGE_LABEL_PADDING.y * 2,
120
+ lines
121
+ };
122
+ }
123
+
124
+ /*
125
+ * The layer of every node: the longest path to it from a node without incoming edges. A cycle has
126
+ * no such path, so it is broken first by a depth-first walk in file order (sources first, then every
127
+ * node still unvisited) and every edge that points back at a node still on the walk is left out of
128
+ * the layering. The walk only depends on the order of the file, so the same file breaks the same edges.
129
+ */
130
+ export function layersOf(nodes: readonly Pick<DiagramNode, 'id'>[], edges: readonly Pick<DiagramEdge, 'from' | 'to'>[]): Map<string, number> {
131
+ const known = new Set(nodes.map((node) => node.id));
132
+ const outgoing = new Map<string, string[]>(nodes.map((node) => [node.id, []]));
133
+ const incoming = new Map<string, number>(nodes.map((node) => [node.id, 0]));
134
+ for (const edge of edges) {
135
+ // An edge to an id that is not there is refused before a layout is asked for; a loop has no direction to rank.
136
+ if (!known.has(edge.from) || !known.has(edge.to) || edge.from === edge.to) {
137
+ continue;
138
+ }
139
+ outgoing.get(edge.from)!.push(edge.to);
140
+ incoming.set(edge.to, incoming.get(edge.to)! + 1);
141
+ }
142
+
143
+ const ON_WALK = 1;
144
+ const DONE = 2;
145
+ const state = new Map<string, number>();
146
+ const forward = new Map<string, string[]>(nodes.map((node) => [node.id, []]));
147
+ const roots = [...nodes.filter((node) => incoming.get(node.id) === 0), ...nodes];
148
+ for (const root of roots) {
149
+ if (state.has(root.id)) {
150
+ continue;
151
+ }
152
+ // Iterative, so a long chain written by an agent cannot run the stack out.
153
+ const stack: { id: string; next: number }[] = [{ id: root.id, next: 0 }];
154
+ state.set(root.id, ON_WALK);
155
+ while (stack.length > 0) {
156
+ const top = stack.at(-1)!;
157
+ const targets = outgoing.get(top.id)!;
158
+ if (top.next === targets.length) {
159
+ state.set(top.id, DONE);
160
+ stack.pop();
161
+ continue;
162
+ }
163
+ const target = targets[top.next]!;
164
+ top.next += 1;
165
+ const seen = state.get(target);
166
+ if (seen === ON_WALK) {
167
+ continue;
168
+ }
169
+ forward.get(top.id)!.push(target);
170
+ if (seen === undefined) {
171
+ state.set(target, ON_WALK);
172
+ stack.push({ id: target, next: 0 });
173
+ }
174
+ }
175
+ }
176
+
177
+ // What is left is acyclic, so a topological pass settles every longest path.
178
+ const remaining = new Map<string, number>(nodes.map((node) => [node.id, 0]));
179
+ for (const targets of forward.values()) {
180
+ for (const target of targets) {
181
+ remaining.set(target, remaining.get(target)! + 1);
182
+ }
183
+ }
184
+ const layers = new Map<string, number>(nodes.map((node) => [node.id, 0]));
185
+ const ready = nodes.filter((node) => remaining.get(node.id) === 0).map((node) => node.id);
186
+ for (let i = 0; i < ready.length; i++) {
187
+ const id = ready[i]!;
188
+ for (const target of forward.get(id)!) {
189
+ layers.set(target, Math.max(layers.get(target)!, layers.get(id)! + 1));
190
+ const left = remaining.get(target)! - 1;
191
+ remaining.set(target, left);
192
+ if (left === 0) {
193
+ ready.push(target);
194
+ }
195
+ }
196
+ }
197
+ return layers;
198
+ }
199
+
200
+ /* The layout works in the frame of a diagram that runs to the right; one that runs down is that frame mirrored across the diagonal. */
201
+ function flip<T extends Rect>(box: T): T {
202
+ return { ...box, x: box.y, y: box.x, w: box.h, h: box.w };
203
+ }
204
+
205
+ function flipPoint(point: Point): Point {
206
+ return { x: point.y, y: point.x };
207
+ }
208
+
209
+ /* Rounded, because a diagram lays its boxes and its lines out on a whole-pixel grid. */
210
+ function center(box: Rect): Point {
211
+ return roundPoint(centerOf(box));
212
+ }
213
+
214
+ function unionOf(rects: readonly Rect[]): Rect {
215
+ if (rects.length === 0) {
216
+ return { x: 0, y: 0, w: 0, h: 0 };
217
+ }
218
+ const left = Math.min(...rects.map((rect) => rect.x));
219
+ const top = Math.min(...rects.map((rect) => rect.y));
220
+ const right = Math.max(...rects.map((rect) => rect.x + rect.w));
221
+ const bottom = Math.max(...rects.map((rect) => rect.y + rect.h));
222
+ return { x: left, y: top, w: right - left, h: bottom - top };
223
+ }
224
+
225
+ /* Drops repeated corners and corners in the middle of a straight run. */
226
+ function simplify(points: readonly Point[]): Point[] {
227
+ const unique = points.filter((point, index) => index === 0 || point.x !== points[index - 1]!.x || point.y !== points[index - 1]!.y);
228
+ return unique.filter((point, index) => {
229
+ const before = unique[index - 1];
230
+ const after = unique[index + 1];
231
+ if (!before || !after) {
232
+ return true;
233
+ }
234
+ return !((before.x === point.x && point.x === after.x) || (before.y === point.y && point.y === after.y));
235
+ });
236
+ }
237
+
238
+ /*
239
+ * How far inside the side of its box a shape's outline is, at `offset` from the middle of that side,
240
+ * in the right-running frame, so an arrowhead lands on the outline rather than on the box.
241
+ */
242
+ function insetOf(shape: DiagramShape | undefined, box: Rect, offset: number, down: boolean): number {
243
+ const away = Math.abs(offset);
244
+ const half = box.h / 2;
245
+ const rounded = (radius: number): number => {
246
+ const straight = half - radius;
247
+ if (away <= straight) {
248
+ return 0;
249
+ }
250
+ const into = Math.min(radius, away - straight);
251
+ return Math.round(radius - Math.sqrt(radius * radius - into * into));
252
+ };
253
+ switch (shape ?? 'rect') {
254
+ case 'round':
255
+ return rounded(Math.min(10, box.w / 2, half));
256
+ case 'pill':
257
+ return rounded(Math.min(box.w, box.h) / 2);
258
+ case 'diamond':
259
+ return Math.round((Math.min(away, half) / half) * (box.w / 2));
260
+ case 'cylinder': {
261
+ // Running down, the side a hop meets is a lid, an ellipse as deep as the lid and as wide as the box.
262
+ if (!down) {
263
+ return 0;
264
+ }
265
+ const radiusX = Math.round(box.h / 2);
266
+ const radiusY = Math.round(Math.round(box.w * 0.18) / 2);
267
+ const share = Math.min(1, away / radiusX);
268
+ return Math.round(radiusY - radiusY * Math.sqrt(1 - share * share));
269
+ }
270
+ default:
271
+ return 0;
272
+ }
273
+ }
274
+
275
+ /* An edge with an end the layout did not place: straight out of one box and into the other, with one elbow. */
276
+ function looseRoute(source: Rect, target: Rect, loop: boolean, down: boolean): Point[] {
277
+ const from = center(source);
278
+ const to = center(target);
279
+ if (loop) {
280
+ const right = source.x + source.w;
281
+ return [
282
+ { x: right, y: from.y - 6 },
283
+ { x: right + LOOP, y: from.y - 6 },
284
+ { x: right + LOOP, y: from.y + 6 },
285
+ { x: right, y: from.y + 6 }
286
+ ];
287
+ }
288
+ // A diagram that runs down leaves a box downwards whenever the two are stacked, as its laid-out
289
+ // edges do; otherwise a dragged node's edges would come out of its side against the flow.
290
+ const stacked = target.y >= source.y + source.h || target.y + target.h <= source.y;
291
+ if (!(down && stacked) && target.x >= source.x + source.w) {
292
+ const middle = Math.round((source.x + source.w + target.x) / 2);
293
+ return simplify([
294
+ { x: source.x + source.w, y: from.y },
295
+ { x: middle, y: from.y },
296
+ { x: middle, y: to.y },
297
+ { x: target.x, y: to.y }
298
+ ]);
299
+ }
300
+ if (!(down && stacked) && target.x + target.w <= source.x) {
301
+ const middle = Math.round((target.x + target.w + source.x) / 2);
302
+ return simplify([
303
+ { x: source.x, y: from.y },
304
+ { x: middle, y: from.y },
305
+ { x: middle, y: to.y },
306
+ { x: target.x + target.w, y: to.y }
307
+ ]);
308
+ }
309
+ const middle = Math.round((from.y + to.y) / 2);
310
+ const start = to.y < from.y ? source.y : source.y + source.h;
311
+ const end = to.y < from.y ? target.y + target.h : target.y;
312
+ return simplify([
313
+ { x: from.x, y: start },
314
+ { x: from.x, y: middle },
315
+ { x: to.x, y: middle },
316
+ { x: to.x, y: end }
317
+ ]);
318
+ }
319
+
320
+ interface Chain {
321
+ edge: number;
322
+ hops: number[];
323
+ /* The edge runs against the flow, so its hops were made from its target and its points are turned around. */
324
+ reversed: boolean;
325
+ }
326
+
327
+ interface Loop {
328
+ edge: number;
329
+ unit: number;
330
+ }
331
+
332
+ interface Port {
333
+ key: number;
334
+ edge: number;
335
+ order: number;
336
+ apply: (y: number) => void;
337
+ }
338
+
339
+ /*
340
+ * Deterministic layered layout with integer coordinates. Barycenter sweeps order unpinned nodes;
341
+ * pinned nodes keep their position, and skipped layers give edges explicit routing points.
342
+ */
343
+ export function layoutOf(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups' | 'edges'>): DiagramLayout {
344
+ const down = document.meta.direction === 'down';
345
+ const layers = layersOf(document.nodes, document.edges);
346
+ const sizes = document.nodes.map((node) => sizeOfNode(node));
347
+ const knownIds = new Set(document.nodes.map((node) => node.id));
348
+
349
+ // Every node that is not pinned is a unit, sized in the right-running frame.
350
+ const units: Unit[] = [];
351
+ const unitOf = new Map<string, number>();
352
+ const unitOfNode = new Map<number, number>();
353
+ document.nodes.forEach((node, index) => {
354
+ if (node.pos) {
355
+ return;
356
+ }
357
+ const size = sizes[index]!;
358
+ unitOf.set(node.id, units.length);
359
+ unitOfNode.set(index, units.length);
360
+ units.push({
361
+ layer: layers.get(node.id) ?? 0,
362
+ node: index,
363
+ band: -1,
364
+ w: down ? size.h : size.w,
365
+ h: down ? size.w : size.h,
366
+ margin: NODE_MARGIN,
367
+ line: -1,
368
+ up: [],
369
+ down: []
370
+ });
371
+ });
372
+
373
+ const bands: Band[] = [];
374
+ const bandOfGroup = new Map<number, number>();
375
+ document.groups.forEach((group, groupIndex) => {
376
+ const inside = group.wraps.flatMap((id) => {
377
+ const unit = unitOf.get(id);
378
+ return unit === undefined || units[unit]!.band >= 0 ? [] : [unit];
379
+ });
380
+ if (inside.length === 0) {
381
+ return;
382
+ }
383
+ const first = Math.min(...inside.map((unit) => units[unit]!.layer));
384
+ const last = Math.max(...inside.map((unit) => units[unit]!.layer));
385
+ bandOfGroup.set(groupIndex, bands.length);
386
+ for (const unit of inside) {
387
+ units[unit]!.band = bands.length;
388
+ }
389
+ bands.push({
390
+ group: groupIndex,
391
+ first,
392
+ last,
393
+ members: Array.from({ length: last - first + 1 }, () => []),
394
+ top: 0,
395
+ height: 0,
396
+ before: GROUP_PADDING + (down ? 0 : GROUP_LABEL_BAND),
397
+ after: GROUP_PADDING,
398
+ rank: 0,
399
+ labelWidth: estimateTextWidth(group.label, SUB_SIZE, true)
400
+ });
401
+ });
402
+
403
+ const hops: Hop[] = [];
404
+ const chains: Chain[] = [];
405
+ const loops: Loop[] = [];
406
+ const loose: number[] = [];
407
+ const lines: number[][] = [];
408
+ document.edges.forEach((edge, index) => {
409
+ if (!knownIds.has(edge.from) || !knownIds.has(edge.to)) {
410
+ return;
411
+ }
412
+ const source = unitOf.get(edge.from);
413
+ const target = unitOf.get(edge.to);
414
+ if (source === undefined || target === undefined) {
415
+ loose.push(index);
416
+ return;
417
+ }
418
+ if (source === target) {
419
+ loops.push({ edge: index, unit: source });
420
+ return;
421
+ }
422
+ const reversed = units[source]!.layer > units[target]!.layer;
423
+ const low = reversed ? target : source;
424
+ const high = reversed ? source : target;
425
+ if (units[low]!.layer === units[high]!.layer) {
426
+ loose.push(index);
427
+ return;
428
+ }
429
+ // A point of an edge between two members of one group belongs inside that group.
430
+ const band = units[low]!.band >= 0 && units[low]!.band === units[high]!.band ? units[low]!.band : -1;
431
+ const chain: number[] = [];
432
+ const line: number[] = [];
433
+ let previous = low;
434
+ for (let layer = units[low]!.layer + 1; layer <= units[high]!.layer; layer++) {
435
+ let next = high;
436
+ if (layer < units[high]!.layer) {
437
+ next = units.length;
438
+ line.push(next);
439
+ units.push({ layer, node: -1, band, w: 0, h: 0, margin: DUMMY_MARGIN, line: lines.length, up: [], down: [] });
440
+ }
441
+ chain.push(hops.length);
442
+ hops.push({ edge: index, from: previous, to: next });
443
+ units[previous]!.down.push(next);
444
+ units[next]!.up.push(previous);
445
+ previous = next;
446
+ }
447
+ if (line.length > 0) {
448
+ lines.push(line);
449
+ }
450
+ chains.push({ edge: index, hops: chain, reversed });
451
+ });
452
+
453
+ const layerCount = units.reduce((count, unit) => Math.max(count, unit.layer + 1), 0);
454
+ const items: number[][] = Array.from({ length: layerCount }, () => []);
455
+ units.forEach((unit, index) => {
456
+ if (unit.band < 0) {
457
+ items[unit.layer]!.push(index);
458
+ return;
459
+ }
460
+ const band = bands[unit.band]!;
461
+ const slot = band.members[unit.layer - band.first]!;
462
+ if (slot.length === 0) {
463
+ items[unit.layer]!.push(bandCode(unit.band));
464
+ }
465
+ slot.push(index);
466
+ });
467
+ bands.forEach((band, index) => {
468
+ band.members.forEach((slot, offset) => {
469
+ if (slot.length === 0) {
470
+ items[band.first + offset]!.push(bandCode(index));
471
+ }
472
+ });
473
+ const tallest = band.members.reduce((most, slot) => {
474
+ const extent = slot.reduce(
475
+ (sum, unit, position) => sum + units[unit]!.h + (position > 0 ? units[slot[position - 1]!]!.margin + units[unit]!.margin : 0),
476
+ 0
477
+ );
478
+ return Math.max(most, extent);
479
+ }, 0);
480
+ band.height = band.before + tallest + band.after;
481
+ if (down) {
482
+ // Running down, a group's label runs across the flow, so the group is at least as wide as it.
483
+ band.height = Math.max(band.height, band.labelWidth + GROUP_LABEL_INSET * 2);
484
+ }
485
+ });
486
+
487
+ const layering = { units, hops, bands, lines, items };
488
+ orderLayers(layering);
489
+ const top = placeAcross(layering);
490
+ const centerOf = (unit: number): number => top[unit]! + units[unit]!.h / 2;
491
+
492
+ // Ports: every hop leaves its box on the side facing the flow and enters on the side facing back.
493
+ const hopStart = new Array<number>(hops.length).fill(0);
494
+ const hopEnd = new Array<number>(hops.length).fill(0);
495
+ const loopStart = new Array<number>(loops.length).fill(0);
496
+ const loopEnd = new Array<number>(loops.length).fill(0);
497
+ const exits: Port[][] = units.map(() => []);
498
+ const entries: Port[][] = units.map(() => []);
499
+ hops.forEach((hop, index) => {
500
+ exits[hop.from]!.push({ key: centerOf(hop.to), edge: hop.edge, order: 0, apply: (y) => (hopStart[index] = y) });
501
+ entries[hop.to]!.push({ key: centerOf(hop.from), edge: hop.edge, order: 0, apply: (y) => (hopEnd[index] = y) });
502
+ });
503
+ loops.forEach((loop, index) => {
504
+ const key = centerOf(loop.unit);
505
+ exits[loop.unit]!.push(
506
+ { key, edge: loop.edge, order: 0, apply: (y) => (loopStart[index] = y) },
507
+ { key, edge: loop.edge, order: 1, apply: (y) => (loopEnd[index] = y) }
508
+ );
509
+ });
510
+ const spread = (unit: number, ports: Port[]): void => {
511
+ if (ports.length === 0) {
512
+ return;
513
+ }
514
+ // In the order of the other ends, so two hops leaving one box do not cross on the way out.
515
+ ports.sort((left, right) => left.key - right.key || left.edge - right.edge || left.order - right.order);
516
+ const middle = centerOf(unit);
517
+ const room = Math.max(0, units[unit]!.h - PORT_INSET * 2);
518
+ const step = ports.length > 1 ? Math.min(PORT_GAP, Math.floor(room / (ports.length - 1))) : 0;
519
+ ports.forEach((port, index) => port.apply(Math.round(middle + (index - (ports.length - 1) / 2) * step)));
520
+ };
521
+ units.forEach((_, unit) => {
522
+ spread(unit, exits[unit]!);
523
+ spread(unit, entries[unit]!);
524
+ });
525
+ const hasRoomAt = (unit: number, y: number): boolean =>
526
+ units[unit]!.node >= 0 && y >= top[unit]! + PORT_INSET && y <= top[unit]! + units[unit]!.h - PORT_INSET;
527
+ hops.forEach((hop, index) => {
528
+ const gap = Math.abs(hopEnd[index]! - hopStart[index]!);
529
+ if (gap === 0 || gap > SNAP) {
530
+ return;
531
+ }
532
+ if (entries[hop.to]!.length === 1 && hasRoomAt(hop.to, hopStart[index]!)) {
533
+ hopEnd[index] = hopStart[index]!;
534
+ } else if (exits[hop.from]!.length === 1 && hasRoomAt(hop.from, hopEnd[index]!)) {
535
+ hopStart[index] = hopEnd[index]!;
536
+ }
537
+ });
538
+
539
+ const byLayer: number[][] = Array.from({ length: layerCount }, () => []);
540
+ units.forEach((unit, index) => byLayer[unit.layer]!.push(index));
541
+ const columnWidth = byLayer.map((column) => even(column.reduce((widest, unit) => Math.max(widest, units[unit]!.w), 0)));
542
+ const columnX = new Array<number>(layerCount).fill(0);
543
+ const unitX = new Array<number>(units.length).fill(0);
544
+ const hopsByLayer: number[][] = Array.from({ length: layerCount }, () => []);
545
+ hops.forEach((hop, index) => hopsByLayer[units[hop.from]!.layer]!.push(index));
546
+
547
+ const labelSizes = document.edges.map((edge) => (edge.label ? sizeOfEdgeLabel(edge.label) : null));
548
+ const labelHop = new Map<number, number>();
549
+ for (const chain of chains) {
550
+ if (labelSizes[chain.edge]) {
551
+ labelHop.set(chain.hops[Math.floor((chain.hops.length - 1) / 2)]!, chain.edge);
552
+ }
553
+ }
554
+ const frameLabels = new Map<number, Rect>();
555
+ const trackX = new Map<number, number>();
556
+
557
+ const alongBefore = GROUP_PADDING + (down ? GROUP_LABEL_BAND : 0);
558
+ const bandAlongStart = (band: Band): number => Math.min(...band.members[0]!.map((unit) => unitX[unit]!)) - alongBefore;
559
+ for (let layer = 0; layer < layerCount; layer++) {
560
+ for (const unit of byLayer[layer]!) {
561
+ unitX[unit] = columnX[layer]! + Math.floor((columnWidth[layer]! - units[unit]!.w) / 2);
562
+ }
563
+ if (layer === layerCount - 1) {
564
+ break;
565
+ }
566
+ const start = columnX[layer]! + columnWidth[layer]!;
567
+ const channelHops: ChannelHop[] = hopsByLayer[layer]!.map((hop) => ({ hop, ya: hopStart[hop]!, yb: hopEnd[hop]! }));
568
+ const channelLabels: ChannelLabel[] = [];
569
+ for (const hop of hopsByLayer[layer]!) {
570
+ const edge = labelHop.get(hop);
571
+ const size = edge === undefined ? null : labelSizes[edge];
572
+ if (edge !== undefined && size) {
573
+ channelLabels.push({ edge, hop, y: hopStart[hop]!, along: down ? size.h : size.w, across: down ? size.w : size.h });
574
+ }
575
+ }
576
+ loops.forEach((loop, index) => {
577
+ const size = labelSizes[loop.edge];
578
+ if (size && units[loop.unit]!.layer === layer) {
579
+ channelLabels.push({ edge: loop.edge, hop: -1, y: loopEnd[index]!, along: down ? size.h : size.w, across: down ? size.w : size.h });
580
+ }
581
+ });
582
+ channelLabels.sort((left, right) => left.edge - right.edge);
583
+ const obstacles: { y: number; h: number }[] = [];
584
+ for (const band of bands) {
585
+ if (band.first > layer || band.last <= layer) {
586
+ continue;
587
+ }
588
+ // A group that runs through the channel crosses it with its two borders, and running right with its label band.
589
+ obstacles.push({ y: band.top - 3, h: down ? 6 : GROUP_LABEL_BAND }, { y: band.top + band.height - 3, h: 6 });
590
+ }
591
+ const endsHere = bands.some((band) => band.last === layer);
592
+ const startsNext = bands.some((band) => band.first === layer + 1);
593
+ const channel = routeChannel({
594
+ start,
595
+ clearBefore: EDGE_CLEAR + (endsHere ? GROUP_PADDING : 0),
596
+ clearAfter: EDGE_CLEAR + (startsNext ? alongBefore : 0),
597
+ hops: channelHops,
598
+ labels: channelLabels,
599
+ obstacles
600
+ });
601
+ channel.trackX.forEach((x, hop) => trackX.set(hop, x));
602
+ channel.labels.forEach((rect, edge) => frameLabels.set(edge, rect));
603
+ columnX[layer + 1] = start + channel.width;
604
+ }
605
+
606
+ const frameBoxOf = (unit: number): Rect => ({ x: unitX[unit]!, y: top[unit]!, w: units[unit]!.w, h: units[unit]!.h });
607
+ const shapeOf = (unit: number): DiagramShape | undefined => document.nodes[units[unit]!.node]!.shape;
608
+ const exitX = (unit: number, y: number): number => {
609
+ const self = units[unit]!;
610
+ if (self.node < 0) {
611
+ return columnX[self.layer]! + columnWidth[self.layer]!;
612
+ }
613
+ const box = frameBoxOf(unit);
614
+ return box.x + box.w - insetOf(shapeOf(unit), box, y - centerOf(unit), down);
615
+ };
616
+ const entryX = (unit: number, y: number): number => {
617
+ const self = units[unit]!;
618
+ if (self.node < 0) {
619
+ return columnX[self.layer]!;
620
+ }
621
+ const box = frameBoxOf(unit);
622
+ return box.x + insetOf(shapeOf(unit), box, y - centerOf(unit), down);
623
+ };
624
+ const toReal = (point: Point): Point => (down ? flipPoint(point) : point);
625
+ const labelOf = (edge: number, frame: Rect | undefined): EdgeLabelBox | null => {
626
+ const size = labelSizes[edge];
627
+ if (!size || !frame) {
628
+ return null;
629
+ }
630
+ return { ...(down ? flip(frame) : frame), lines: size.lines };
631
+ };
632
+
633
+ const nodes: NodeBox[] = document.nodes.map((node, index) => {
634
+ const size = sizes[index]!;
635
+ const unit = unitOfNode.get(index);
636
+ if (unit === undefined) {
637
+ const [px, py] = node.pos!;
638
+ return {
639
+ id: node.id,
640
+ x: Math.round(px),
641
+ y: Math.round(py),
642
+ w: size.w,
643
+ h: size.h,
644
+ layer: layers.get(node.id) ?? 0,
645
+ pinned: true,
646
+ label: size.label,
647
+ sub: size.sub
648
+ };
649
+ }
650
+ const frame = frameBoxOf(unit);
651
+ const box = down ? flip(frame) : frame;
652
+ return { id: node.id, ...box, layer: units[unit]!.layer, pinned: false, label: size.label, sub: size.sub };
653
+ });
654
+ const byId = new Map(nodes.map((box) => [box.id, box]));
655
+
656
+ const routes: (EdgeRoute | undefined)[] = [];
657
+ const unplaced = new Set<number>(loose);
658
+ for (const chain of chains) {
659
+ const points: Point[] = [];
660
+ for (const index of chain.hops) {
661
+ const hop = hops[index]!;
662
+ const ya = hopStart[index]!;
663
+ const yb = hopEnd[index]!;
664
+ points.push({ x: exitX(hop.from, ya), y: ya });
665
+ if (ya !== yb) {
666
+ const x = trackX.get(index)!;
667
+ points.push({ x, y: ya }, { x, y: yb });
668
+ }
669
+ points.push({ x: entryX(hop.to, yb), y: yb });
670
+ }
671
+ const ordered = chain.reversed ? simplify(points).reverse() : simplify(points);
672
+ const edge = document.edges[chain.edge]!;
673
+ routes[chain.edge] = {
674
+ index: chain.edge,
675
+ from: edge.from,
676
+ to: edge.to,
677
+ points: ordered.map(toReal),
678
+ label: labelOf(chain.edge, frameLabels.get(chain.edge))
679
+ };
680
+ }
681
+ loops.forEach((loop, index) => {
682
+ const box = frameBoxOf(loop.unit);
683
+ const reach = box.x + box.w + LOOP;
684
+ const startY = loopStart[index]!;
685
+ const endY = loopEnd[index]!;
686
+ const points = [
687
+ { x: exitX(loop.unit, startY), y: startY },
688
+ { x: reach, y: startY },
689
+ { x: reach, y: endY },
690
+ { x: exitX(loop.unit, endY), y: endY }
691
+ ];
692
+ if (!frameLabels.has(loop.edge)) {
693
+ unplaced.add(loop.edge);
694
+ }
695
+ const size = labelSizes[loop.edge];
696
+ // In the last layer there is no channel to put a label in, so it sits beside the loop.
697
+ const frame =
698
+ frameLabels.get(loop.edge) ??
699
+ (size
700
+ ? {
701
+ x: reach + 6,
702
+ y: Math.round((startY + endY) / 2) - Math.floor((down ? size.w : size.h) / 2),
703
+ w: down ? size.h : size.w,
704
+ h: down ? size.w : size.h
705
+ }
706
+ : undefined);
707
+ const edge = document.edges[loop.edge]!;
708
+ routes[loop.edge] = { index: loop.edge, from: edge.from, to: edge.to, points: points.map(toReal), label: labelOf(loop.edge, frame) };
709
+ });
710
+ for (const index of loose) {
711
+ const edge = document.edges[index]!;
712
+ const source = byId.get(edge.from)!;
713
+ const target = byId.get(edge.to)!;
714
+ const points = looseRoute(source, target, edge.from === edge.to, down);
715
+ const size = labelSizes[index];
716
+ let label: EdgeLabelBox | null = null;
717
+ if (size) {
718
+ const at = Math.max(0, Math.floor((points.length - 2) / 2));
719
+ const first = points[at]!;
720
+ const second = points[at + 1] ?? first;
721
+ const middle = { x: Math.round((first.x + second.x) / 2), y: Math.round((first.y + second.y) / 2) };
722
+ label = { x: middle.x - size.w / 2, y: middle.y - size.h - 4, w: size.w, h: size.h, lines: size.lines };
723
+ }
724
+ routes[index] = { index, from: edge.from, to: edge.to, points, label };
725
+ }
726
+ const edges = routes.filter((route): route is EdgeRoute => route !== undefined);
727
+
728
+ const alongAfter = GROUP_PADDING;
729
+ const groups: GroupBox[] = document.groups.flatMap((group, groupIndex) => {
730
+ const rects: Rect[] = [];
731
+ const bandIndex = bandOfGroup.get(groupIndex);
732
+ if (bandIndex !== undefined) {
733
+ const band = bands[bandIndex]!;
734
+ const members = band.members.flat().filter((unit) => units[unit]!.node >= 0);
735
+ const alongStart = bandAlongStart(band);
736
+ const alongEnd = Math.max(...members.map((unit) => unitX[unit]! + units[unit]!.w)) + alongAfter;
737
+ const frame = { x: alongStart, y: band.top, w: alongEnd - alongStart, h: band.height };
738
+ rects.push(down ? flip(frame) : frame);
739
+ }
740
+ for (const id of group.wraps) {
741
+ const box = byId.get(id);
742
+ if (box?.pinned) {
743
+ rects.push({
744
+ x: box.x - GROUP_PADDING,
745
+ y: box.y - GROUP_PADDING - GROUP_LABEL_BAND,
746
+ w: box.w + GROUP_PADDING * 2,
747
+ h: box.h + GROUP_PADDING * 2 + GROUP_LABEL_BAND
748
+ });
749
+ }
750
+ }
751
+ if (rects.length === 0) {
752
+ return [];
753
+ }
754
+ const rect = unionOf(rects);
755
+ const labelBox = { x: rect.x + GROUP_LABEL_INSET, y: rect.y + 4, w: estimateTextWidth(group.label, SUB_SIZE, true), h: SUB_LINE };
756
+ return [{ id: group.id, ...rect, labelBox }];
757
+ });
758
+
759
+ // A label that no channel placed (a loop in the last layer, an edge with a pinned end) moves down until it is clear.
760
+ const settled = edges.flatMap((edge) => (edge.label && !unplaced.has(edge.index) ? [edge.label] : []));
761
+ for (const edge of edges) {
762
+ const label = edge.label;
763
+ if (!label || !unplaced.has(edge.index)) {
764
+ continue;
765
+ }
766
+ const taken = (): boolean =>
767
+ [...nodes, ...groups.map((group) => group.labelBox)].some((box) => intersects(box, label)) ||
768
+ settled.some((other) => intersects({ x: other.x - 4, y: other.y - 4, w: other.w + 8, h: other.h + 8 }, label));
769
+ for (let step = 0; step < 1000 && taken(); step++) {
770
+ label.y += 4;
771
+ }
772
+ settled.push(label);
773
+ }
774
+ const corners = edges.flatMap((edge) => edge.points.map((point) => ({ ...point, w: 0, h: 0 })));
775
+ const labels = edges.flatMap((edge) => (edge.label ? [edge.label] : []));
776
+ const groupLabels = groups.map((group) => group.labelBox);
777
+ return { nodes, groups, edges, bounds: unionOf([...nodes, ...groups, ...groupLabels, ...corners, ...labels]) };
778
+ }