@zombie-mermaid/svg-renderer 3.2.0 → 4.0.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.
Files changed (45) hide show
  1. package/dist/index.cjs +45 -41
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +34 -4
  4. package/dist/index.d.ts +34 -4
  5. package/dist/index.js +2310 -1115
  6. package/dist/index.js.map +1 -1
  7. package/package.json +3 -3
  8. package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
  9. package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
  10. package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
  11. package/src/__tests__/c4-text-width-1210.test.ts +43 -0
  12. package/src/__tests__/class-component-order-1249.test.ts +40 -0
  13. package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
  14. package/src/__tests__/class-namespace-1196.test.ts +165 -0
  15. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
  16. package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
  17. package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
  18. package/src/__tests__/er-default-direction-1250.test.ts +69 -0
  19. package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
  20. package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
  21. package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
  22. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
  23. package/src/c4/arial-widths.ts +59 -0
  24. package/src/c4/layout.ts +234 -44
  25. package/src/c4/metrics.ts +56 -30
  26. package/src/c4/renderer.ts +1 -1
  27. package/src/class/layout.ts +178 -13
  28. package/src/class/renderer.ts +31 -2
  29. package/src/er/layout.ts +20 -4
  30. package/src/er/renderer.ts +10 -1
  31. package/src/layout-engine/back-edges.ts +73 -0
  32. package/src/layout-engine/compound-flat.ts +916 -0
  33. package/src/layout-engine/elk-graph-builder.ts +5 -4
  34. package/src/layout-engine/from-elk.ts +7 -2
  35. package/src/layout-engine/inner-edges.ts +329 -0
  36. package/src/layout-engine/layout-hints.ts +27 -0
  37. package/src/layout-engine/to-elk.ts +170 -20
  38. package/src/layout-engine.ts +38 -1
  39. package/src/registry.ts +2 -2
  40. package/src/renderer.ts +26 -2
  41. package/src/sequence/layout.ts +72 -4
  42. package/src/sequence/renderer.ts +155 -15
  43. package/src/title-gaps.ts +146 -0
  44. package/src/xychart/layout.ts +31 -2
  45. package/src/xychart/renderer.ts +1 -0
