@yassimba/pi-loom-mermaid 0.2.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.
@@ -0,0 +1,1749 @@
1
+ /**
2
+ * Graph layout: rank, order, place, route, draw.
3
+ *
4
+ * Follows the Sugiyama outline — assign ranks along the flow axis, reorder
5
+ * within ranks to cut crossings, then relax positions on the cross axis so
6
+ * chains stay straight. Edges between adjacent ranks share horizontal "bus"
7
+ * rows; everything else is routed around the diagram through vertical "lanes".
8
+ *
9
+ * `BT` and `RL` reuse the `TD`/`LR` layouts and flip the finished canvas, so
10
+ * text never ends up mirrored.
11
+ */
12
+
13
+ import type { Canvas } from './canvas.ts'
14
+ import type { Edge } from './graph.ts'
15
+ import type { Graph } from './graph.ts'
16
+ import { fitLabel, type Limits, wrapLabel } from './labels.ts'
17
+ import { brandesKoepf, type LayeredGraph } from './placement.ts'
18
+ import { stringWidth } from './width.ts'
19
+
20
+ /** Cells of padding between a box border and its text. */
21
+ export const PAD = 1
22
+ /** Minimum horizontal / vertical space between boxes. */
23
+ const GAP_X = 3
24
+ const GAP_Y = 2
25
+ /** Refuse to allocate a canvas larger than this many cells. */
26
+ export const MAX_CANVAS_CELLS = 1 << 21
27
+
28
+ /** Saturating subtraction; Rust's `usize` arithmetic never goes negative. */
29
+ export const sat = (a: number, b: number): number => Math.max(0, a - b)
30
+ export const half = (n: number): number => Math.floor(n / 2)
31
+
32
+ /**
33
+ * Everything an edge says, joined — the fallback for routes that have no
34
+ * per-end placement (lanes, self-loops). Forward routes place `cardFrom` /
35
+ * `cardTo` at their own ends instead.
36
+ */
37
+ /** Columns a label takes once fitted to `max`. */
38
+ const labelCols = (text: string, max: number): number => Math.min(stringWidth(text), max)
39
+
40
+ /** Where a label starts: right of its arrow, or ending just left of it. */
41
+ const labelStart = (arrowX: number, text: string, left: boolean, max: number): number =>
42
+ left ? sat(arrowX, labelCols(text, max)) : arrowX + 1
43
+
44
+ /** The label parts drawn at a forward edge's arrival: verb and target cardinality. */
45
+ const arrivalParts = (e: Edge): string[] => [e.label, e.cardTo].filter((p) => p != null) as string[]
46
+
47
+ export function edgeText(edge: Edge): string | null {
48
+ const joined = [edge.cardFrom ?? '', edge.label ?? '', edge.cardTo ?? '']
49
+ .filter((part) => part !== '')
50
+ .join(' ')
51
+ return joined === '' ? null : joined
52
+ }
53
+
54
+ export interface Placed {
55
+ x: number
56
+ y: number
57
+ w: number
58
+ h: number
59
+ cx: number
60
+ cy: number
61
+ rank: number
62
+ }
63
+
64
+ /** Per-node dimensions. `lay*` include room for self-edge loops and labels. */
65
+ interface NodeSizes {
66
+ boxW: number[]
67
+ boxH: number[]
68
+ layW: number[]
69
+ layH: number[]
70
+ extraH: number[]
71
+ selfLabelW: number[]
72
+ /** Edge labels are fitted to this many columns. */
73
+ maxLabel: number
74
+ }
75
+
76
+ /** What to draw inside a node box. */
77
+ export type NodeExtra =
78
+ | { kind: 'plain' }
79
+ | { kind: 'frame'; sub: Canvas }
80
+ | { kind: 'compartments'; sections: string[][] }
81
+
82
+ /**
83
+ * One edge's path as data: cell corners from the source border to the head
84
+ * cell, in drawing order. Painting derives everything else — the junction
85
+ * bits at the border, the head and tail glyphs from the approach direction,
86
+ * the segments between corners. Labels wait until every route has landed.
87
+ */
88
+ export interface Route {
89
+ points: [number, number][]
90
+ /** Labels written across the lines once all routes are drawn. */
91
+ labels: { text: string; row: number; x: number }[]
92
+ /** A lane label that slides along its run to a clear stretch (left-to-right lanes). */
93
+ laneLabel?: LaneLabel
94
+ }
95
+
96
+ /** A lane label waiting for every route to land before claiming its spot. */
97
+ export interface LaneLabel {
98
+ text: string
99
+ y: number
100
+ lo: number
101
+ hi: number
102
+ }
103
+
104
+ /** The placement stage's result: canvas size and a route per edge (`null` for a self loop, which draws its own stub). */
105
+ interface Plan {
106
+ canvasW: number
107
+ canvasH: number
108
+ routes: Route[]
109
+ }
110
+
111
+ // ------------------------------------------------------------------ ranking
112
+
113
+ /**
114
+ * Rank assignment along the flow axis.
115
+ *
116
+ * Cycles are broken by a DFS colouring pass in declaration order, so the
117
+ * edge treated as the return is the one the author wrote against the flow
118
+ * (`A --> B --> C --> A` returns on `C --> A`); greedy feedback-set
119
+ * heuristics reverse fewer edges on random graphs but ignore that order.
120
+ * Reversed edges take part in ranking in their reversed direction, so a
121
+ * return always climbs at least one rank. Longest-path layering puts each
122
+ * node as early as its predecessors allow, then Nikolov's node promotion
123
+ * (mirrored: nodes move later) shortens edges while that removes more
124
+ * virtual chain nodes than it adds.
125
+ */
126
+ function computeRanks(graph: Graph): number[] {
127
+ const n = graph.nodes.length
128
+ const children: number[][] = Array.from({ length: n }, () => [])
129
+ const indeg = new Array<number>(n).fill(0)
130
+ for (const e of graph.edges) {
131
+ if (e.from !== e.to) {
132
+ children[e.from].push(e.to)
133
+ indeg[e.to]++
134
+ }
135
+ }
136
+ const color = new Uint8Array(n)
137
+ const tree: number[][] = Array.from({ length: n }, () => [])
138
+ const postorder: number[] = []
139
+ // Roots first so ranks grow from natural entry points, then any leftovers.
140
+ const roots = [...Array(n).keys()].filter((i) => indeg[i] === 0)
141
+ for (const start of [...roots, ...Array(n).keys()]) {
142
+ if (color[start] === 0) dfsDag(start, children, color, tree, postorder)
143
+ }
144
+ const forward = new Set<string>()
145
+ tree.forEach((vs, u) => {
146
+ for (const v of vs) forward.add(`${u}>${v}`)
147
+ })
148
+
149
+ const succ: number[][] = Array.from({ length: n }, () => [])
150
+ const pred: number[][] = Array.from({ length: n }, () => [])
151
+ for (const e of graph.edges) {
152
+ if (e.from === e.to) continue
153
+ const [a, b] = forward.has(`${e.from}>${e.to}`) ? [e.from, e.to] : [e.to, e.from]
154
+ succ[a].push(b)
155
+ pred[b].push(a)
156
+ }
157
+ const order = [...postorder].reverse()
158
+
159
+ const rank = new Array<number>(n).fill(0)
160
+ for (const u of order) for (const v of succ[u]) rank[v] = Math.max(rank[v], rank[u] + 1)
161
+
162
+ // Demote a node (and whatever it would collide with) one rank later;
163
+ // worth keeping when the virtual nodes saved on its incoming edges
164
+ // outnumber those added on its outgoing ones.
165
+ const demote = (v: number): number => {
166
+ let saved = 0
167
+ for (const w of succ[v]) if (rank[w] === rank[v] + 1) saved += demote(w)
168
+ rank[v]++
169
+ return saved + succ[v].length - pred[v].length
170
+ }
171
+ for (let round = 0; round < 8; round++) {
172
+ let improved = false
173
+ for (let v = 0; v < n; v++) {
174
+ if (succ[v].length === 0) continue
175
+ const before = [...rank]
176
+ if (demote(v) > 0) improved = true
177
+ else rank.splice(0, n, ...before)
178
+ }
179
+ if (!improved) break
180
+ }
181
+ const min = Math.min(...rank, 0)
182
+ return rank.map((r) => r - min)
183
+ }
184
+
185
+ /** Iterative DFS recording postorder and skipping edges back into the stack. */
186
+ function dfsDag(
187
+ start: number,
188
+ children: number[][],
189
+ color: Uint8Array,
190
+ dag: number[][],
191
+ order: number[],
192
+ ): void {
193
+ const stack: { u: number; i: number }[] = [{ u: start, i: 0 }]
194
+ color[start] = 1
195
+ while (stack.length > 0) {
196
+ const frame = stack[stack.length - 1]
197
+ const u = frame.u
198
+ if (frame.i < children[u].length) {
199
+ const v = children[u][frame.i]
200
+ frame.i++
201
+ if (color[v] === 1) continue // grey: a back edge, ignore it
202
+ dag[u].push(v)
203
+ if (color[v] === 0) {
204
+ color[v] = 1
205
+ stack.push({ u: v, i: 0 })
206
+ }
207
+ } else {
208
+ color[u] = 2
209
+ order.push(u)
210
+ stack.pop()
211
+ }
212
+ }
213
+ }
214
+
215
+
216
+ /**
217
+ * The layered graph crossing reduction works on: every real node plus one
218
+ * virtual node per intermediate rank of each forward edge spanning more than
219
+ * one rank (the edge becomes a chain of unit segments). Ids below `n` are
220
+ * real; `up[id]` / `down[id]` list unit-segment neighbours.
221
+ */
222
+ interface Layered extends LayeredGraph {
223
+ /** Per edge, its virtual nodes from source to target; empty unless it skips ranks. */
224
+ chains: number[][]
225
+ /** Virtual nodes on more than one chain (a concentrated trunk). */
226
+ shared: Set<number>
227
+ }
228
+
229
+ /**
230
+ * Split each edge into unit-rank segments: forward adjacent edges and the
231
+ * ones `interior` accepts take part, the rest run around the outside and
232
+ * are left out. A chain is listed in the edge's own direction, so a back
233
+ * edge's runs up the ranks.
234
+ *
235
+ * Edges leaving one node share virtual nodes for as long as they all
236
+ * continue (dot's `concentrate`): the fan runs as one trunk that splits
237
+ * where the first target arrives, one column per rank instead of one per
238
+ * edge. Edges arriving at one node share the same way on their last
239
+ * ranks. A node is never shared both ways, which would join two edges
240
+ * with neither end in common and read as a third. Naive normalisation is
241
+ * bounded by MAX_EDGES × MAX_NODES virtual nodes, small enough here.
242
+ */
243
+ function normalize(
244
+ byRank: number[][],
245
+ edges: Edge[],
246
+ ranks: number[],
247
+ interior: (e: Edge) => boolean,
248
+ ): Layered {
249
+ const n = ranks.length
250
+ const layers = byRank.map((row) => [...row])
251
+ const up: number[][] = Array.from({ length: n }, () => [])
252
+ const down: number[][] = Array.from({ length: n }, () => [])
253
+ const link = (a: number, b: number, upward: boolean): void => {
254
+ const [hi, lo] = upward ? [b, a] : [a, b]
255
+ if (down[hi].includes(lo)) return
256
+ down[hi].push(lo)
257
+ up[lo].push(hi)
258
+ }
259
+ const chains: number[][] = edges.map(() => [])
260
+ const shared = new Set<number>()
261
+ const trunks = new Map<string, number>()
262
+ const takes = (e: Edge): boolean =>
263
+ e.from !== e.to && (ranks[e.to] === ranks[e.from] + 1 || interior(e))
264
+ // How far from each end a group of edges keeps company: up to the
265
+ // second farthest endpoint among edges sharing that end, since sharing
266
+ // needs two.
267
+ const reach = (key: 'from' | 'to'): Map<number, number> => {
268
+ const other = key === 'from' ? 'to' : 'from'
269
+ const ends = new Map<number, number[]>()
270
+ for (const e of edges) {
271
+ if (!takes(e)) continue
272
+ const list = ends.get(e[key]) ?? []
273
+ list.push(ranks[e[other]])
274
+ ends.set(e[key], list)
275
+ }
276
+ const out = new Map<number, number>()
277
+ for (const [node, rs] of ends) {
278
+ const d = rs.map((r) => Math.abs(r - ranks[node])).sort((a, b) => a - b)
279
+ if (d.length > 1) out.set(node, d[d.length - 2])
280
+ }
281
+ return out
282
+ }
283
+ const fromReach = reach('from')
284
+ const toReach = reach('to')
285
+ edges.forEach((e, i) => {
286
+ if (!takes(e)) return
287
+ const upward = ranks[e.to] < ranks[e.from]
288
+ const step = upward ? -1 : 1
289
+ const span = Math.abs(ranks[e.to] - ranks[e.from])
290
+ const headEnd = Math.min(fromReach.get(e.from) ?? 0, span) - 1
291
+ const tailStart = span - Math.min(toReach.get(e.to) ?? 0, span) + 1
292
+ let prev = e.from
293
+ for (let k = 1; k < span; k++) {
294
+ const r = ranks[e.from] + step * k
295
+ const key = k <= headEnd ? `f${e.from}@${r}` : k >= tailStart && k > headEnd ? `t${e.to}@${r}` : null
296
+ let v = key === null ? undefined : trunks.get(key)
297
+ if (v === undefined) {
298
+ v = up.length
299
+ up.push([])
300
+ down.push([])
301
+ layers[r].push(v)
302
+ if (key !== null) trunks.set(key, v)
303
+ } else shared.add(v)
304
+ chains[i].push(v)
305
+ link(prev, v, upward)
306
+ prev = v
307
+ }
308
+ link(prev, e.to, upward)
309
+ })
310
+ return { layers, up, down, chains, shared }
311
+ }
312
+
313
+ /**
314
+ * Reorder nodes within each rank to minimise edge crossings.
315
+ *
316
+ * Edges `interior` accepts (the ones later routed through the diagram
317
+ * rather than around it) are normalised into virtual-node chains first, so every boundary crossing is
318
+ * visible to the count and a long edge is ordered as one coherent chain;
319
+ * the rest run around the outside and are ignored here. Alternate down/up barycenter sweeps are each followed
320
+ * by adjacent-transposition cleanup; sweeping stops after two rounds without
321
+ * improvement, keeping whichever ordering crossed least.
322
+ *
323
+ * `trailing` nodes must end their rank (lane endpoints: the strip they exit
324
+ * toward lies past the rank's last box, so anything ordered beyond them
325
+ * would be cut through). The constraint is applied inside every sweep, so the
326
+ * crossing count that picks the best order is the count of the order used.
327
+ */
328
+ export function orderRanks(
329
+ byRank: number[][],
330
+ edges: Edge[],
331
+ ranks: number[],
332
+ interior: (e: Edge) => boolean,
333
+ trailing: boolean[] = [],
334
+ ): Layered {
335
+ const n = ranks.length
336
+ const isTrailing = (v: number): boolean => trailing[v] ?? false
337
+ const partition = (row: number[]): void => {
338
+ row.sort((a, b) => Number(isTrailing(a)) - Number(isTrailing(b)))
339
+ }
340
+ for (const row of byRank) partition(row)
341
+ const layered = normalize(byRank, edges, ranks, interior)
342
+ if (byRank.length < 2 || n < 3) return layered
343
+
344
+ const { layers, up, down } = layered
345
+ const pos = new Array<number>(up.length).fill(0)
346
+ const reindex = (row: number[]): void => {
347
+ for (let i = 0; i < row.length; i++) pos[row[i]] = i
348
+ }
349
+ for (const row of layers) reindex(row)
350
+ const total = (): number => {
351
+ let sum = 0
352
+ for (let r = 0; r + 1 < layers.length; r++) sum += crossingsBetween(layers[r], down, pos)
353
+ return sum
354
+ }
355
+
356
+ let best = layers.map((row) => [...row])
357
+ let bestCrossings = total()
358
+ const sweep = (): void => {
359
+ let stale = 0
360
+ let current = total()
361
+ for (let it = 0; current > 0 && stale < 2 && it < 24; it++) {
362
+ const downward = it % 2 === 0
363
+ const rows = downward ? layers.slice(1) : layers.slice(0, -1).reverse()
364
+ const neigh = downward ? up : down
365
+ for (const row of rows) {
366
+ sortByMedian(row, neigh, pos)
367
+ partition(row)
368
+ reindex(row)
369
+ }
370
+ transpose(layers, up, down, pos, isTrailing)
371
+ const crossings = total()
372
+ if (crossings < current) {
373
+ current = crossings
374
+ stale = 0
375
+ } else stale++
376
+ if (crossings < bestCrossings) {
377
+ bestCrossings = crossings
378
+ best = layers.map((row) => [...row])
379
+ }
380
+ }
381
+ }
382
+ // The sweeps settle into a local minimum shaped by the starting order:
383
+ // declaration order first, then a few seeded shuffles, best kept.
384
+ let seed = 0x9e3779b9
385
+ const random = (): number => {
386
+ seed = (Math.imul(seed, 1103515245) + 12345) >>> 0
387
+ return seed / 0x100000000
388
+ }
389
+ for (let restart = 0; restart < 4 && bestCrossings > 0; restart++) {
390
+ if (restart > 0) {
391
+ for (const row of layers) {
392
+ for (let i = row.length - 1; i > 0; i--) {
393
+ const j = Math.floor(random() * (i + 1))
394
+ ;[row[i], row[j]] = [row[j], row[i]]
395
+ }
396
+ partition(row)
397
+ reindex(row)
398
+ }
399
+ }
400
+ sweep()
401
+ }
402
+
403
+ for (let i = 0; i < byRank.length; i++) {
404
+ byRank[i].splice(0, byRank[i].length, ...best[i].filter((v) => v < n))
405
+ }
406
+ return { ...layered, layers: best }
407
+ }
408
+
409
+ /**
410
+ * Sort a rank by each node's weighted median neighbour position (Gansner
411
+ * et al.): the median for an odd count, the mean of the two middle ones
412
+ * for two, otherwise the two middle ones weighted toward the side whose
413
+ * neighbours spread less. A node without neighbours keeps its place.
414
+ */
415
+ function sortByMedian(row: number[], neigh: number[][], pos: number[]): void {
416
+ const key = (v: number): number => {
417
+ const p = neigh[v].map((u) => pos[u]).sort((a, b) => a - b)
418
+ const m = p.length >> 1
419
+ if (p.length === 0) return pos[v]
420
+ if (p.length % 2 === 1) return p[m]
421
+ if (p.length === 2) return (p[0] + p[1]) / 2
422
+ const left = p[m - 1] - p[0]
423
+ const right = p[p.length - 1] - p[m]
424
+ return left + right === 0 ? (p[m - 1] + p[m]) / 2 : (p[m - 1] * right + p[m] * left) / (left + right)
425
+ }
426
+ const keyed = row.map((v) => ({ key: key(v), v }))
427
+ keyed.sort((a, b) => a.key - b.key)
428
+ for (let i = 0; i < keyed.length; i++) row[i] = keyed[i].v
429
+ }
430
+
431
+ /**
432
+ * Swap adjacent nodes while that lowers the crossings with both neighbouring
433
+ * layers (Gansner et al.'s transpose step). Never swaps across the
434
+ * trailing boundary.
435
+ */
436
+ function transpose(
437
+ layers: number[][],
438
+ up: number[][],
439
+ down: number[][],
440
+ pos: number[],
441
+ isTrailing: (v: number) => boolean,
442
+ ): void {
443
+ let improved = true
444
+ for (let guard = 0; improved && guard < 8; guard++) {
445
+ improved = false
446
+ for (const row of layers) {
447
+ for (let i = 0; i + 1 < row.length; i++) {
448
+ const v = row[i]
449
+ const w = row[i + 1]
450
+ if (isTrailing(v) !== isTrailing(w)) continue
451
+ const before = pairCrossings(v, w, up, pos) + pairCrossings(v, w, down, pos)
452
+ const after = pairCrossings(w, v, up, pos) + pairCrossings(w, v, down, pos)
453
+ if (after < before) {
454
+ row[i] = w
455
+ row[i + 1] = v
456
+ pos[w] = i
457
+ pos[v] = i + 1
458
+ improved = true
459
+ }
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ /** Crossings among the segments of `v` and `w` if `v` sits left of `w`. */
466
+ function pairCrossings(v: number, w: number, neigh: number[][], pos: number[]): number {
467
+ let count = 0
468
+ for (const a of neigh[v]) for (const b of neigh[w]) if (pos[a] > pos[b]) count++
469
+ return count
470
+ }
471
+
472
+ /**
473
+ * Crossings between `row` and the layer below it: segments sorted by their
474
+ * upper end, then inversions of the lower ends counted with a Fenwick tree
475
+ * (Barth, Mutzel and Jünger's O(M log N) method).
476
+ */
477
+ function crossingsBetween(row: number[], down: number[][], pos: number[]): number {
478
+ const lower: number[] = []
479
+ let width = 0
480
+ for (const v of row) {
481
+ const ends = down[v].map((u) => pos[u]).sort((a, b) => a - b)
482
+ for (const p of ends) {
483
+ lower.push(p)
484
+ width = Math.max(width, p + 1)
485
+ }
486
+ }
487
+ const tree = new Array<number>(width + 1).fill(0)
488
+ let crossings = 0
489
+ for (let i = 0; i < lower.length; i++) {
490
+ // Earlier segments ending right of this one cross it.
491
+ let greater = i
492
+ for (let k = lower[i] + 1; k > 0; k -= k & -k) greater -= tree[k]
493
+ crossings += greater
494
+ for (let k = lower[i] + 1; k <= width; k += k & -k) tree[k]++
495
+ }
496
+ return crossings
497
+ }
498
+
499
+ /**
500
+ * Cross-axis centre for every node of the layered graph, real and virtual:
501
+ * Brandes–Köpf over measured sizes. A virtual chain node takes one cell so
502
+ * the long edge it carries has a clear column (row, in LR) to run along,
503
+ * one blank cell from whatever neighbours it — plus `pad(left)` cells when
504
+ * it follows a real node, room for that node's arrival labels.
505
+ */
506
+ function assignPositions(
507
+ layered: Layered,
508
+ size: number[],
509
+ sep: number,
510
+ pad: (node: number) => number = () => 0,
511
+ offset: (v: number) => number = () => 0,
512
+ padLeft: (node: number) => number = () => 0,
513
+ ): number[] {
514
+ const n = size.length
515
+ const all = [...size]
516
+ while (all.length < layered.up.length) all.push(1)
517
+ // `pad(v)` reserves cells right of `v` for a label: a real node's arrival
518
+ // labels when a chain follows it, or a chain node's own edge label;
519
+ // `padLeft(v)` the same on its left.
520
+ const sepOf = (left: number, right: number): number =>
521
+ left < n && right < n ? sep : 1 + (left >= n || right >= n ? pad(left) : 0) + padLeft(right)
522
+ return brandesKoepf(layered, all, sepOf, n, offset)
523
+ }
524
+
525
+ // ------------------------------------------------------------------- tracks
526
+
527
+ /**
528
+ * A span competing for a track: the covered coordinate range, the arms
529
+ * that reach it from either side, and its edge. In a band between ranks,
530
+ * `up` arms come from the earlier rank and `down` arms lead on to the
531
+ * later one (in a lane strip both arms are `up`).
532
+ */
533
+ interface TrackSpan {
534
+ start: number
535
+ end: number
536
+ from: number
537
+ to: number
538
+ edge: number
539
+ up: number[]
540
+ down: number[]
541
+ /** A labelled lane refuses endpoint sharing in `packTracks`: the label
542
+ * would appear to cover every edge merged onto the row. */
543
+ labeled?: boolean
544
+ }
545
+
546
+ /** Spans merged onto one track because they share an endpoint. */
547
+ interface Hyper {
548
+ members: TrackSpan[]
549
+ start: number
550
+ end: number
551
+ up: number[]
552
+ down: number[]
553
+ }
554
+
555
+ /**
556
+ * Order spans onto parallel tracks, nearest the earlier rank first, so
557
+ * that arms cross as few other spans' runs as possible (Sander's segment
558
+ * ordering, as in ELK's orthogonal router): spans that share an endpoint
559
+ * merge into one run — edges fanning out of one node draw one `┴` origin
560
+ * rather than a stack of them — then every two runs that overlap are
561
+ * compared both ways, the cheaper order becomes a dependency, cycles are
562
+ * broken greedily, and a run's track is its longest dependency path. Runs
563
+ * two cells apart share a track.
564
+ */
565
+ function assignTracks(spans: TrackSpan[]): { assigned: [number, number][]; count: number } {
566
+ const hypers = mergeShared(spans)
567
+ const n = hypers.length
568
+ const overlaps = (a: Hyper, b: Hyper): boolean => a.start <= b.end + 1 && b.start <= a.end + 1
569
+ /** Crossings when `a` runs on the track nearer the earlier rank than `b`. */
570
+ const crossings = (a: Hyper, b: Hyper): number =>
571
+ a.down.filter((x) => b.start < x && x < b.end).length +
572
+ b.up.filter((x) => a.start < x && x < a.end).length
573
+ const weight: number[][] = Array.from({ length: n }, () => new Array<number>(n).fill(-1))
574
+ for (let i = 0; i < n; i++) {
575
+ for (let j = i + 1; j < n; j++) {
576
+ if (!overlaps(hypers[i], hypers[j])) continue
577
+ const ij = crossings(hypers[i], hypers[j])
578
+ const ji = crossings(hypers[j], hypers[i])
579
+ // Equal: keep the earlier-starting run nearer, the packing order.
580
+ if (ij < ji || (ij === ji && hypers[i].start <= hypers[j].start)) weight[i][j] = ji - ij
581
+ else weight[j][i] = ij - ji
582
+ }
583
+ }
584
+ const order = greedyAcyclic(weight)
585
+ const track = new Array<number>(n).fill(0)
586
+ for (const v of order) {
587
+ for (let u = 0; u < n; u++) {
588
+ if (weight[u][v] >= 0 && track[u] + 1 > track[v]) track[v] = track[u] + 1
589
+ }
590
+ }
591
+ const assigned: [number, number][] = []
592
+ hypers.forEach((h, i) => {
593
+ for (const m of h.members) assigned.push([m.edge, track[i]])
594
+ })
595
+ return { assigned, count: n === 0 ? 0 : Math.max(...track) + 1 }
596
+ }
597
+
598
+ /**
599
+ * Pack lane spans into as few tracks as possible, shortest first: a span
600
+ * contained in another takes the inner track, so exits and entries at rows
601
+ * the inner lane never reaches cross nothing. Lanes trade crossings for
602
+ * height, where `assignTracks`' dependency chains would cost a track each.
603
+ */
604
+ function packTracks(spans: TrackSpan[]): { assigned: [number, number][]; count: number } {
605
+ const sorted = [...spans].sort(
606
+ (a, b) =>
607
+ a.end - a.start - (b.end - b.start) ||
608
+ a.start - b.start ||
609
+ a.end - b.end ||
610
+ a.from - b.from ||
611
+ a.to - b.to ||
612
+ a.edge - b.edge,
613
+ )
614
+ const tracks: TrackSpan[][] = []
615
+ const assigned: [number, number][] = []
616
+ for (const span of sorted) {
617
+ let slot = tracks.findIndex((members) =>
618
+ members.every(
619
+ (m) =>
620
+ m.end + 2 <= span.start ||
621
+ span.end + 2 <= m.start ||
622
+ ((m.from === span.from || m.to === span.to) && !m.labeled && !span.labeled),
623
+ ),
624
+ )
625
+ if (slot === -1) {
626
+ tracks.push([])
627
+ slot = tracks.length - 1
628
+ }
629
+ tracks[slot].push(span)
630
+ assigned.push([span.edge, slot])
631
+ }
632
+ return { assigned, count: tracks.length }
633
+ }
634
+
635
+ function mergeShared(spans: TrackSpan[]): Hyper[] {
636
+ const sorted = [...spans].sort(
637
+ (a, b) => a.start - b.start || a.end - b.end || a.from - b.from || a.to - b.to || a.edge - b.edge,
638
+ )
639
+ const hypers: Hyper[] = []
640
+ for (const span of sorted) {
641
+ const host = hypers.find((h) =>
642
+ h.members.some(
643
+ (m) =>
644
+ (m.from === span.from && m.up[0] === span.up[0]) ||
645
+ (m.to === span.to && m.down[0] === span.down[0]),
646
+ ),
647
+ )
648
+ if (host === undefined) {
649
+ hypers.push({ members: [span], start: span.start, end: span.end, up: [...span.up], down: [...span.down] })
650
+ continue
651
+ }
652
+ host.members.push(span)
653
+ host.start = Math.min(host.start, span.start)
654
+ host.end = Math.max(host.end, span.end)
655
+ host.up.push(...span.up)
656
+ host.down.push(...span.down)
657
+ }
658
+ return hypers
659
+ }
660
+
661
+ /**
662
+ * Eades–Lin–Smyth greedy cycle removal on a weighted dependency matrix:
663
+ * returns a vertex order; dependencies pointing backwards in it are
664
+ * dropped (set to -1). Sinks go last, sources first, else the vertex with
665
+ * the largest outgoing-minus-incoming weight goes first.
666
+ */
667
+ function greedyAcyclic(weight: number[][]): number[] {
668
+ const n = weight.length
669
+ const alive = new Array<boolean>(n).fill(true)
670
+ const head: number[] = []
671
+ const tail: number[] = []
672
+ const sum = (v: number, incoming: boolean): number => {
673
+ let total = 0
674
+ for (let u = 0; u < n; u++) {
675
+ const w = incoming ? weight[u][v] : weight[v][u]
676
+ if (alive[u] && w >= 0) total += w + 1
677
+ }
678
+ return total
679
+ }
680
+ let left = n
681
+ while (left > 0) {
682
+ let progressed = false
683
+ for (let v = 0; v < n; v++) {
684
+ if (!alive[v]) continue
685
+ if (sum(v, false) === 0) {
686
+ tail.push(v)
687
+ alive[v] = false
688
+ left--
689
+ progressed = true
690
+ } else if (sum(v, true) === 0) {
691
+ head.push(v)
692
+ alive[v] = false
693
+ left--
694
+ progressed = true
695
+ }
696
+ }
697
+ if (progressed || left === 0) continue
698
+ let best = -1
699
+ let bestScore = Number.NEGATIVE_INFINITY
700
+ for (let v = 0; v < n; v++) {
701
+ if (!alive[v]) continue
702
+ const score = sum(v, false) - sum(v, true)
703
+ if (score > bestScore) {
704
+ bestScore = score
705
+ best = v
706
+ }
707
+ }
708
+ head.push(best)
709
+ alive[best] = false
710
+ left--
711
+ }
712
+ const order = [...head, ...tail.reverse()]
713
+ const pos = new Array<number>(n).fill(0)
714
+ order.forEach((v, i) => (pos[v] = i))
715
+ for (let u = 0; u < n; u++) for (let v = 0; v < n; v++) if (weight[u][v] >= 0 && pos[u] > pos[v]) weight[u][v] = -1
716
+ return order
717
+ }
718
+
719
+ /** Forward edges crossing the band between rank `r` and `r + 1` that must
720
+ * jog sideways, so need a bus row. */
721
+ function busSpans(
722
+ graph: Graph,
723
+ ranks: number[],
724
+ centers: number[],
725
+ r: number,
726
+ exact: boolean,
727
+ entry: (edge: number) => number = (i) => centers[graph.edges[i].to],
728
+ ): TrackSpan[] {
729
+ const out: TrackSpan[] = []
730
+ graph.edges.forEach((e, i) => {
731
+ const jogs = exact
732
+ ? centers[e.from] !== centers[e.to]
733
+ : Math.abs(centers[e.from] - centers[e.to]) > 1
734
+ if (e.from !== e.to && ranks[e.to] === ranks[e.from] + 1 && ranks[e.from] === r && jogs) {
735
+ const arrive = exact ? centers[e.to] : entry(i)
736
+ out.push({
737
+ start: Math.min(centers[e.from], arrive),
738
+ end: Math.max(centers[e.from], arrive),
739
+ from: e.from,
740
+ to: e.to,
741
+ edge: i,
742
+ up: [centers[e.from]],
743
+ down: [arrive],
744
+ })
745
+ }
746
+ })
747
+ return out
748
+ }
749
+
750
+ /** Left-to-right edges skipping a rank or running backwards that go around
751
+ * in a lane below the diagram. */
752
+ function laneSpans(graph: Graph, ranks: number[], placed: Placed[]): TrackSpan[] {
753
+ const out: TrackSpan[] = []
754
+ graph.edges.forEach((e, i) => {
755
+ if (e.from === e.to || ranks[e.to] === ranks[e.from] + 1) return
756
+ const pf = placed[e.from]
757
+ const pt = placed[e.to]
758
+ const a = Math.min(pf.cx, pt.cx)
759
+ const b = Math.max(pf.cx, pt.cx)
760
+ out.push({
761
+ start: a,
762
+ end: b,
763
+ from: e.from,
764
+ to: e.to,
765
+ edge: i,
766
+ up: [pf.cx, pt.cx],
767
+ down: [],
768
+ labeled: edgeText(e) !== null,
769
+ })
770
+ })
771
+ return out
772
+ }
773
+
774
+ // ----------------------------------------------------------------- placement
775
+
776
+ /** One sideways jog of an interior skip route, competing for a bus track. */
777
+ interface ChainJog extends TrackSpan {
778
+ band: number
779
+ /** Cross-axis coordinate the edge continues along after the jog. */
780
+ at: number
781
+ }
782
+
783
+ /**
784
+ * The jogs an interior edge makes following its virtual chain: exit
785
+ * coordinate to the first chain coordinate, between chain nodes where they
786
+ * differ, and from the last one to the entry coordinate. Edges `exit`
787
+ * returns `null` for take no part (they stay on a lane). A back edge walks
788
+ * its bands upward.
789
+ */
790
+ function chainJogs(
791
+ graph: Graph,
792
+ ranks: number[],
793
+ layered: Layered,
794
+ centers: number[],
795
+ ends: (e: Edge, i: number) => { exit: number; entry: number } | null,
796
+ ): ChainJog[] {
797
+ const jogs: ChainJog[] = []
798
+ graph.edges.forEach((e, i) => {
799
+ const at = ends(e, i)
800
+ if (at === null) return
801
+ const chain = layered.chains[i]
802
+ const stops = [at.exit, ...chain.map((v) => centers[v]), at.entry]
803
+ const ids = [e.from, ...chain, e.to]
804
+ const upward = ranks[e.to] < ranks[e.from]
805
+ for (let k = 0; k + 1 < stops.length; k++) {
806
+ if (stops[k] === stops[k + 1]) continue
807
+ jogs.push({
808
+ band: upward ? ranks[e.from] - 1 - k : ranks[e.from] + k,
809
+ at: stops[k + 1],
810
+ start: Math.min(stops[k], stops[k + 1]),
811
+ end: Math.max(stops[k], stops[k + 1]),
812
+ from: ids[k],
813
+ to: ids[k + 1],
814
+ edge: i,
815
+ up: [upward ? stops[k + 1] : stops[k]],
816
+ down: [upward ? stops[k] : stops[k + 1]],
817
+ })
818
+ }
819
+ })
820
+ return jogs
821
+ }
822
+
823
+ /** Per edge, its jogs as route waypoints once bus coordinates are known. */
824
+ function skipRoutes(
825
+ graph: Graph,
826
+ jogs: ChainJog[],
827
+ busOf: (j: ChainJog) => number,
828
+ ): { bus: number; at: number }[][] {
829
+ const routes: { bus: number; at: number }[][] = graph.edges.map(() => [])
830
+ for (const j of jogs) routes[j.edge].push({ bus: busOf(j), at: j.at })
831
+ return routes
832
+ }
833
+
834
+ /**
835
+ * A chain column that coincides with a port column of the rank above or
836
+ * below would share cells with that port's vertical inside the band (a
837
+ * forward exit at the centre of the box above; a back exit beside centre
838
+ * in the box below, which climbs past the forward tracks). Nudge
839
+ * such a chain node by a cell where the gaps to its neighbours allow.
840
+ */
841
+ function clearPorts(
842
+ graph: Graph,
843
+ layered: Layered,
844
+ centers: number[],
845
+ size: number[],
846
+ backExit: (node: number) => number[],
847
+ ): void {
848
+ const n = graph.nodes.length
849
+ const ends = new Map<number, number[]>()
850
+ graph.edges.forEach((e, i) => {
851
+ for (const v of layered.chains[i]) ends.set(v, [...(ends.get(v) ?? []), e.from, e.to])
852
+ })
853
+ layered.layers.forEach((row, r) => {
854
+ /** Port column → the node owning it; a chain's own endpoints are no conflict. */
855
+ const ports = new Map<number, number[]>()
856
+ const claim = (col: number, u: number): void => {
857
+ const owners = ports.get(col)
858
+ if (owners) owners.push(u)
859
+ else ports.set(col, [u])
860
+ }
861
+ for (const u of layered.layers[r - 1] ?? []) if (u < n) claim(centers[u], u)
862
+ for (const u of layered.layers[r + 1] ?? []) {
863
+ if (u < n) for (const col of backExit(u)) claim(col, u)
864
+ }
865
+ row.forEach((v, i) => {
866
+ if (v < n) return
867
+ const own: number[] = ends.get(v) ?? []
868
+ const blocked = (col: number): boolean =>
869
+ (ports.get(col) ?? []).some((u) => !own.includes(u))
870
+ if (!blocked(centers[v])) return
871
+ const left = row[i - 1]
872
+ const right = row[i + 1]
873
+ const lo = left === undefined ? 0 : centers[left] + Math.ceil(size[left] / 2) + 1
874
+ const hi = right === undefined ? Number.MAX_SAFE_INTEGER : centers[right] - Math.ceil(size[right] / 2) - 1
875
+ for (const d of [1, -1, 2, -2]) {
876
+ const c = centers[v] + d
877
+ if (c >= lo && c <= hi && !blocked(c)) {
878
+ centers[v] = c
879
+ return
880
+ }
881
+ }
882
+ })
883
+ })
884
+ }
885
+
886
+ function placeTd(
887
+ ranks: number[],
888
+ maxRank: number,
889
+ byRank: number[][],
890
+ layered: Layered,
891
+ sizes: NodeSizes,
892
+ graph: Graph,
893
+ placed: Placed[],
894
+ ): Plan {
895
+ const maxLabel = sizes.maxLabel
896
+ // Arrival labels hang right of a box's entry heads (forward: above the
897
+ // top, back: below the bottom), on rows a chain column passes through: a
898
+ // chain placed right of a box keeps clear of them.
899
+ const headPad = (skip: (i: number) => boolean): number[] => {
900
+ const pad = new Array<number>(layered.up.length).fill(0)
901
+ graph.edges.forEach((e, i) => {
902
+ if (e.from === e.to || skip(i)) return
903
+ const parts = ranks[e.to] > ranks[e.from] ? arrivalParts(e) : [edgeText(e) ?? '']
904
+ for (const part of parts) pad[e.to] = Math.max(pad[e.to], part === '' ? 0 : labelCols(part, maxLabel) + 1)
905
+ })
906
+ return pad
907
+ }
908
+ // A back edge leaves the source's top and enters the target's bottom two
909
+ // cells off centre, clear of the forward exits and arrivals that own the
910
+ // centre column — the short return arrow mermaid draws. Which side: the
911
+ // port's arm climbs (drops) through the band's forward bus rows, crossing
912
+ // every one that spans the port column; and at the target, the jog from
913
+ // the port toward the route's next stop crosses the target's own exit
914
+ // column when the stop lies on the other side (at the source the
915
+ // arrivals' arms end below the back rows, so its jog crosses nothing).
916
+ // Take the side that costs less.
917
+ // The chain, aligned with an endpoint's centre by Brandes–Köpf, then
918
+ // shifts to that endpoint's port so it runs straight from it.
919
+ const isBack = (e: Edge): boolean => e.from !== e.to && ranks[e.to] < ranks[e.from]
920
+ const allAtHead = headPad(() => false)
921
+ const first = assignPositions(layered, sizes.layW, GAP_X, (node) => allAtHead[node])
922
+ // An edge with a chain carries its label beside the chain's vertical
923
+ // (dagre's label dummy), on whichever chain node has slack enough beside
924
+ // it in the first placement, nearest the middle; the node then reserves
925
+ // that width. Without such slack the label stays at the head row, where
926
+ // it shares the target's row and costs nothing.
927
+ /** Per edge, the chain node carrying its label, the label width and side (+1 right, -1 left). */
928
+ const chainLabel: ({ v: number; w: number; side: number } | null)[] = graph.edges.map(() => null)
929
+ const taken = new Set<number>()
930
+ const layerOf = new Array<number>(layered.up.length).fill(0)
931
+ layered.layers.forEach((row, r) => {
932
+ for (const v of row) layerOf[v] = r
933
+ })
934
+ const extent = (v: number): number => (v < graph.nodes.length ? sizes.layW[v] : 1)
935
+ const slack = (v: number, side: number): number => {
936
+ const row = layered.layers[layerOf[v]]
937
+ const u = row[row.indexOf(v) + side]
938
+ if (u === undefined) return 0
939
+ const reserved = side < 0 ? allAtHead[u] : 0
940
+ return Math.abs(first[u] - first[v]) - half(extent(u)) - half(extent(v)) - 1 - reserved
941
+ }
942
+ graph.edges.forEach((e, i) => {
943
+ const chain = layered.chains[i]
944
+ const text = edgeText(e)
945
+ if (chain.length === 0 || text === null) return
946
+ const w = labelCols(text, maxLabel)
947
+ const mid = chain.length >> 1
948
+ let best: { v: number; side: number; dist: number } | null = null
949
+ chain.forEach((v, k) => {
950
+ for (const side of [1, -1]) {
951
+ if (slack(v, side) < w + 1 || taken.has(v) || layered.shared.has(v)) continue
952
+ const dist = Math.abs(k - mid)
953
+ if (best === null || dist < best.dist) best = { v, side, dist }
954
+ }
955
+ })
956
+ if (best === null) return
957
+ const { v, side } = best as { v: number; side: number }
958
+ chainLabel[i] = { v, w, side }
959
+ taken.add(v)
960
+ })
961
+ const labelPad = headPad((i) => chainLabel[i] !== null)
962
+ const labelPadLeft = new Array<number>(layered.up.length).fill(0)
963
+ for (const label of chainLabel) {
964
+ if (label === null) continue
965
+ if (label.side > 0) labelPad[label.v] = label.w + 1
966
+ else labelPadLeft[label.v] = label.w + 1
967
+ }
968
+ /** Forward bus rows in the band below rank `r` whose span covers column `p`. */
969
+ const busOver = (r: number, p: number): number =>
970
+ graph.edges.filter((e) => {
971
+ if (e.from === e.to || ranks[e.from] !== r || ranks[e.to] !== r + 1) return false
972
+ const [a, b] = [first[e.from], first[e.to]]
973
+ return Math.abs(a - b) > 1 && Math.min(a, b) < p && p < Math.max(a, b)
974
+ }).length
975
+ const portSide = (node: number, band: number, toward: number, atTarget: boolean): number => {
976
+ const cx = first[node]
977
+ const cost = (side: number): number => {
978
+ const p = cx + 2 * side
979
+ const exits = graph.edges.some((e) => e.from === node && ranks[e.to] > ranks[node])
980
+ const jog = atTarget && exits && (toward - cx) * side < 0 ? 1 : 0
981
+ return busOver(band, p) + jog
982
+ }
983
+ return cost(-1) < cost(1) ? -1 : 1
984
+ }
985
+ const exitSide = graph.edges.map((e, i) =>
986
+ isBack(e) ? portSide(e.from, ranks[e.from] - 1, first[layered.chains[i][0] ?? e.to], false) : 0,
987
+ )
988
+ const entrySide = graph.edges.map((e, i) => {
989
+ if (!isBack(e)) return 0
990
+ const last = layered.chains[i].at(-1)
991
+ const toward = last === undefined ? first[e.from] + 2 * exitSide[i] : first[last]
992
+ return portSide(e.to, ranks[e.to], toward, true)
993
+ })
994
+ const shift = new Map<number, number>()
995
+ graph.edges.forEach((e, i) => {
996
+ const chain = layered.chains[i]
997
+ if (!isBack(e) || chain.length === 0) return
998
+ const side =
999
+ first[chain[0]] === first[e.from]
1000
+ ? exitSide[i]
1001
+ : first[chain[chain.length - 1]] === first[e.to]
1002
+ ? entrySide[i]
1003
+ : 0
1004
+ for (const v of chain) shift.set(v, 2 * side)
1005
+ })
1006
+ const centers = assignPositions(
1007
+ layered,
1008
+ sizes.layW,
1009
+ GAP_X,
1010
+ (node) => labelPad[node],
1011
+ (v) => shift.get(v) ?? 0,
1012
+ (node) => labelPadLeft[node],
1013
+ )
1014
+ const boxL = (j: number): number => sat(centers[j], half(sizes.boxW[j]))
1015
+ const boxR = (j: number): number => boxL(j) + sizes.boxW[j] - 1
1016
+ const port = (node: number, side: number): number =>
1017
+ Math.max(boxL(node) + 1, Math.min(boxR(node) - 1, centers[node] + 2 * side))
1018
+ // A return entering on the left labels leftward; give the leftmost such
1019
+ // label room before the first column.
1020
+ let margin = 0
1021
+ graph.edges.forEach((e, i) => {
1022
+ const text = edgeText(e)
1023
+ if (!isBack(e) || entrySide[i] >= 0 || text === null || chainLabel[i] !== null) return
1024
+ margin = Math.max(margin, labelCols(text, maxLabel) + 1 - port(e.to, -1))
1025
+ })
1026
+ for (let v = 0; v < centers.length; v++) centers[v] += margin
1027
+ clearPorts(graph, layered, centers, sizes.layW, (node) =>
1028
+ graph.edges.flatMap((e, i) => (isBack(e) && e.from === node ? [port(node, exitSide[i])] : [])),
1029
+ )
1030
+
1031
+ // Top-entry geometry, derivable before placement. A node's entries land
1032
+ // across the box top in the order they arrive from (a forward by its
1033
+ // source's column, a skip by the column its chain comes down), so no
1034
+ // approach crosses another on the way in. A forward arrival whose source
1035
+ // sits over the box top keeps its own head and drops straight, unless
1036
+ // forwards jog in from both sides of it (their shared bus would cross
1037
+ // the drop); the forwards jogging in from outside merge into one
1038
+ // arrival, placed at their sources' mean; each skip gets its own. Whatever falls outside the top
1039
+ // spreads over the room left beside the straight drops. A label that
1040
+ // does not fit before the next entry renders left of its arrow.
1041
+ const isSkip = (e: Edge): boolean => e.from !== e.to && ranks[e.to] - ranks[e.from] > 1
1042
+ const isFwd = (e: Edge): boolean => e.from !== e.to && ranks[e.to] === ranks[e.from] + 1
1043
+ const edgeEntryX = new Array<number>(graph.edges.length).fill(-1)
1044
+ const edgeLabelLeft = new Array<boolean>(graph.edges.length).fill(false)
1045
+ const labelW = (i: number): number => {
1046
+ if (chainLabel[i] !== null) return -1
1047
+ const e = graph.edges[i]
1048
+ const parts = arrivalParts(e)
1049
+ return parts.length === 0 ? -1 : Math.max(...parts.map((p) => labelCols(p, maxLabel)))
1050
+ }
1051
+ const into: number[][] = graph.nodes.map(() => [])
1052
+ graph.edges.forEach((e, i) => {
1053
+ if (isSkip(e) || isFwd(e)) into[e.to].push(i)
1054
+ })
1055
+ graph.nodes.forEach((_, t) => {
1056
+ const entries = into[t]
1057
+ if (entries.length === 0) return
1058
+ const cx = centers[t]
1059
+ const left = boxL(t)
1060
+ const right = boxR(t)
1061
+ const arrives = (i: number): number => centers[layered.chains[i].at(-1) ?? graph.edges[i].from]
1062
+ /** One entry: a single edge, or the forwards merged onto one arrival. */
1063
+ type Item = { slot: number; w: number; edges: number[]; key: number }
1064
+ const item = (edges: number[], key: number): Item => ({
1065
+ slot: 0,
1066
+ w: Math.max(...edges.map(labelW)),
1067
+ edges,
1068
+ key,
1069
+ })
1070
+ const fwds = entries.filter((i) => isFwd(graph.edges[i]))
1071
+ const over = (i: number): boolean => arrives(i) > left && arrives(i) < right
1072
+ const flanked = fwds.some((i) => arrives(i) <= left) && fwds.some((i) => arrives(i) >= right)
1073
+ const jogging = fwds.filter((i) => !over(i) || flanked)
1074
+ let items: Item[] = entries.filter((i) => !jogging.includes(i)).map((i) => item([i], arrives(i)))
1075
+ if (jogging.length > 0) {
1076
+ items.push(item(jogging, jogging.reduce((a, i) => a + centers[graph.edges[i].from], 0) / jogging.length))
1077
+ }
1078
+ // Slots: an arrival at most a cell off centre snaps to it (routeForward
1079
+ // straightens such a jog), other in-range arrivals keep their column,
1080
+ // the rest spread evenly over the top. Then walk left to right with a
1081
+ // cursor over the free head-row cells: each entry lands at its slot
1082
+ // (or past the previous label), its own label going right when the
1083
+ // next slot leaves room, else left when the cells behind the cursor
1084
+ // allow. Null when the top runs out of room.
1085
+ const walk = (list: Item[]): { cols: number[]; lefts: boolean[] } | null => {
1086
+ list.sort((a, b) => a.key - b.key || a.edges[0] - b.edges[0])
1087
+ const fixed = list.filter((it) => it.key > left && it.key < right)
1088
+ for (const item of fixed) item.slot = Math.abs(item.key - cx) <= 1 ? cx : item.key
1089
+ const spread = (group: Item[], lo: number, hi: number): void => {
1090
+ group.forEach((item, i) => {
1091
+ const at = lo + Math.round(((hi - lo) * (i + 1)) / (group.length + 1))
1092
+ item.slot = Math.max(left + 1, Math.min(right - 1, at))
1093
+ })
1094
+ }
1095
+ spread(
1096
+ list.filter((it) => it.key <= left),
1097
+ left,
1098
+ fixed.length > 0 ? Math.max(left, fixed[0].slot - 2) : right,
1099
+ )
1100
+ spread(
1101
+ list.filter((it) => it.key >= right),
1102
+ fixed.length > 0 ? Math.min(right, fixed[fixed.length - 1].slot + 2) : left,
1103
+ right,
1104
+ )
1105
+ const cols: number[] = []
1106
+ const lefts: boolean[] = []
1107
+ let cursor = left
1108
+ for (const [i, item] of list.entries()) {
1109
+ const x = Math.max(item.slot, cursor)
1110
+ if (x > right - 1) return null
1111
+ const next = list[i + 1]?.slot ?? Number.MAX_SAFE_INTEGER
1112
+ const w = item.w
1113
+ if (w >= 0 && x + w + 2 > next && x - cursor >= w) {
1114
+ lefts.push(true)
1115
+ cursor = x + 2
1116
+ } else {
1117
+ lefts.push(false)
1118
+ cursor = w >= 0 ? x + w + 2 : x + 2
1119
+ }
1120
+ cols.push(x)
1121
+ }
1122
+ return { cols, lefts }
1123
+ }
1124
+ let fit = walk(items)
1125
+ // No room for a head each: every forward merges into one arrival on
1126
+ // the centre and the skips spread around it.
1127
+ if (fit === null && fwds.length > jogging.length) {
1128
+ items = [...items.filter((it) => !fwds.includes(it.edges[0])), item(fwds, cx)]
1129
+ fit = walk(items)
1130
+ }
1131
+ if (fit !== null) {
1132
+ items.forEach((it, i) => {
1133
+ for (const ei of it.edges) {
1134
+ edgeEntryX[ei] = fit.cols[i]
1135
+ edgeLabelLeft[ei] = fit.lefts[i]
1136
+ }
1137
+ })
1138
+ return
1139
+ }
1140
+ // Legacy: forwards merge on the centre; a skip lands past the arrival
1141
+ // labels, or left of centre with its own label flipped left.
1142
+ const reach = fwds.length > 0 ? Math.max(cx, ...fwds.map((i) => cx + 1 + labelW(i))) : -1
1143
+ for (const si of entries) {
1144
+ if (isFwd(graph.edges[si])) {
1145
+ edgeEntryX[si] = cx
1146
+ continue
1147
+ }
1148
+ // A gap of one cell keeps two heads apart; with no room for that
1149
+ // on either side, the skip merges onto the centre arrow.
1150
+ const clear = reach === -1 ? cx + 2 : reach + 2
1151
+ if (clear <= right - 1) edgeEntryX[si] = clear
1152
+ else if (cx - 2 >= left + 1) {
1153
+ edgeEntryX[si] = cx - 2
1154
+ edgeLabelLeft[si] = true
1155
+ } else edgeEntryX[si] = cx
1156
+ }
1157
+ })
1158
+ // Every skip and back edge runs through the interior along the column its
1159
+ // virtual chain reserved; each band it jogs in lends it a bus track. A
1160
+ // skip's departure jog shares the source's fan row (endpoint sharing), so
1161
+ // a node's forward fan and its skips split from one `┴` origin.
1162
+ graph.edges.forEach((e, i) => {
1163
+ if (!isBack(e)) return
1164
+ edgeEntryX[i] = port(e.to, entrySide[i])
1165
+ edgeLabelLeft[i] = entrySide[i] < 0
1166
+ })
1167
+ const edgeExitX = new Array<number>(graph.edges.length).fill(-1)
1168
+ graph.edges.forEach((e, i) => {
1169
+ if (!isBack(e)) return
1170
+ // A one-column step reads as a kink; snap the exit to the next stop.
1171
+ const next = layered.chains[i].length > 0 ? centers[layered.chains[i][0]] : edgeEntryX[i]
1172
+ const exit = port(e.from, exitSide[i])
1173
+ edgeExitX[i] = Math.abs(exit - next) <= 1 ? next : exit
1174
+ })
1175
+ const jogs = chainJogs(graph, ranks, layered, centers, (e, i) => {
1176
+ if (isSkip(e)) return { exit: centers[e.from], entry: edgeEntryX[i] }
1177
+ return isBack(e) ? { exit: edgeExitX[i], entry: edgeEntryX[i] } : null
1178
+ })
1179
+ const jogTrack = new Map<ChainJog, number>()
1180
+
1181
+ const edgeBus = new Array<number>(graph.edges.length).fill(0)
1182
+ const busTracks = new Array<number>(maxRank + 1).fill(0)
1183
+ for (let r = 0; r < maxRank; r++) {
1184
+ const spans = busSpans(graph, ranks, centers, r, false, (i) =>
1185
+ edgeEntryX[i] === -1 ? centers[graph.edges[i].to] : edgeEntryX[i],
1186
+ )
1187
+ const bandJogs = jogs.filter((j) => j.band === r)
1188
+ spans.push(...bandJogs)
1189
+ if (spans.length === 0) continue
1190
+ // Back-edge arrowheads sit on the first band row, back buses right under
1191
+ // it, forward buses below those: with the attach columns offset right of
1192
+ // centre, a reciprocal pair then runs as two parallel staircases whose
1193
+ // verticals fall outside each other's horizontal spans — no crossings.
1194
+ const back = spans.filter((s) => isBack(graph.edges[s.edge]))
1195
+ const fwd = spans.filter((s) => !isBack(graph.edges[s.edge]))
1196
+ const base = graph.edges.some((e) => isBack(e) && ranks[e.to] === r) ? 1 : 0
1197
+ const b = assignTracks(back)
1198
+ for (const [idx, slot] of b.assigned) edgeBus[idx] = base + slot
1199
+ const f = assignTracks(fwd)
1200
+ for (const [idx, slot] of f.assigned) edgeBus[idx] = base + b.count + slot
1201
+ for (const j of bandJogs) jogTrack.set(j, edgeBus[j.edge])
1202
+ busTracks[r] = base + b.count + f.count
1203
+ }
1204
+
1205
+ const rankH = byRank.map((row) =>
1206
+ row.length === 0 ? 3 : Math.max(...row.map((i) => sizes.boxH[i] + sizes.extraH[i])),
1207
+ )
1208
+ // Per-end cardinalities want a row each around the verb: source card,
1209
+ // label, arrow-and-target-card.
1210
+ const hasCards = graph.edges.some((e) => e.cardFrom !== undefined || e.cardTo !== undefined)
1211
+ const gapY = hasCards ? Math.max(GAP_Y, 3) : GAP_Y
1212
+ const rankY = new Array<number>(maxRank + 1).fill(0)
1213
+ for (let r = 1; r <= maxRank; r++) {
1214
+ rankY[r] = rankY[r - 1] + rankH[r - 1] + Math.max(gapY, busTracks[r - 1] + 1)
1215
+ }
1216
+ const canvasH = rankY[maxRank] + rankH[maxRank]
1217
+ const bandEnd = Array.from({ length: maxRank + 1 }, (_, r) => rankY[r] + rankH[r])
1218
+ const jogRoute = skipRoutes(graph, jogs, (j) => bandEnd[j.band] + (jogTrack.get(j) ?? 0))
1219
+
1220
+ let diagramW = 1
1221
+ for (let v = graph.nodes.length; v < centers.length; v++) diagramW = Math.max(diagramW, centers[v] + 1)
1222
+ byRank.forEach((row, r) => {
1223
+ for (const idx of row) {
1224
+ const w = sizes.boxW[idx]
1225
+ const h = sizes.boxH[idx]
1226
+ const cx = centers[idx]
1227
+ const x = sat(cx, half(w))
1228
+ const y = rankY[r] + half(rankH[r] - h - sizes.extraH[idx])
1229
+ placed[idx] = { x, y, w, h, cx, cy: y + half(h), rank: r }
1230
+ diagramW = Math.max(diagramW, x + w)
1231
+ if (sizes.extraH[idx] > 0 && sizes.selfLabelW[idx] > 0) {
1232
+ diagramW = Math.max(diagramW, x + w + 2 + sizes.selfLabelW[idx])
1233
+ }
1234
+ }
1235
+ })
1236
+
1237
+ const edgeLabelAt = chainLabel.map((label) => {
1238
+ if (label === null) return null
1239
+ const { v, w, side } = label
1240
+ const r = layerOf[v]
1241
+ return { row: rankY[r] + half(rankH[r]), x: side > 0 ? centers[v] + 2 : centers[v] - 1 - w }
1242
+ })
1243
+ let contentW = diagramW
1244
+ graph.edges.forEach((e, i) => {
1245
+ if (e.from === e.to) return
1246
+ const label = chainLabel[i]
1247
+ if (label !== null) {
1248
+ contentW = Math.max(contentW, (edgeLabelAt[i] as { x: number }).x + label.w)
1249
+ } else if (ranks[e.to] > ranks[e.from]) {
1250
+ const parts = arrivalParts(e)
1251
+ const entry = Math.max(placed[e.to].cx, edgeEntryX[i])
1252
+ for (const part of parts) {
1253
+ const lw = labelCols(part, maxLabel)
1254
+ contentW = Math.max(contentW, entry + 2 + lw)
1255
+ }
1256
+ if (e.cardFrom !== undefined) {
1257
+ contentW = Math.max(contentW, placed[e.from].cx + 2 + stringWidth(e.cardFrom))
1258
+ }
1259
+ } else {
1260
+ const text = edgeText(e)
1261
+ if (text !== null) {
1262
+ // routeBackChain starts the label right of the entry column.
1263
+ contentW = Math.max(contentW, edgeEntryX[i] + 2 + labelCols(text, maxLabel))
1264
+ }
1265
+ }
1266
+ })
1267
+
1268
+ const routes = graph.edges.map((edge, i): Route => {
1269
+ const from = placed[edge.from]
1270
+ const to = placed[edge.to]
1271
+ if (edge.from === edge.to) return selfRoute(from, edge, maxLabel)
1272
+ if (isBack(edge)) {
1273
+ return backChainRoute(from, to, edge, edgeExitX[i], edgeEntryX[i], jogRoute[i], edgeLabelLeft[i], edgeLabelAt[i], maxLabel)
1274
+ }
1275
+ if (isSkip(edge)) {
1276
+ return chainRoute(from, to, edge, edgeEntryX[i], jogRoute[i], edgeLabelLeft[i], edgeLabelAt[i], maxLabel)
1277
+ }
1278
+ return forwardRoute(from, to, edge, bandEnd[from.rank] + edgeBus[i], edgeEntryX[i], edgeLabelLeft[i], maxLabel)
1279
+ })
1280
+ return { canvasW: contentW, canvasH, routes }
1281
+ }
1282
+
1283
+ function placeLr(
1284
+ ranks: number[],
1285
+ maxRank: number,
1286
+ byRank: number[][],
1287
+ layered: Layered,
1288
+ sizes: NodeSizes,
1289
+ graph: Graph,
1290
+ placed: Placed[],
1291
+ ): Plan {
1292
+ const colW = byRank.map((row) =>
1293
+ row.length === 0 ? 0 : Math.max(...row.map((i) => sizes.boxW[i])),
1294
+ )
1295
+
1296
+ const centers = assignPositions(layered, sizes.layH, 1)
1297
+
1298
+ // A skip whose target entry row crosses no box on any intermediate rank
1299
+ // runs straight through the diagram into the target's left side, exiting
1300
+ // through the source's right-side fan; the bottom lane is the fallback.
1301
+ // (No entry spreading or local returns here: LR boxes are three rows tall,
1302
+ // so the centre row is the only usable port on a side.)
1303
+ const isSkip = (e: Edge): boolean => e.from !== e.to && ranks[e.to] - ranks[e.from] > 1
1304
+ // A back-edge target's bottom-entry `▲` stub sits one row below its box;
1305
+ // a straight run through that cell would appear to carry the arrival.
1306
+ const stubRows = new Set<number>()
1307
+ for (const e of graph.edges) {
1308
+ if (e.from === e.to || ranks[e.to] >= ranks[e.from]) continue
1309
+ const t = e.to
1310
+ stubRows.add(sat(centers[t], half(sizes.boxH[t] + sizes.extraH[t])) + sizes.boxH[t])
1311
+ }
1312
+ // A skip whose target row crosses no box on any intermediate rank runs
1313
+ // straight through the diagram into the target's left side; otherwise
1314
+ // the bottom lane. (No chains here: LR back edges must lane, and a
1315
+ // diagram mixing interior skips with laned returns crosses itself.)
1316
+ const edgeStraight = new Array<boolean>(graph.edges.length).fill(false)
1317
+ const clearRow = (e: Edge): boolean =>
1318
+ !stubRows.has(centers[e.to]) &&
1319
+ graph.nodes.every(
1320
+ (_, j) =>
1321
+ ranks[j] <= ranks[e.from] ||
1322
+ ranks[j] >= ranks[e.to] ||
1323
+ Math.abs(centers[j] - centers[e.to]) > half(sizes.boxH[j] + sizes.extraH[j]),
1324
+ )
1325
+ const entryY = graph.edges.map((e) => (isSkip(e) && clearRow(e) ? centers[e.to] : -1))
1326
+ const jogs = chainJogs(graph, ranks, layered, centers, (e, i) =>
1327
+ entryY[i] === -1 ? null : { exit: centers[e.from], entry: entryY[i] },
1328
+ )
1329
+ graph.edges.forEach((e, i) => {
1330
+ if (isSkip(e) && entryY[i] !== -1) edgeStraight[i] = true
1331
+ })
1332
+ const jogTrack = new Map<ChainJog, number>()
1333
+
1334
+ // Left-to-right edge labels sit in the gap after their source's column, so
1335
+ // each gap sizes to the widest label *leaving through it* — one long label
1336
+ // widens its own band, not the whole diagram. Straight skips label there
1337
+ // too; a self-loop's label hangs beside its own box (selfLabelW).
1338
+ const bandLabel = new Array<number>(maxRank + 1).fill(0)
1339
+ graph.edges.forEach((e, i) => {
1340
+ if (e.from === e.to) return
1341
+ if (ranks[e.to] !== ranks[e.from] + 1 && !edgeStraight[i]) return
1342
+ const verb = e.label === null ? 0 : labelCols(e.label, sizes.maxLabel)
1343
+ const cards = [e.cardFrom, e.cardTo]
1344
+ .filter((c) => c !== undefined)
1345
+ .reduce((w, c) => w + stringWidth(c as string) + 1, 0)
1346
+ bandLabel[ranks[e.from]] = Math.max(bandLabel[ranks[e.from]], verb + cards)
1347
+ })
1348
+
1349
+ const edgeBus = new Array<number>(graph.edges.length).fill(0)
1350
+ const busTracks = new Array<number>(maxRank + 1).fill(0)
1351
+ for (let r = 0; r < maxRank; r++) {
1352
+ const spans = busSpans(graph, ranks, centers, r, true)
1353
+ const bandJogs = jogs.filter((j) => j.band === r)
1354
+ spans.push(...bandJogs)
1355
+ if (spans.length === 0) continue
1356
+ const { assigned, count } = assignTracks(spans)
1357
+ for (const [idx, slot] of assigned) edgeBus[idx] = slot
1358
+ for (const j of bandJogs) jogTrack.set(j, edgeBus[j.edge])
1359
+ busTracks[r] = count
1360
+ }
1361
+
1362
+ const rankX = new Array<number>(maxRank + 1).fill(0)
1363
+ for (let r = 1; r <= maxRank; r++) {
1364
+ const gap = Math.max(GAP_X + 1, bandLabel[r - 1] + 3, busTracks[r - 1] + 1)
1365
+ rankX[r] = rankX[r - 1] + colW[r - 1] + gap
1366
+ }
1367
+ const selfTails = byRank[maxRank]
1368
+ .filter((i) => sizes.extraH[i] > 0 && sizes.selfLabelW[i] > 0)
1369
+ .map((i) => 2 + sizes.selfLabelW[i])
1370
+ const canvasW =
1371
+ rankX[maxRank] + colW[maxRank] + (selfTails.length === 0 ? 0 : Math.max(...selfTails))
1372
+ const bandEnd = Array.from({ length: maxRank + 1 }, (_, r) => rankX[r] + colW[r])
1373
+ const skipRoute = skipRoutes(graph, jogs, (j) => bandEnd[j.band] + (jogTrack.get(j) ?? 0))
1374
+
1375
+ let diagramH = 1
1376
+ for (let v = graph.nodes.length; v < centers.length; v++) diagramH = Math.max(diagramH, centers[v] + 1)
1377
+ byRank.forEach((row, r) => {
1378
+ const x = rankX[r]
1379
+ for (const idx of row) {
1380
+ const w = sizes.boxW[idx]
1381
+ const h = sizes.boxH[idx]
1382
+ const cy = centers[idx]
1383
+ const y = sat(cy, half(h + sizes.extraH[idx]))
1384
+ placed[idx] = { x, y, w, h, cx: x + half(w), cy: y + half(h), rank: r }
1385
+ diagramH = Math.max(diagramH, y + h + sizes.extraH[idx])
1386
+ }
1387
+ })
1388
+
1389
+ const edgeLane = new Array<number>(graph.edges.length).fill(0)
1390
+ const lanes = laneSpans(graph, ranks, placed).filter((s) => !edgeStraight[s.edge])
1391
+ let canvasH = diagramH
1392
+ let laneBase = 0
1393
+ if (lanes.length > 0) {
1394
+ const { assigned, count } = packTracks(lanes)
1395
+ for (const [idx, slot] of assigned) edgeLane[idx] = slot
1396
+ canvasH = diagramH + 1 + count
1397
+ laneBase = diagramH + 1
1398
+ }
1399
+
1400
+ const routes = graph.edges.map((edge, i): Route => {
1401
+ const from = placed[edge.from]
1402
+ const to = placed[edge.to]
1403
+ const max = sizes.maxLabel
1404
+ if (edge.from === edge.to) return selfRoute(from, edge, max)
1405
+ if (to.rank === from.rank + 1) return forwardRouteLr(from, to, edge, bandEnd[from.rank] + edgeBus[i], max)
1406
+ if (to.rank > from.rank && edgeStraight[i]) return skipRouteLr(from, to, edge, skipRoute[i], max)
1407
+ return laneRoute(from, to, edge, laneBase + edgeLane[i], max)
1408
+ })
1409
+ return { canvasW, canvasH, routes }
1410
+ }
1411
+
1412
+ // -------------------------------------------------------------------- canvas
1413
+ /**
1414
+ * The geometry of a laid-out graph: canvas size, a box per node, a route
1415
+ * per edge (null for a self loop) and each node's wrapped label lines.
1416
+ * Pure data — `paint` in paint.ts turns it into a canvas, and tests or
1417
+ * metrics can read it without one.
1418
+ */
1419
+ export interface Layout {
1420
+ w: number
1421
+ h: number
1422
+ placed: Placed[]
1423
+ routes: Route[]
1424
+ labels: string[][]
1425
+ }
1426
+
1427
+ /** Rank, order, place and route a graph. Null when it is empty or over the cell cap. */
1428
+ export function layout(graph: Graph, extras: NodeExtra[], limits: Limits): Layout | null {
1429
+ const n = graph.nodes.length
1430
+ if (n === 0) return null
1431
+
1432
+ // Parallel edges ride the same cells, so all labels after the first were
1433
+ // silently lost — join them onto the first instead. Done before sizing so
1434
+ // the joined label gets its room.
1435
+ const firstOf = new Map<string, number>()
1436
+ graph.edges.forEach((e, i) => {
1437
+ if (e.from === e.to) return
1438
+ const key = `${e.from}>${e.to}`
1439
+ const first = firstOf.get(key)
1440
+ if (first === undefined) {
1441
+ firstOf.set(key, i)
1442
+ return
1443
+ }
1444
+ if (e.label !== null) {
1445
+ const head = graph.edges[first].label
1446
+ graph.edges[first].label = head === null ? e.label : `${head} / ${e.label}`
1447
+ e.label = null
1448
+ }
1449
+ })
1450
+
1451
+ const ranks = computeRanks(graph)
1452
+ const maxRank = Math.max(...ranks, 0)
1453
+
1454
+ const byRank: number[][] = Array.from({ length: maxRank + 1 }, () => [])
1455
+ for (let idx = 0; idx < ranks.length; idx++) byRank[ranks[idx]].push(idx)
1456
+ // Top-down routes every edge through the interior. Left-to-right boxes
1457
+ // are three rows tall, leaving no port off the centre row for a return,
1458
+ // so LR back edges go around in a lane below — and skips with them, as a
1459
+ // diagram mixing interior skips with laned returns crosses itself. Lane
1460
+ // endpoints go last within the rank, or whatever the ordering put beyond
1461
+ // them would sit in that corridor and be cut through.
1462
+ const vertical = graph.dir === 'down' || graph.dir === 'up'
1463
+ const interior = (): boolean => vertical
1464
+ const inLane = new Array<boolean>(graph.nodes.length).fill(false)
1465
+ for (const e of graph.edges) {
1466
+ if (e.from !== e.to && ranks[e.to] !== ranks[e.from] + 1 && !vertical) {
1467
+ inLane[e.from] = true
1468
+ inLane[e.to] = true
1469
+ }
1470
+ }
1471
+ const layered = orderRanks(byRank, graph.edges, ranks, interior, inLane)
1472
+
1473
+ const wrapped = graph.nodes.map((node) => wrapLabel(node.label, limits.wrap, limits.lines))
1474
+ const widest = (lines: string[]): number =>
1475
+ Math.max(1, lines.length === 0 ? 1 : Math.max(...lines.map(stringWidth)))
1476
+
1477
+ const boxW = extras.map((extra, i) => {
1478
+ if (extra.kind === 'frame') {
1479
+ return Math.max(extra.sub.w + 2, stringWidth(fitLabel(graph.nodes[i].label, limits.wrap)) + 4)
1480
+ }
1481
+ if (extra.kind === 'compartments') return widest(extra.sections.flat()) + 2 * PAD + 2
1482
+ return widest(wrapped[i]) + 2 * PAD + 2
1483
+ })
1484
+ const boxH = extras.map((extra, i) => {
1485
+ if (extra.kind === 'frame') return extra.sub.h + 2
1486
+ if (extra.kind === 'compartments') {
1487
+ const filled = extra.sections.filter((s) => s.length > 0).length
1488
+ return extra.sections.reduce((s, sec) => s + sec.length, 0) + sat(filled, 1) + 2
1489
+ }
1490
+ return wrapped[i].length + 2
1491
+ })
1492
+
1493
+ // A self-edge needs two rows below its box, and room beside it for a label.
1494
+ const extraH = new Array<number>(n).fill(0)
1495
+ const selfLabelW = new Array<number>(n).fill(0)
1496
+ for (const e of graph.edges) {
1497
+ if (e.from !== e.to) continue
1498
+ extraH[e.from] = 2
1499
+ const text = edgeText(e)
1500
+ if (text !== null) {
1501
+ selfLabelW[e.from] = Math.max(selfLabelW[e.from], labelCols(text, limits.label))
1502
+ }
1503
+ }
1504
+ for (let i = 0; i < n; i++) if (extraH[i] > 0) boxW[i] = Math.max(boxW[i], 7)
1505
+
1506
+ const sizes: NodeSizes = {
1507
+ boxW,
1508
+ boxH,
1509
+ layW: boxW.map((w, i) => w + (selfLabelW[i] > 0 ? 2 * (selfLabelW[i] + 3) : 0)),
1510
+ layH: boxH.map((h, i) => h + extraH[i]),
1511
+ extraH,
1512
+ selfLabelW,
1513
+ maxLabel: limits.label,
1514
+ }
1515
+
1516
+ const placed: Placed[] = Array.from({ length: n }, () => ({
1517
+ x: 0,
1518
+ y: 0,
1519
+ w: 0,
1520
+ h: 0,
1521
+ cx: 0,
1522
+ cy: 0,
1523
+ rank: 0,
1524
+ }))
1525
+
1526
+ const plan = vertical
1527
+ ? placeTd(ranks, maxRank, byRank, layered, sizes, graph, placed)
1528
+ : placeLr(ranks, maxRank, byRank, layered, sizes, graph, placed)
1529
+
1530
+ if (plan.canvasW * plan.canvasH > MAX_CANVAS_CELLS) return null
1531
+ return { w: plan.canvasW, h: plan.canvasH, placed, routes: plan.routes, labels: wrapped }
1532
+ }
1533
+
1534
+ type Jog = { bus: number; at: number }
1535
+ type LabelAt = { row: number; x: number } | null
1536
+
1537
+ /** Route labels with their text fitted to `max` columns, as painted. */
1538
+ const fitted = (labels: Route['labels'], max: number): Route['labels'] =>
1539
+ labels.map((l) => ({ ...l, text: fitLabel(l.text, max) }))
1540
+
1541
+ /**
1542
+ * A self loop: no corners — paint draws the stub below the box — and its
1543
+ * label beside the box on the loop's first row.
1544
+ */
1545
+ function selfRoute(p: Placed, edge: Edge, max: number): Route {
1546
+ const text = edgeText(edge)
1547
+ const labels = text === null ? [] : [{ text: fitLabel(text, max), row: p.y + p.h, x: p.x + p.w + 1 }]
1548
+ return { points: [], labels }
1549
+ }
1550
+
1551
+ /**
1552
+ * Corners of a path that follows its chain's jogs from `start`: each jog
1553
+ * runs along the flow axis to its bus, then across it to where the chain
1554
+ * continues.
1555
+ */
1556
+ function jogPoints(start: [number, number], jogs: Jog[], vertical: boolean): [number, number][] {
1557
+ const points: [number, number][] = [start]
1558
+ let [x, y] = start
1559
+ for (const { bus, at } of jogs) {
1560
+ if (vertical) points.push([x, bus], [at, bus])
1561
+ else points.push([bus, y], [bus, at])
1562
+ ;[x, y] = vertical ? [at, bus] : [bus, at]
1563
+ }
1564
+ return points
1565
+ }
1566
+
1567
+ /**
1568
+ * Adjacent ranks, top-down: out the source's bottom, jog on a bus row,
1569
+ * into the target's top. A jog of one column reads as a kink and snaps
1570
+ * straight. Cardinalities sit at their own ends; the verb takes the row
1571
+ * above the head, falling back beside the target card when the gap has no
1572
+ * spare row.
1573
+ */
1574
+ function forwardRoute(
1575
+ from: Placed,
1576
+ to: Placed,
1577
+ edge: Edge,
1578
+ bus: number,
1579
+ entryX: number,
1580
+ labelLeft: boolean,
1581
+ max: number,
1582
+ ): Route {
1583
+ const tx = entryX === -1 ? to.cx : entryX
1584
+ const bx = Math.abs(from.cx - tx) <= 1 ? tx : from.cx
1585
+ const by = from.y + from.h - 1
1586
+ const headRow = to.y - 1
1587
+ const points: [number, number][] =
1588
+ bx === tx
1589
+ ? [
1590
+ [bx, by],
1591
+ [tx, headRow],
1592
+ ]
1593
+ : [
1594
+ [bx, by],
1595
+ [bx, bus],
1596
+ [tx, bus],
1597
+ [tx, headRow],
1598
+ ]
1599
+ const labels: Route['labels'] = []
1600
+ if (edge.cardFrom === undefined && edge.cardTo === undefined) {
1601
+ if (edge.label !== null) labels.push({ text: edge.label, row: headRow, x: labelStart(tx, edge.label, labelLeft, max) })
1602
+ return { points, labels: fitted(labels, max) }
1603
+ }
1604
+ const srcRow = by + 1
1605
+ if (edge.cardFrom !== undefined) labels.push({ text: edge.cardFrom, row: srcRow, x: bx + 1 })
1606
+ if (edge.cardTo !== undefined) labels.push({ text: edge.cardTo, row: headRow, x: tx + 1 })
1607
+ if (edge.label !== null) {
1608
+ const midRow = headRow - 1
1609
+ if (midRow > srcRow) labels.push({ text: edge.label, row: midRow, x: (midRow > bus ? tx : bx) + 1 })
1610
+ else {
1611
+ const x = tx + 1 + (edge.cardTo === undefined ? 0 : stringWidth(edge.cardTo) + 1)
1612
+ labels.push({ text: edge.label, row: headRow, x })
1613
+ }
1614
+ }
1615
+ return { points, labels: fitted(labels, max) }
1616
+ }
1617
+
1618
+ /** A chain-routed edge's label: beside its chain when it has a spot there, else at the head. */
1619
+ function chainLabel(
1620
+ edge: Edge,
1621
+ headRow: number,
1622
+ entryX: number,
1623
+ labelLeft: boolean,
1624
+ labelAt: LabelAt,
1625
+ max: number,
1626
+ ): Route['labels'] {
1627
+ const text = edgeText(edge)
1628
+ if (text === null) return []
1629
+ const at = labelAt ?? { row: headRow, x: labelStart(entryX, text, labelLeft, max) }
1630
+ return [{ text: fitLabel(text, max), ...at }]
1631
+ }
1632
+
1633
+ /**
1634
+ * Back edge, top-down: up out of the source's top, along the column its
1635
+ * virtual chain reserved (jogging on a bus row wherever it steps), arrow
1636
+ * into the target's bottom. Adjacent returns have no chain and jog once.
1637
+ */
1638
+ function backChainRoute(
1639
+ from: Placed,
1640
+ to: Placed,
1641
+ edge: Edge,
1642
+ exitX: number,
1643
+ entryX: number,
1644
+ jogs: Jog[],
1645
+ labelLeft: boolean,
1646
+ labelAt: LabelAt,
1647
+ max: number,
1648
+ ): Route {
1649
+ const headRow = to.y + to.h
1650
+ const points = jogPoints([exitX, from.y], jogs, true)
1651
+ points.push([entryX, headRow])
1652
+ return { points, labels: chainLabel(edge, headRow, entryX, labelLeft, labelAt, max) }
1653
+ }
1654
+
1655
+ /**
1656
+ * Forward skip edge, top-down: out the source's *bottom*, then down the
1657
+ * column its virtual chain reserved, jogging along a bus row wherever the
1658
+ * chain steps sideways (the first jog shares the source's fan row — one `┴`
1659
+ * origin split; the last lands on the entry column) into the target's *top*.
1660
+ */
1661
+ function chainRoute(
1662
+ from: Placed,
1663
+ to: Placed,
1664
+ edge: Edge,
1665
+ entryX: number,
1666
+ jogs: Jog[],
1667
+ labelLeft: boolean,
1668
+ labelAt: LabelAt,
1669
+ max: number,
1670
+ ): Route {
1671
+ const headRow = to.y - 1
1672
+ const points = jogPoints([from.cx, from.y + from.h - 1], jogs, true)
1673
+ points.push([entryX, headRow])
1674
+ return { points, labels: chainLabel(edge, headRow, entryX, labelLeft, labelAt, max) }
1675
+ }
1676
+
1677
+ /**
1678
+ * Adjacent ranks, left-to-right: out the right side, jog on the bus
1679
+ * column. The verb keeps its usual spot above the line; cardinalities hug
1680
+ * their own ends on the rows above the departure and arrival cells.
1681
+ */
1682
+ function forwardRouteLr(from: Placed, to: Placed, edge: Edge, bus: number, max: number): Route {
1683
+ const rx = from.x + from.w - 1
1684
+ const ry = from.cy
1685
+ const ly = to.cy
1686
+ const headCol = to.x - 1
1687
+ const points: [number, number][] =
1688
+ ry === ly
1689
+ ? [
1690
+ [rx, ry],
1691
+ [headCol, ly],
1692
+ ]
1693
+ : [
1694
+ [rx, ry],
1695
+ [bus, ry],
1696
+ [bus, ly],
1697
+ [headCol, ly],
1698
+ ]
1699
+ const labels: Route['labels'] = []
1700
+ if (edge.label !== null) labels.push({ text: edge.label, row: sat(ly, 1), x: bus + 1 })
1701
+ if (edge.cardFrom !== undefined) labels.push({ text: edge.cardFrom, row: sat(ry, 1), x: rx + 1 })
1702
+ if (edge.cardTo !== undefined) {
1703
+ labels.push({ text: edge.cardTo, row: sat(ly, 1), x: sat(headCol, stringWidth(edge.cardTo)) })
1704
+ }
1705
+ return { points, labels: fitted(labels, max) }
1706
+ }
1707
+
1708
+ /**
1709
+ * Forward skip, left-to-right: out the source's right side, along the row
1710
+ * its virtual chain reserved, jogging on a bus column wherever the chain
1711
+ * steps (the first jog shares the source's fan column), into the target's
1712
+ * left side on its centre row. Label after the first jog, where forward
1713
+ * labels sit — the gap before the target belongs to the arrivals that end
1714
+ * there.
1715
+ */
1716
+ function skipRouteLr(from: Placed, to: Placed, edge: Edge, jogs: Jog[], max: number): Route {
1717
+ const rx = from.x + from.w - 1
1718
+ const ry = from.cy
1719
+ const points = jogPoints([rx, ry], jogs, false)
1720
+ points.push([to.x - 1, to.cy])
1721
+ const text = edgeText(edge)
1722
+ const labels: Route['labels'] = []
1723
+ if (text !== null) labels.push({ text, row: sat(jogs[0]?.at ?? to.cy, 1), x: (jogs[0]?.bus ?? rx) + 1 })
1724
+ return { points, labels: fitted(labels, max) }
1725
+ }
1726
+
1727
+ /**
1728
+ * Skip or back edge, left-to-right: down out the bottom, along a lane,
1729
+ * back up. The label interrupts its own lane row — the row above belongs
1730
+ * to the neighbouring lane once several stack — and waits until every
1731
+ * route landed so it can dodge the verticals that cross this row.
1732
+ */
1733
+ function laneRoute(from: Placed, to: Placed, edge: Edge, laneY: number, max: number): Route {
1734
+ const sx = from.cx
1735
+ const sy = from.y + from.h - 1
1736
+ const tx = to.cx
1737
+ const points: [number, number][] = [
1738
+ [sx, sy],
1739
+ [sx, laneY],
1740
+ [tx, laneY],
1741
+ [tx, to.y + to.h],
1742
+ ]
1743
+ const text = edgeText(edge)
1744
+ const laneLabel =
1745
+ text === null
1746
+ ? undefined
1747
+ : { text: ` ${fitLabel(text, max)} `, y: laneY, lo: Math.min(sx, tx), hi: Math.max(sx, tx) }
1748
+ return { points, labels: [], laneLabel }
1749
+ }