@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.
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/index.ts +1 -0
- package/package.json +57 -0
- package/src/index.ts +103 -0
- package/src/loom-mermaid/ansi.ts +65 -0
- package/src/loom-mermaid/canvas.ts +466 -0
- package/src/loom-mermaid/class-style.ts +65 -0
- package/src/loom-mermaid/css-colors.ts +153 -0
- package/src/loom-mermaid/diagrams/class.ts +297 -0
- package/src/loom-mermaid/diagrams/er.ts +189 -0
- package/src/loom-mermaid/diagrams/flowchart.ts +519 -0
- package/src/loom-mermaid/diagrams/gitgraph.ts +230 -0
- package/src/loom-mermaid/diagrams/mindmap.ts +107 -0
- package/src/loom-mermaid/diagrams/pie.ts +121 -0
- package/src/loom-mermaid/diagrams/sequence.ts +305 -0
- package/src/loom-mermaid/diagrams/state.ts +279 -0
- package/src/loom-mermaid/diagrams/timeline.ts +117 -0
- package/src/loom-mermaid/graph-render.ts +195 -0
- package/src/loom-mermaid/graph.ts +205 -0
- package/src/loom-mermaid/index.ts +78 -0
- package/src/loom-mermaid/labels.ts +353 -0
- package/src/loom-mermaid/layout-seq.ts +234 -0
- package/src/loom-mermaid/layout.ts +1749 -0
- package/src/loom-mermaid/paint.ts +325 -0
- package/src/loom-mermaid/placement.ts +255 -0
- package/src/loom-mermaid/registry.ts +88 -0
- package/src/loom-mermaid/source-box.ts +111 -0
- package/src/loom-mermaid/statements.ts +228 -0
- package/src/loom-mermaid/types.ts +64 -0
- package/src/loom-mermaid/width-data.ts +993 -0
- package/src/loom-mermaid/width.ts +69 -0
|
@@ -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
|
+
}
|