@@ -0,0 +1,916 @@
1
+ /**
2
+ * Layout for a flowchart with subgraphs, arranged the way mermaid.js arranges it.
3
+ *
4
+ * ELK lays a subgraph's contents out as a block before the nodes around it, so
5
+ * a node outside the subgraph can never sit beside a node deep inside it, and
6
+ * an edge from outside can't pull a member down to a later layer. mermaid.js
7
+ * (dagre) lays the whole graph out flat, ranks every node on its own, and draws
8
+ * each subgraph as a box around its members, with the nodes that don't belong
9
+ * to it placed beside the box. In the CI/CD sample, `Deploy Staging`,
10
+ * `QA Approved?` and `Production` form a column to the left of the pipeline's
11
+ * box, and `Fix & Retry` sits at the bottom of the box, level with `Production`.
12
+ *
13
+ * This does the same with ELK:
14
+ *
15
+ * 1. Lay the graph out flat (no subgraphs) to learn the layers and the order of
16
+ * the nodes within them.
17
+ * 2. Keep each subgraph's box clear of the nodes beside it. In a layer between
18
+ * a subgraph's first and last member where it has no member, add an
19
+ * invisible *spine* node, chained to the subgraph's members in the layers
20
+ * on either side. It stands for the box in that layer, so ELK keeps the
21
+ * other nodes of the layer out of the box's way.
22
+ * 3. Give ELK the nodes in a left-to-right order that keeps each subgraph's
23
+ * nodes together, with each outsider on the side of the subgraph its
24
+ * connections already put it on, and lay it out again.
25
+ * 4. Draw each subgraph's box around its members. If a box would still cover a
26
+ * node that isn't a member, widen that subgraph's spine nodes (they start
27
+ * narrow) and lay out again.
28
+ *
29
+ * The spine nodes and their edges are layout scaffolding: they are dropped from
30
+ * the result. If the boxes can't be made to clear the other nodes, `undefined`
31
+ * is returned and the caller uses the nested layout instead.
32
+ */
33
+
34
+ import type {
35
+ Direction,
36
+ MermaidEdge,
37
+ MermaidGraph,
38
+ MermaidNode,
39
+ MermaidSubgraph,
40
+ PositionedEdge,
41
+ Point,
42
+ PositionedGraph,
43
+ PositionedGroup,
44
+ PositionedNode,
45
+ } from '@zombie-mermaid/core'
46
+ import { measureMultilineText } from '@zombie-mermaid/core'
47
+ import type { FontSizes } from '../styles.ts'
48
+ import { ARROW_HEAD, FONT_WEIGHTS } from '../styles.ts'
49
+ import { DEFAULTS } from './constants.ts'
50
+ import { resolveEdgeStyle } from './from-elk.ts'
51
+ import { labelSpot, routeInnerEdge } from './inner-edges.ts'
52
+ import type { LayoutHints } from './layout-hints.ts'
53
+ import {
54
+ SUBGRAPH_PADDING,
55
+ hasAnyDirectionOverride,
56
+ isStateGraph,
57
+ } from './to-elk.ts'
58
+
59
+ /** Lays a graph out, with optional hints; the engine's `layoutGraphSync` bound to its options. */
60
+ export type LayoutFn = (
61
+ graph: MermaidGraph,
62
+ hints?: LayoutHints,
63
+ ) => PositionedGraph
64
+
65
+ /**
66
+ * Room two boxes (or a box and a node) that are too close ask for: `amount` px
67
+ * more along `axis`, opened at `after`, where the first of them ends.
68
+ */
69
+ interface RoomRequest {
70
+ axis: 'x' | 'y'
71
+ amount: number
72
+ after: number
73
+ }
74
+
75
+ /** Prefix of the ids of the spine nodes, which no real node may share. */
76
+ const SPINE_PREFIX = '__zm_spine__'
77
+
78
+ /** How narrow a spine node starts, in px; it is widened only as far as needed. */
79
+ const SPINE_MIN_WIDTH = 8
80
+
81
+ /** How many times the spine nodes are widened before giving up. */
82
+ const MAX_WIDENINGS = 6
83
+
84
+ /** How many times room is opened between boxes that sit too close. */
85
+ const MAX_ROOM_STEPS = 12
86
+
87
+ /** Slack added to the room two boxes ask for, in px. */
88
+ const SPACING_SLACK = 4
89
+
90
+ /** Slack added each time a spine node is widened, in px. */
91
+ const WIDEN_SLACK = 4
92
+
93
+ /** Px a neighbouring node moves per px a spine node is widened, until measured. */
94
+ const ASSUMED_SLOPE = 0.25
95
+
96
+ /** A measured slope below this is noise, and the assumed one is used. */
97
+ const MIN_SLOPE = 0.02
98
+
99
+ /** Space kept between a box and the nodes and boxes around it, in px. */
100
+ const BOX_CLEARANCE = 40
101
+
102
+ /** Left and right room the title text needs inside its box, in px. */
103
+ const TITLE_INSET = 12
104
+
105
+ type Box = { x: number; y: number; width: number; height: number }
106
+
107
+ const isVertical = (direction: Direction): boolean =>
108
+ direction === 'TD' || direction === 'TB' || direction === 'BT'
109
+
110
+ const isSpine = (id: string): boolean => id.startsWith(SPINE_PREFIX)
111
+
112
+ function flatten(subgraphs: MermaidSubgraph[]): MermaidSubgraph[] {
113
+ return subgraphs.flatMap((sg) => [sg, ...flatten(sg.children)])
114
+ }
115
+
116
+ /** Every node id in `sg`, including those of its nested subgraphs. */
117
+ function membersOf(sg: MermaidSubgraph): Set<string> {
118
+ const out = new Set<string>(sg.nodeIds)
119
+ for (const child of sg.children) {
120
+ for (const id of membersOf(child)) out.add(id)
121
+ }
122
+ return out
123
+ }
124
+
125
+ // ============================================================================
126
+ // Whether to use it
127
+ // ============================================================================
128
+
129
+ /**
130
+ * Whether `graph` can be laid out this way: it has subgraphs, and nothing in it
131
+ * needs the nested layout. A subgraph with its own direction is laid out as its
132
+ * own graph, which this doesn't do; state diagrams have their own handling of
133
+ * composite states; an edge to a subgraph (not a node) has no node to attach
134
+ * to, and the parser gives it a node with the subgraph's id; and a subgraph
135
+ * with no node in it has nothing to draw a box around.
136
+ */
137
+ export function canLayOutFlat(graph: MermaidGraph): boolean {
138
+ if (graph.subgraphs.length === 0) return false
139
+ if (isStateGraph(graph) || hasAnyDirectionOverride(graph.subgraphs)) {
140
+ return false
141
+ }
142
+ for (const id of graph.nodes.keys()) if (isSpine(id)) return false
143
+ for (const edge of graph.edges) {
144
+ if (!graph.nodes.has(edge.source) || !graph.nodes.has(edge.target)) {
145
+ return false
146
+ }
147
+ }
148
+ const clusters = flatten(graph.subgraphs)
149
+ // An edge to a subgraph comes out of the parser as an edge to a node that
150
+ // has the subgraph's id; the nested layout points it at the subgraph's box.
151
+ if (clusters.some((sg) => graph.nodes.has(sg.id))) return false
152
+ return clusters.every((sg) =>
153
+ [...membersOf(sg)].some((id) => graph.nodes.has(id)),
154
+ )
155
+ }
156
+
157
+ // ============================================================================
158
+ // Layers
159
+ // ============================================================================
160
+
161
+ /**
162
+ * The layer of each node of a laid-out graph, counted from the start of the
163
+ * flow. The nodes of one layer overlap along the flow axis (their centres or
164
+ * their start edges line up, depending on the direction), and the next layer
165
+ * starts after the shortest of them ends.
166
+ */
167
+ export function layerIndexes(
168
+ nodes: ReadonlyArray<Box & { id: string }>,
169
+ direction: Direction,
170
+ ): Map<string, number> {
171
+ const vertical = isVertical(direction)
172
+ const span = (n: Box): [number, number] =>
173
+ vertical ? [n.y, n.y + n.height] : [n.x, n.x + n.width]
174
+ const sorted = [...nodes].sort((a, b) => {
175
+ const [a0, a1] = span(a)
176
+ const [b0, b1] = span(b)
177
+ return (a0 + a1) / 2 - (b0 + b1) / 2
178
+ })
179
+ const layers: Array<{ end: number; ids: string[] }> = []
180
+ for (const node of sorted) {
181
+ const [start, end] = span(node)
182
+ const current = layers[layers.length - 1]
183
+ if (current && start < current.end) {
184
+ current.end = Math.min(current.end, end)
185
+ current.ids.push(node.id)
186
+ } else {
187
+ layers.push({ end, ids: [node.id] })
188
+ }
189
+ }
190
+ if (direction === 'BT' || direction === 'RL') layers.reverse()
191
+ const out = new Map<string, number>()
192
+ layers.forEach((layer, i) => layer.ids.forEach((id) => out.set(id, i)))
193
+ return out
194
+ }
195
+
196
+ // ============================================================================
197
+ // Spine nodes
198
+ // ============================================================================
199
+
200
+ export interface SpinePlan {
201
+ /** Spine node id and the subgraph it stands for. */
202
+ ghosts: Array<{ id: string; subgraph: string }>
203
+ /** The invisible edges chaining each subgraph's members and spine nodes by layer. */
204
+ edges: MermaidEdge[]
205
+ }
206
+
207
+ /**
208
+ * The spine nodes and the edges that chain them. For each subgraph, one node
209
+ * per layer between its first and last member where it has no member, and an
210
+ * edge from one representative per layer to the next, where either is a spine
211
+ * node. The representative is the subgraph's leftmost member in that layer, or
212
+ * its spine node. `crossOf` is a
213
+ * node's position across the flow, in the first layout.
214
+ */
215
+ export function planSpines(
216
+ clusters: MermaidSubgraph[],
217
+ layer: ReadonlyMap<string, number>,
218
+ crossOf: (id: string) => number,
219
+ ): SpinePlan {
220
+ const plan: SpinePlan = { ghosts: [], edges: [] }
221
+ for (const sg of clusters) {
222
+ const real = [...membersOf(sg)].filter((id) => layer.has(id))
223
+ if (real.length === 0) continue
224
+ const layers = real.map((id) => layer.get(id)!)
225
+ const first = Math.min(...layers)
226
+ const last = Math.max(...layers)
227
+ const representative = new Map<number, string>()
228
+ for (let l = first; l <= last; l++) {
229
+ const here = real
230
+ .filter((id) => layer.get(id) === l)
231
+ .sort((a, b) => crossOf(a) - crossOf(b))
232
+ if (here.length > 0) {
233
+ representative.set(l, here[0]!)
234
+ } else {
235
+ const id = `${SPINE_PREFIX}${sg.id}__${l}`
236
+ plan.ghosts.push({ id, subgraph: sg.id })
237
+ representative.set(l, id)
238
+ }
239
+ }
240
+ for (let l = first; l < last; l++) {
241
+ const source = representative.get(l)!
242
+ const target = representative.get(l + 1)!
243
+ // Two real members in neighbouring layers need no chain: the layers they
244
+ // are in already keep them together.
245
+ if (!isSpine(source) && !isSpine(target)) continue
246
+ plan.edges.push({
247
+ source,
248
+ target,
249
+ style: 'invisible',
250
+ hasArrowStart: false,
251
+ hasArrowEnd: false,
252
+ })
253
+ }
254
+ }
255
+ return plan
256
+ }
257
+
258
+ // ============================================================================
259
+ // Order
260
+ // ============================================================================
261
+
262
+ /**
263
+ * Put sibling subgraphs that sit side by side in the order mermaid.js draws
264
+ * them: the reverse of their declaration order (mermaid.js walks its list of
265
+ * subgraphs backwards, so `subgraph a` then `subgraph b` come out with `b` on
266
+ * the left; see #444 and the same reversal in `mermaidToElk`). ELK chooses the
267
+ * order of two independent subgraphs by their edges, so the first layout may
268
+ * have them the other way round. The subgraphs of a group swap places: each
269
+ * takes the position of whichever came to occupy its slot, by moving the keys of
270
+ * its nodes (`keys`, positions across the flow, is changed in place). Only
271
+ * subgraphs whose layers overlap are side by side; the rest are left alone.
272
+ */
273
+ export function orderSiblings(
274
+ subgraphs: readonly MermaidSubgraph[],
275
+ layer: ReadonlyMap<string, number>,
276
+ keys: Map<string, number>,
277
+ connected: (a: MermaidSubgraph, b: MermaidSubgraph) => boolean = () => false,
278
+ ): void {
279
+ const span = (sg: MermaidSubgraph): [number, number] | undefined => {
280
+ const layers = [...membersOf(sg)]
281
+ .filter((id) => layer.has(id))
282
+ .map((id) => layer.get(id)!)
283
+ return layers.length > 0
284
+ ? [Math.min(...layers), Math.max(...layers)]
285
+ : undefined
286
+ }
287
+ const mean = (sg: MermaidSubgraph): number => {
288
+ const at = [...membersOf(sg)]
289
+ .filter((id) => keys.has(id))
290
+ .map((id) => keys.get(id)!)
291
+ return at.reduce((a, b) => a + b, 0) / at.length
292
+ }
293
+ // Group the siblings whose layers overlap, directly or through one another.
294
+ const spans = subgraphs
295
+ .map((sg) => ({ sg, at: span(sg) }))
296
+ .filter((e): e is { sg: MermaidSubgraph; at: [number, number] } => !!e.at)
297
+ .sort((a, b) => a.at[0] - b.at[0])
298
+ const groups: MermaidSubgraph[][] = []
299
+ let reach = -Infinity
300
+ for (const { sg, at } of spans) {
301
+ const current = groups[groups.length - 1]
302
+ if (current && at[0] <= reach) {
303
+ current.push(sg)
304
+ reach = Math.max(reach, at[1])
305
+ } else {
306
+ groups.push([sg])
307
+ reach = at[1]
308
+ }
309
+ }
310
+ for (const group of groups) {
311
+ if (group.length < 2) continue
312
+ // Subgraphs with edges running both ways between them sit where the layout
313
+ // put them, as in mermaid.js; the rest take the reversed order.
314
+ if (group.some((a, i) => group.slice(i + 1).some((b) => connected(a, b)))) {
315
+ continue
316
+ }
317
+ // Declaration order, last to first, is left to right.
318
+ const want = [...group].sort(
319
+ (a, b) => subgraphs.indexOf(b) - subgraphs.indexOf(a),
320
+ )
321
+ const slots = group.map(mean).sort((a, b) => a - b)
322
+ const moves = want.map((sg, i) => slots[i]! - mean(sg))
323
+ want.forEach((sg, i) => {
324
+ for (const id of membersOf(sg)) {
325
+ if (keys.has(id)) keys.set(id, keys.get(id)! + moves[i]!)
326
+ }
327
+ })
328
+ }
329
+ for (const sg of subgraphs) {
330
+ orderSiblings(sg.children, layer, keys, connected)
331
+ }
332
+ }
333
+
334
+ /**
335
+ * The order to give ELK the nodes in, which becomes the left-to-right order
336
+ * within each layer. Starts from where the first layout put each node across
337
+ * the flow, then moves every node that is not in a subgraph but falls within
338
+ * its span of layers to whichever side of the subgraph it was already nearer,
339
+ * so the subgraph's own nodes stay together. `keys` holds each node's position
340
+ * across the flow (spine nodes included); `original` breaks ties.
341
+ */
342
+ export function orderNodes(
343
+ original: readonly string[],
344
+ keys: Map<string, number>,
345
+ layer: ReadonlyMap<string, number>,
346
+ clusters: MermaidSubgraph[],
347
+ spineNodes: ReadonlyMap<string, string[]>,
348
+ ): string[] {
349
+ const key = new Map(keys)
350
+ // Outer subgraphs first, so an inner one's adjustment is applied last.
351
+ for (const sg of clusters) {
352
+ const inside = membersOf(sg)
353
+ for (const id of spineNodes.get(sg.id) ?? []) inside.add(id)
354
+ const members = [...inside].filter((id) => key.has(id))
355
+ const withLayer = members.filter((id) => layer.has(id))
356
+ if (withLayer.length === 0) continue
357
+ const first = Math.min(...withLayer.map((id) => layer.get(id)!))
358
+ const last = Math.max(...withLayer.map((id) => layer.get(id)!))
359
+ const positions = members.map((id) => key.get(id)!)
360
+ const left = Math.min(...positions)
361
+ const right = Math.max(...positions)
362
+ const centre = (left + right) / 2
363
+ let step = 0
364
+ for (const id of original) {
365
+ if (inside.has(id) || isSpine(id)) continue
366
+ const l = layer.get(id)
367
+ if (l === undefined || l < first || l > last) continue
368
+ step++
369
+ const at = key.get(id)!
370
+ // The tiny per-step offset keeps the nodes pushed to one side in their
371
+ // original relative order.
372
+ key.set(
373
+ id,
374
+ at <= centre
375
+ ? Math.min(at, left) - 1 - step * 1e-3
376
+ : Math.max(at, right) + 1 + step * 1e-3,
377
+ )
378
+ }
379
+ }
380
+ const index = new Map(original.map((id, i) => [id, i]))
381
+ return [...original].sort(
382
+ (a, b) => key.get(a)! - key.get(b)! || index.get(a)! - index.get(b)!,
383
+ )
384
+ }
385
+
386
+ // ============================================================================
387
+ // Boxes
388
+ // ============================================================================
389
+
390
+ /** Whether `l` is between the first and last of `layers`. */
391
+ const withinSpan = (layers: readonly number[], l: number): boolean =>
392
+ l >= Math.min(...layers) && l <= Math.max(...layers)
393
+
394
+ /** The box of every subgraph, nested, drawn around the real nodes in `nodes`. */
395
+ function buildGroups(
396
+ subgraphs: MermaidSubgraph[],
397
+ nodes: ReadonlyMap<string, PositionedNode>,
398
+ fontSizes: FontSizes,
399
+ ): PositionedGroup[] {
400
+ const build = (sg: MermaidSubgraph): PositionedGroup => {
401
+ const children = sg.children.map(build)
402
+ const boxes: Box[] = [
403
+ ...[...membersOf(sg)].flatMap((id) => {
404
+ const n = nodes.get(id)
405
+ return n ? [n] : []
406
+ }),
407
+ ...children,
408
+ ]
409
+ const x0 = Math.min(...boxes.map((b) => b.x)) - SUBGRAPH_PADDING.left
410
+ const y0 = Math.min(...boxes.map((b) => b.y)) - SUBGRAPH_PADDING.top
411
+ const x1 =
412
+ Math.max(...boxes.map((b) => b.x + b.width)) + SUBGRAPH_PADDING.right
413
+ const y1 =
414
+ Math.max(...boxes.map((b) => b.y + b.height)) + SUBGRAPH_PADDING.bottom
415
+ // The title has to fit inside the box.
416
+ const titleWidth =
417
+ measureMultilineText(
418
+ sg.label,
419
+ fontSizes.groupHeader,
420
+ FONT_WEIGHTS.groupHeader,
421
+ ).width +
422
+ 2 * TITLE_INSET
423
+ return {
424
+ id: sg.id,
425
+ label: sg.label,
426
+ x: x0,
427
+ y: y0,
428
+ width: Math.max(x1 - x0, titleWidth),
429
+ height: y1 - y0,
430
+ children,
431
+ }
432
+ }
433
+ return subgraphs.map(build)
434
+ }
435
+
436
+ const flattenGroups = (groups: PositionedGroup[]): PositionedGroup[] =>
437
+ groups.flatMap((g) => [g, ...flattenGroups(g.children)])
438
+
439
+ /**
440
+ * How far each subgraph's box reaches into a node that isn't a member, across
441
+ * the flow, for the subgraphs that do. A node in a layer where the subgraph has
442
+ * no member is pushed away by the subgraph's spine nodes there; if the subgraph
443
+ * has none, `stuck` is set. Anything else, a node beside a member, a node
444
+ * before or after the subgraph's layers, or two boxes that overlap each other,
445
+ * is a matter of spacing: `room` is how much more space between nodes (across
446
+ * the flow) or between layers (along it) would separate them, taking whichever
447
+ * is less for each pair.
448
+ */
449
+ function overlaps(
450
+ groups: PositionedGroup[],
451
+ subgraphs: MermaidSubgraph[],
452
+ nodes: ReadonlyMap<string, PositionedNode>,
453
+ direction: Direction,
454
+ hasSpine: ReadonlySet<string>,
455
+ layer: ReadonlyMap<string, number>,
456
+ ): {
457
+ penetration: Map<string, number>
458
+ stuck: boolean
459
+ requests: RoomRequest[]
460
+ } {
461
+ const vertical = isVertical(direction)
462
+ const members = new Map(
463
+ flatten(subgraphs).map((sg) => [sg.id, membersOf(sg)]),
464
+ )
465
+ const penetration = new Map<string, number>()
466
+ let stuck = false
467
+ const requests: RoomRequest[] = []
468
+ const cross = (b: Box): [number, number] =>
469
+ vertical ? [b.x, b.x + b.width] : [b.y, b.y + b.height]
470
+ const along = (b: Box): [number, number] =>
471
+ vertical ? [b.y, b.y + b.height] : [b.x, b.x + b.width]
472
+ const overlap = (
473
+ [a0, a1]: [number, number],
474
+ [b0, b1]: [number, number],
475
+ ): number => Math.max(0, Math.min(a1, b1) - Math.max(a0, b0))
476
+ /** A box with the clearance added on every side. */
477
+ const grown = (b: Box): Box => ({
478
+ x: b.x - BOX_CLEARANCE,
479
+ y: b.y - BOX_CLEARANCE,
480
+ width: b.width + 2 * BOX_CLEARANCE,
481
+ height: b.height + 2 * BOX_CLEARANCE,
482
+ })
483
+ /** How far `a` reaches into `b` across the flow, if they overlap along it. */
484
+ const hit = (a: Box, b: Box): number =>
485
+ overlap(along(a), along(b)) > 0 ? overlap(cross(a), cross(b)) : 0
486
+ const all = flattenGroups(groups)
487
+ const wide = new Map(all.map((g) => [g, grown(g)]))
488
+ /**
489
+ * Ask for the room that separates `a` from `b`, along or across, whichever is
490
+ * less. `a` and `b` are the boxes as drawn (the clearance is in the overlap
491
+ * measured here, not in them); whichever comes first gets the room opened
492
+ * after it, see `openRoom`.
493
+ */
494
+ const needRoom = (a: Box, b: Box, aDrawn: Box, bDrawn: Box): void => {
495
+ const acrossBy = overlap(cross(a), cross(b))
496
+ const alongBy = overlap(along(a), along(b))
497
+ if (acrossBy <= 0 || alongBy <= 0) return
498
+ const isAcross = acrossBy <= alongBy
499
+ // Final coordinates: x across a vertical flow, y along it.
500
+ const useX = isAcross === vertical
501
+ const near = (box: Box): number => (useX ? box.x : box.y)
502
+ const far = (box: Box): number =>
503
+ useX ? box.x + box.width : box.y + box.height
504
+ // The one centred first comes first (a box can start above a node it
505
+ // overlaps and still end before it).
506
+ const centre = (box: Box): number => (near(box) + far(box)) / 2
507
+ const first = centre(aDrawn) <= centre(bDrawn) ? aDrawn : bDrawn
508
+ requests.push({
509
+ axis: useX ? 'x' : 'y',
510
+ amount: isAcross ? acrossBy : alongBy,
511
+ after: far(first),
512
+ })
513
+ }
514
+ for (const group of all) {
515
+ const inside = members.get(group.id)!
516
+ const layers = [...inside]
517
+ .filter((id) => layer.has(id))
518
+ .map((id) => layer.get(id)!)
519
+ const memberLayers = new Set(layers)
520
+ for (const [id, node] of nodes) {
521
+ if (inside.has(id)) continue
522
+ // Beside a member, or before or after the subgraph's layers.
523
+ if (
524
+ memberLayers.has(layer.get(id)!) ||
525
+ !withinSpan(layers, layer.get(id)!)
526
+ ) {
527
+ needRoom(wide.get(group)!, node, group, node)
528
+ continue
529
+ }
530
+ const pen = hit(wide.get(group)!, node)
531
+ if (pen <= 0) continue
532
+ if (hasSpine.has(group.id)) {
533
+ penetration.set(group.id, Math.max(penetration.get(group.id) ?? 0, pen))
534
+ } else {
535
+ stuck = true
536
+ }
537
+ }
538
+ }
539
+ // Two boxes that overlap and don't contain one another.
540
+ const contains = (outer: PositionedGroup, inner: PositionedGroup): boolean =>
541
+ flattenGroups(outer.children).includes(inner)
542
+ for (let i = 0; i < all.length; i++) {
543
+ for (let j = i + 1; j < all.length; j++) {
544
+ const a = all[i]!
545
+ const b = all[j]!
546
+ if (contains(a, b) || contains(b, a)) continue
547
+ needRoom(wide.get(a)!, b, a, b)
548
+ }
549
+ }
550
+ return { penetration, stuck, requests }
551
+ }
552
+
553
+ // ============================================================================
554
+ // Layout
555
+ // ============================================================================
556
+
557
+ /**
558
+ * Lay out `graph` with its subgraphs arranged as described at the top of this
559
+ * file. `layout` lays out a graph (with the hints it is given). Returns
560
+ * `undefined` if the boxes can't be made to clear the other nodes.
561
+ */
562
+ export function layoutCompoundFlat(
563
+ graph: MermaidGraph,
564
+ layout: LayoutFn,
565
+ fontSizes: FontSizes,
566
+ ): PositionedGraph | undefined {
567
+ const realOrder = [...graph.nodes.keys()]
568
+ const flat: MermaidGraph = { ...graph, subgraphs: [] }
569
+ const first = layout(flat)
570
+ const layer = layerIndexes(first.nodes, graph.direction)
571
+ const vertical = isVertical(graph.direction)
572
+ const firstNode = new Map(first.nodes.map((n) => [n.id, n]))
573
+ const crossOf = (id: string): number => {
574
+ const n = firstNode.get(id)!
575
+ return vertical ? n.x : n.y
576
+ }
577
+
578
+ const clusters = flatten(graph.subgraphs)
579
+ const plan = planSpines(clusters, layer, crossOf)
580
+ const spineNodes = new Map<string, string[]>()
581
+ for (const g of plan.ghosts) {
582
+ spineNodes.set(g.subgraph, [...(spineNodes.get(g.subgraph) ?? []), g.id])
583
+ }
584
+
585
+ // Where each node sits across the flow, for the order: the first layout's
586
+ // position, and for a spine node the middle of its subgraph's members.
587
+ const keys = new Map<string, number>(
588
+ first.nodes.map((n) => [n.id, crossOf(n.id)]),
589
+ )
590
+ const joins = (a: MermaidSubgraph, b: MermaidSubgraph): boolean => {
591
+ const inA = membersOf(a)
592
+ const inB = membersOf(b)
593
+ const run = (from: Set<string>, to: Set<string>): boolean =>
594
+ graph.edges.some((e) => from.has(e.source) && to.has(e.target))
595
+ return run(inA, inB) && run(inB, inA)
596
+ }
597
+ orderSiblings(graph.subgraphs, layer, keys, joins)
598
+ for (const g of plan.ghosts) {
599
+ const sg = clusters.find((c) => c.id === g.subgraph)!
600
+ const at = [...membersOf(sg)]
601
+ .filter((id) => keys.has(id))
602
+ .map((id) => keys.get(id)!)
603
+ keys.set(g.id, at.reduce((a, b) => a + b, 0) / at.length)
604
+ }
605
+
606
+ const nodes = new Map<string, MermaidNode>(graph.nodes)
607
+ for (const g of plan.ghosts) {
608
+ nodes.set(g.id, { id: g.id, label: ' ', shape: 'rectangle' })
609
+ }
610
+ const ordered = orderNodes(
611
+ [...nodes.keys()],
612
+ keys,
613
+ layer,
614
+ clusters,
615
+ spineNodes,
616
+ )
617
+ const augmented: MermaidGraph = {
618
+ ...flat,
619
+ nodes: new Map(ordered.map((id) => [id, nodes.get(id)!])),
620
+ edges: [...graph.edges, ...plan.edges],
621
+ }
622
+ const looseEdges = new Set(plan.edges.map((_, i) => graph.edges.length + i))
623
+
624
+ /**
625
+ * Lay out and widen the spine nodes until no box covers a node that isn't a
626
+ * member, with more room between neighbours in a layer and between layers.
627
+ * Returns the room two overlapping boxes still need if that is all that is
628
+ * wrong, and `undefined` if it can't be fixed.
629
+ */
630
+ const settle = (
631
+ detachedEdges: ReadonlySet<number>,
632
+ ): PositionedGraph | undefined => {
633
+ // How much wider each subgraph's spine nodes are than their minimum, and
634
+ // how far the box reached into a neighbour at the width before.
635
+ const widen = new Map<string, number>()
636
+ const previous = new Map<string, { width: number; overlap: number }>()
637
+ for (let round = 0; ; round++) {
638
+ const fixedWidths = new Map(
639
+ plan.ghosts.map((g) => [
640
+ g.id,
641
+ SPINE_MIN_WIDTH + (widen.get(g.subgraph) ?? 0),
642
+ ]),
643
+ )
644
+ const laidOut = layout(augmented, {
645
+ fixedWidths,
646
+ looseEdges,
647
+ detachedEdges,
648
+ walkOrder: realOrder,
649
+ forceNodeOrder: true,
650
+ })
651
+ const real = new Map(
652
+ laidOut.nodes.filter((n) => !isSpine(n.id)).map((n) => [n.id, n]),
653
+ )
654
+ let groups = buildGroups(graph.subgraphs, real, fontSizes)
655
+ let found = overlaps(
656
+ groups,
657
+ graph.subgraphs,
658
+ real,
659
+ graph.direction,
660
+ new Set(spineNodes.keys()),
661
+ layer,
662
+ )
663
+ const { penetration, stuck } = found
664
+ if (stuck) return undefined
665
+ if (penetration.size === 0) {
666
+ // Boxes that sit too close: open up room between them.
667
+ for (let i = 0; found.requests.length > 0; i++) {
668
+ if (i === MAX_ROOM_STEPS) return undefined
669
+ const request = found.requests.reduce((a, b) =>
670
+ b.amount > a.amount ? b : a,
671
+ )
672
+ openRoom(request, [...real.values()], laidOut.edges)
673
+ groups = buildGroups(graph.subgraphs, real, fontSizes)
674
+ found = overlaps(
675
+ groups,
676
+ graph.subgraphs,
677
+ real,
678
+ graph.direction,
679
+ new Set(spineNodes.keys()),
680
+ layer,
681
+ )
682
+ }
683
+ const inner = routeDetached(
684
+ graph,
685
+ detachedEdges,
686
+ real,
687
+ groups,
688
+ clusters,
689
+ )
690
+ return inner && assemble(laidOut, [...real.values()], groups, inner)
691
+ }
692
+ if (round === MAX_WIDENINGS) return undefined
693
+ for (const [id, overlap] of penetration) {
694
+ const width = widen.get(id) ?? 0
695
+ const before = previous.get(id)
696
+ // How far the neighbour moves per px of width: measured from the last
697
+ // two runs, or assumed (a spine node is centred on its column, and the
698
+ // neighbours it pushes also nudge it) until there are two.
699
+ const measured = before
700
+ ? (before.overlap - overlap) / (width - before.width)
701
+ : 0
702
+ const slope = measured > MIN_SLOPE ? measured : ASSUMED_SLOPE
703
+ previous.set(id, { width, overlap })
704
+ widen.set(id, width + overlap / slope + WIDEN_SLACK)
705
+ }
706
+ }
707
+ }
708
+
709
+ // Edges inside a box that would have to go round a spine are drawn by hand
710
+ // (see `inner-edges.ts`); if one can't be routed, lay them out as usual.
711
+ const detached = detachableEdges(graph, plan, layer, clusters)
712
+ return (detached.size > 0 ? settle(detached) : undefined) ?? settle(new Set())
713
+ }
714
+
715
+ /**
716
+ * The edges between two members of a subgraph that span a layer where it has
717
+ * only a spine node, and whose ends each keep some other edge to hold them in
718
+ * place without it.
719
+ */
720
+ function detachableEdges(
721
+ graph: MermaidGraph,
722
+ plan: SpinePlan,
723
+ layer: ReadonlyMap<string, number>,
724
+ clusters: MermaidSubgraph[],
725
+ ): Set<number> {
726
+ const spans = clusters.map((sg) => {
727
+ const inside = membersOf(sg)
728
+ const layers = new Set(
729
+ [...inside].filter((id) => layer.has(id)).map((id) => layer.get(id)!),
730
+ )
731
+ return { inside, layers }
732
+ })
733
+ const through = (edge: MermaidEdge): boolean => {
734
+ const from = layer.get(edge.source)
735
+ const to = layer.get(edge.target)
736
+ if (from === undefined || to === undefined || edge.source === edge.target) {
737
+ return false
738
+ }
739
+ return spans.some(
740
+ ({ inside, layers }) =>
741
+ inside.has(edge.source) &&
742
+ inside.has(edge.target) &&
743
+ Array.from(
744
+ { length: Math.max(0, Math.abs(to - from) - 1) },
745
+ (_, i) => Math.min(from, to) + 1 + i,
746
+ ).some((l) => !layers.has(l)),
747
+ )
748
+ }
749
+ const detached = new Set<number>()
750
+ graph.edges.forEach((edge, index) => {
751
+ if (through(edge)) detached.add(index)
752
+ })
753
+ // What holds each node in place: the edges that stay, and the spine's chain.
754
+ const held = new Map<string, number>()
755
+ const hold = (e: { source: string; target: string }): void => {
756
+ if (e.source === e.target) return
757
+ held.set(e.source, (held.get(e.source) ?? 0) + 1)
758
+ held.set(e.target, (held.get(e.target) ?? 0) + 1)
759
+ }
760
+ graph.edges.forEach((e, i) => {
761
+ if (!detached.has(i)) hold(e)
762
+ })
763
+ plan.edges.forEach(hold)
764
+ for (const i of [...detached]) {
765
+ const e = graph.edges[i]!
766
+ if (!held.get(e.source) || !held.get(e.target)) detached.delete(i)
767
+ }
768
+ return detached
769
+ }
770
+
771
+ /**
772
+ * Draw the detached edges inside the innermost box that holds both ends.
773
+ * `undefined` if one has no clear route.
774
+ */
775
+ function routeDetached(
776
+ graph: MermaidGraph,
777
+ detached: ReadonlySet<number>,
778
+ nodes: ReadonlyMap<string, PositionedNode>,
779
+ groups: PositionedGroup[],
780
+ clusters: MermaidSubgraph[],
781
+ ): PositionedEdge[] | undefined {
782
+ const boxes = new Map(flattenGroups(groups).map((g) => [g.id, g]))
783
+ const out: PositionedEdge[] = []
784
+ for (const index of detached) {
785
+ const edge = graph.edges[index]!
786
+ const source = nodes.get(edge.source)
787
+ const target = nodes.get(edge.target)
788
+ const home = clusters
789
+ .filter((sg) => {
790
+ const inside = membersOf(sg)
791
+ return inside.has(edge.source) && inside.has(edge.target)
792
+ })
793
+ .sort((a, b) => membersOf(a).size - membersOf(b).size)[0]
794
+ const box = home && boxes.get(home.id)
795
+ if (!source || !target || !box) return undefined
796
+ const points = routeInnerEdge(
797
+ source,
798
+ target,
799
+ [...nodes.values()].filter((n) => n !== source && n !== target),
800
+ box,
801
+ graph.direction,
802
+ out.map((e) => e.points),
803
+ )
804
+ if (!points) return undefined
805
+ out.push({
806
+ source: edge.source,
807
+ target: edge.target,
808
+ label: edge.label,
809
+ style: edge.style,
810
+ hasArrowStart: edge.hasArrowStart,
811
+ hasArrowEnd: edge.hasArrowEnd,
812
+ points,
813
+ labelPosition: edge.label ? labelSpot(points) : undefined,
814
+ inlineStyle: resolveEdgeStyle(index, graph),
815
+ id: edge.id,
816
+ animate: edge.animate,
817
+ })
818
+ }
819
+ return out
820
+ }
821
+
822
+ /**
823
+ * Open up `request.amount` px (and a little slack) between two boxes that sit
824
+ * too close, by moving everything from the first node beyond the cut line (where
825
+ * the first of them ends) along by that much: those nodes, and the edges'
826
+ * points that are as far along as they are, so an edge that crosses the cut
827
+ * just gets longer (the segments stay straight and square). An edge's bends in
828
+ * the gap between the cut and those nodes stay where they are, close to the
829
+ * first box, so a bus that fans out of a node stays clear of the boxes below.
830
+ * A node that straddles the cut stays put; whatever it was beside moves away
831
+ * from it, never into it.
832
+ */
833
+ function openRoom(
834
+ request: RoomRequest,
835
+ nodes: PositionedNode[],
836
+ edges: PositionedEdge[],
837
+ ): void {
838
+ const { axis, amount, after: cut } = request
839
+ const by = amount + SPACING_SLACK
840
+ const beyond = nodes.filter((n) => n[axis] >= cut)
841
+ const gapEnd = Math.min(...beyond.map((n) => n[axis]))
842
+ for (const n of beyond) n[axis] += by
843
+ const shift = (p: Point): void => {
844
+ if (p[axis] >= gapEnd) p[axis] += by
845
+ }
846
+ for (const e of edges) {
847
+ e.points.forEach(shift)
848
+ if (e.labelPosition) shift(e.labelPosition)
849
+ }
850
+ }
851
+
852
+ /** The result of the last layout without the scaffolding, moved and sized to fit what is left. */
853
+ function assemble(
854
+ laidOut: PositionedGraph,
855
+ nodes: PositionedNode[],
856
+ groups: PositionedGroup[],
857
+ extraEdges: PositionedEdge[],
858
+ ): PositionedGraph {
859
+ const edges: PositionedEdge[] = [
860
+ ...laidOut.edges.filter((e) => !isSpine(e.source) && !isSpine(e.target)),
861
+ ...extraEdges,
862
+ ]
863
+ const all = flattenGroups(groups)
864
+ const xs = [
865
+ ...nodes.map((n) => n.x),
866
+ ...all.map((g) => g.x),
867
+ ...edges.flatMap((e) => e.points.map((p) => p.x)),
868
+ ]
869
+ const ys = [
870
+ ...nodes.map((n) => n.y),
871
+ ...all.map((g) => g.y),
872
+ ...edges.flatMap((e) => e.points.map((p) => p.y)),
873
+ ]
874
+ const dx = DEFAULTS.padding - Math.min(...xs)
875
+ const dy = DEFAULTS.padding - Math.min(...ys)
876
+ for (const n of nodes) {
877
+ n.x += dx
878
+ n.y += dy
879
+ }
880
+ for (const g of all) {
881
+ g.x += dx
882
+ g.y += dy
883
+ }
884
+ for (const e of edges) {
885
+ for (const p of e.points) {
886
+ p.x += dx
887
+ p.y += dy
888
+ }
889
+ if (e.labelPosition) {
890
+ e.labelPosition.x += dx
891
+ e.labelPosition.y += dy
892
+ }
893
+ }
894
+ // Same allowances `elkToPositioned` makes around edges and labels.
895
+ let width = 0
896
+ let height = 0
897
+ for (const n of nodes) {
898
+ width = Math.max(width, n.x + n.width + DEFAULTS.padding)
899
+ height = Math.max(height, n.y + n.height + DEFAULTS.padding)
900
+ }
901
+ for (const g of all) {
902
+ width = Math.max(width, g.x + g.width + DEFAULTS.padding)
903
+ height = Math.max(height, g.y + g.height + DEFAULTS.padding)
904
+ }
905
+ for (const e of edges) {
906
+ for (const p of e.points) {
907
+ width = Math.max(width, p.x + ARROW_HEAD.width + DEFAULTS.padding)
908
+ height = Math.max(height, p.y + ARROW_HEAD.width + DEFAULTS.padding)
909
+ }
910
+ if (e.labelPosition) {
911
+ width = Math.max(width, e.labelPosition.x + 60 + DEFAULTS.padding)
912
+ height = Math.max(height, e.labelPosition.y + 20 + DEFAULTS.padding)
913
+ }
914
+ }
915
+ return { ...laidOut, nodes, edges, groups, width, height }
916
+ }