@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
@@ -50,10 +50,11 @@ export type ElkDirection = 'DOWN' | 'UP' | 'LEFT' | 'RIGHT'
50
50
  * - **C4** — `DOWN`. `C4Diagram.direction` is never set by the parser (C4
51
51
  * sources have no direction statement), only by `RenderOptions.direction`,
52
52
  * so an unset one is the top-down layout every C4 diagram is drawn in.
53
- * - **ER** — `RIGHT`. An ER diagram's `direction` is optional
53
+ * - **ER** — `DOWN`. An ER diagram's `direction` is optional
54
54
  * (`ErDiagram.direction?`), left `undefined` when the source has no
55
- * `direction` statement, and this renderer has always laid those out
56
- * left-to-right. `direction TB`/`LR`/`BT`/`RL` in the source (or
55
+ * `direction` statement, and official Mermaid lays those out top-to-bottom
56
+ * (#1250; this renderer used to lay them out left-to-right).
57
+ * `direction TB`/`LR`/`BT`/`RL` in the source (or
57
58
  * `RenderOptions.direction`, applied via `withDirectionOverride`) still
58
59
  * wins over it.
59
60
  *
@@ -67,7 +68,7 @@ export const ELK_DIRECTION_FALLBACK = {
67
68
  flowchart: 'DOWN',
68
69
  state: 'DOWN',
69
70
  class: 'DOWN',
70
- er: 'RIGHT',
71
+ er: 'DOWN',
71
72
  c4: 'DOWN',
72
73
  } as const satisfies Record<string, ElkDirection>
73
74
 
@@ -22,6 +22,7 @@ import { clipEdgeToShape } from '../shape-clipping.ts'
22
22
  import { DEFAULTS } from './constants.ts'
23
23
  import { alignLayerNodes } from './layer-alignment.ts'
24
24
  import { bundleEdgePaths } from './edge-bundling.ts'
25
+ import { edgesReversedForLayout } from './to-elk.ts'
25
26
  import {
26
27
  extractEdgePoints,
27
28
  extractEdgeLabelPosition,
@@ -527,7 +528,11 @@ function extractEdgesRecursively(
527
528
  style: originalEdge.style,
528
529
  hasArrowStart: originalEdge.hasArrowStart,
529
530
  hasArrowEnd: originalEdge.hasArrowEnd,
530
- points: orthogonalPoints,
531
+ // An edge laid out reversed (see `edgesReversedForLayout`) comes back
532
+ // running target to source; flip it so it runs source to target again.
533
+ points: edgesReversedForLayout(graph).has(edgeIndex)
534
+ ? [...orthogonalPoints].reverse()
535
+ : orthogonalPoints,
531
536
  labelPosition,
532
537
  inlineStyle: resolveEdgeStyle(edgeIndex, graph),
533
538
  id: originalEdge.id,
@@ -740,7 +745,7 @@ function collectAllSubgraphIds(sg: MermaidSubgraph, out: Set<string>): void {
740
745
  * Resolve inline styles for an edge from linkStyles map.
741
746
  * Default link style is applied first, then index-specific overrides.
742
747
  */
743
- function resolveEdgeStyle(
748
+ export function resolveEdgeStyle(
744
749
  edgeIndex: number,
745
750
  graph: MermaidGraph,
746
751
  ): Record<string, string> | undefined {
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Routes an edge between two members of a subgraph through the inside of its
3
+ * box.
4
+ *
5
+ * `compound-flat.ts` keeps outsiders out of a box with invisible spine nodes in
6
+ * the layers where the subgraph has no member. An edge from one member to
7
+ * another that spans such a layer has to get past the spine, and ELK sends it
8
+ * round the outside, which drags its end node, and so the box, sideways. These
9
+ * edges are left out of the layout and drawn afterwards by `routeInnerEdge`,
10
+ * which runs them down a channel between the members.
11
+ *
12
+ * Everything is worked out as (a, c): `a` along the flow, increasing with it,
13
+ * and `c` across it. For `TD` that is (y, x); `BT` negates `a`, `LR` is (x, y).
14
+ */
15
+
16
+ import type { Direction, Point, PositionedNode } from '@zombie-mermaid/core'
17
+
18
+ /** Room kept between a route and the nodes it passes, in px. */
19
+ const CLEARANCE = 10
20
+
21
+ /** Least distance between two routes that run side by side, in px. */
22
+ const PARALLEL_GAP = 14
23
+
24
+ /** How far an edge runs out of a node before turning, in px. */
25
+ const STUB = 14
26
+
27
+ /** Room kept between a route and the box's border, in px. */
28
+ const BORDER_INSET = 10
29
+
30
+ /** Cost of each bend, in px of extra length, so a straighter route wins. */
31
+ const BEND_COST = 24
32
+
33
+ /** Extra cost of leaving a decision by its bottom vertex, in px. */
34
+ const DECISION_STEM = 400
35
+
36
+ /** Shapes whose whole edge is a straight side an edge can leave from anywhere. */
37
+ const FLAT_SIDED = new Set(['rectangle', 'rounded', 'subroutine'])
38
+
39
+ interface Span {
40
+ a0: number
41
+ a1: number
42
+ c0: number
43
+ c1: number
44
+ }
45
+
46
+ interface Pt {
47
+ a: number
48
+ c: number
49
+ }
50
+
51
+ interface Frame {
52
+ span: (box: BoxLike) => Span
53
+ toPoint: (p: Pt) => Point
54
+ }
55
+
56
+ type BoxLike = { x: number; y: number; width: number; height: number }
57
+
58
+ function frameFor(direction: Direction): Frame {
59
+ const vertical =
60
+ direction === 'TD' || direction === 'TB' || direction === 'BT'
61
+ const flip = direction === 'BT' || direction === 'RL'
62
+ const sign = flip ? -1 : 1
63
+ return {
64
+ span: (b) => {
65
+ const [p0, p1] = vertical ? [b.y, b.y + b.height] : [b.x, b.x + b.width]
66
+ const a0 = sign * p0
67
+ const a1 = sign * p1
68
+ return {
69
+ a0: Math.min(a0, a1),
70
+ a1: Math.max(a0, a1),
71
+ c0: vertical ? b.x : b.y,
72
+ c1: vertical ? b.x + b.width : b.y + b.height,
73
+ }
74
+ },
75
+ toPoint: ({ a, c }) =>
76
+ vertical ? { x: c, y: sign * a } : { x: sign * a, y: c },
77
+ }
78
+ }
79
+
80
+ /** Whether the segment `p`-`q` (one axis only) passes through the inside of `s`. */
81
+ function crosses(p: Pt, q: Pt, s: Span): boolean {
82
+ const aLo = Math.min(p.a, q.a)
83
+ const aHi = Math.max(p.a, q.a)
84
+ const cLo = Math.min(p.c, q.c)
85
+ const cHi = Math.max(p.c, q.c)
86
+ return aHi > s.a0 && aLo < s.a1 && cHi > s.c0 && cLo < s.c1
87
+ }
88
+
89
+ const grow = (s: Span, by: number): Span => ({
90
+ a0: s.a0 - by,
91
+ a1: s.a1 + by,
92
+ c0: s.c0 - by,
93
+ c1: s.c1 + by,
94
+ })
95
+
96
+ /** The (a, c) of a point in `frame`. */
97
+ function toPt(frame: Frame, p: Point): Pt {
98
+ const s = frame.span({ x: p.x, y: p.y, width: 0, height: 0 })
99
+ return { a: s.a0, c: s.c0 }
100
+ }
101
+
102
+ /** Whether the segment `p`-`q` runs beside a segment of `path`, closer than `PARALLEL_GAP`. */
103
+ function runsAlong(p: Pt, q: Pt, path: readonly Pt[]): boolean {
104
+ const flowwise = p.c === q.c
105
+ for (let i = 1; i < path.length; i++) {
106
+ const r = path[i - 1]!
107
+ const t = path[i]!
108
+ if ((r.c === t.c) !== flowwise) continue
109
+ if (flowwise) {
110
+ const apart = Math.abs(p.c - r.c)
111
+ const shared =
112
+ Math.min(Math.max(p.a, q.a), Math.max(r.a, t.a)) -
113
+ Math.max(Math.min(p.a, q.a), Math.min(r.a, t.a))
114
+ if (apart < PARALLEL_GAP && shared > 0) return true
115
+ } else {
116
+ const apart = Math.abs(p.a - r.a)
117
+ const shared =
118
+ Math.min(Math.max(p.c, q.c), Math.max(r.c, t.c)) -
119
+ Math.max(Math.min(p.c, q.c), Math.min(r.c, t.c))
120
+ if (apart < PARALLEL_GAP && shared > 0) return true
121
+ }
122
+ }
123
+ return false
124
+ }
125
+
126
+ /** The path with repeated points and points in the middle of a straight run removed. */
127
+ function simplify(path: Pt[]): Pt[] {
128
+ const out: Pt[] = []
129
+ for (const p of path) {
130
+ const last = out[out.length - 1]
131
+ if (last && last.a === p.a && last.c === p.c) continue
132
+ out.push(p)
133
+ }
134
+ for (let i = out.length - 2; i > 0; i--) {
135
+ const prev = out[i - 1]!
136
+ const here = out[i]!
137
+ const next = out[i + 1]!
138
+ const straight =
139
+ (prev.a === here.a && here.a === next.a) ||
140
+ (prev.c === here.c && here.c === next.c)
141
+ if (straight) out.splice(i, 1)
142
+ }
143
+ return out
144
+ }
145
+
146
+ const length = (path: Pt[]): number => {
147
+ let total = 0
148
+ for (let i = 1; i < path.length; i++) {
149
+ total +=
150
+ Math.abs(path[i]!.a - path[i - 1]!.a) +
151
+ Math.abs(path[i]!.c - path[i - 1]!.c)
152
+ }
153
+ return total
154
+ }
155
+
156
+ /**
157
+ * A route for the edge from `source` to `target`, both inside `bounds` (their
158
+ * subgraph's box), kept clear of `others`. The cheapest of a straight line, a
159
+ * Z for an edge that runs with the flow, and a hook out of the side of the
160
+ * source and up the side of the target for one that runs against it. `undefined`
161
+ * if nothing fits.
162
+ */
163
+ export function routeInnerEdge(
164
+ source: PositionedNode,
165
+ target: PositionedNode,
166
+ others: readonly PositionedNode[],
167
+ bounds: BoxLike,
168
+ direction: Direction,
169
+ drawn: readonly (readonly Point[])[] = [],
170
+ ): Point[] | undefined {
171
+ const frame = frameFor(direction)
172
+ const S = frame.span(source)
173
+ const T = frame.span(target)
174
+ const forward = S.a1 <= T.a0
175
+ const backward = T.a1 <= S.a0
176
+ if (!forward && !backward) return undefined
177
+
178
+ const inner = frame.span(bounds)
179
+ const room: Span = {
180
+ a0: inner.a0 + BORDER_INSET,
181
+ a1: inner.a1 - BORDER_INSET,
182
+ c0: inner.c0 + BORDER_INSET,
183
+ c1: inner.c1 - BORDER_INSET,
184
+ }
185
+ const before = drawn.map((path) => path.map((p) => toPt(frame, p)))
186
+ const blocks = others.map((n) => grow(frame.span(n), CLEARANCE))
187
+ const mid = (s: Span): number => (s.c0 + s.c1) / 2
188
+ const midA = (s: Span): number => (s.a0 + s.a1) / 2
189
+ const flat = (n: PositionedNode): boolean => FLAT_SIDED.has(n.shape)
190
+
191
+ /** Whether every segment is clear of the nodes, the ends touch only their own node, and it stays in the box. */
192
+ const valid = (path: Pt[]): boolean => {
193
+ for (const p of path) {
194
+ if (p.a < room.a0 || p.a > room.a1 || p.c < room.c0 || p.c > room.c1) {
195
+ return false
196
+ }
197
+ }
198
+ for (let i = 1; i < path.length; i++) {
199
+ const p = path[i - 1]!
200
+ const q = path[i]!
201
+ if (p.a !== q.a && p.c !== q.c) return false
202
+ if (blocks.some((b) => crosses(p, q, b))) return false
203
+ if (crosses(p, q, S) || crosses(p, q, T)) return false
204
+ if (before.some((path) => runsAlong(p, q, path))) return false
205
+ }
206
+ return true
207
+ }
208
+
209
+ // Cross-flow positions worth trying: where the two nodes are, and just clear
210
+ // of every node and the box's border.
211
+ const candidates = new Set<number>([mid(S), mid(T), room.c0, room.c1])
212
+ for (const b of blocks) {
213
+ candidates.add(b.c0 - 1)
214
+ candidates.add(b.c1 + 1)
215
+ }
216
+ for (const s of [S, T]) {
217
+ candidates.add(s.c0 - CLEARANCE)
218
+ candidates.add(s.c1 + CLEARANCE)
219
+ }
220
+ const within = (n: PositionedNode, s: Span, c: number): boolean =>
221
+ flat(n) ? c >= s.c0 + 4 && c <= s.c1 - 4 : Math.abs(c - mid(s)) < 0.5
222
+
223
+ const routes: Pt[][] = []
224
+ if (forward) {
225
+ const gap = (T.a0 - S.a1) / 2
226
+ const a1 = S.a1 + Math.min(STUB, gap)
227
+ const a2 = T.a0 - Math.min(STUB, gap)
228
+ for (const c of candidates) {
229
+ if (within(source, S, c) && within(target, T, c)) {
230
+ routes.push([
231
+ { a: S.a1, c },
232
+ { a: T.a0, c },
233
+ ])
234
+ }
235
+ // A Z, or an L-shaped pair of turns when one end can take the edge off centre.
236
+ routes.push([
237
+ { a: S.a1, c: mid(S) },
238
+ { a: a1, c: mid(S) },
239
+ { a: a1, c },
240
+ { a: a2, c },
241
+ { a: a2, c: mid(T) },
242
+ { a: T.a0, c: mid(T) },
243
+ ])
244
+ if (within(target, T, c)) {
245
+ routes.push([
246
+ { a: S.a1, c: mid(S) },
247
+ { a: a1, c: mid(S) },
248
+ { a: a1, c },
249
+ { a: T.a0, c },
250
+ ])
251
+ }
252
+ if (within(source, S, c)) {
253
+ routes.push([
254
+ { a: S.a1, c },
255
+ { a: a2, c },
256
+ { a: a2, c: mid(T) },
257
+ { a: T.a0, c: mid(T) },
258
+ ])
259
+ }
260
+ // Out of the source's side and down: for a decision, whose other
261
+ // branches leave by the bottom.
262
+ const side = c >= S.c1 ? S.c1 : c <= S.c0 ? S.c0 : undefined
263
+ if (side !== undefined) {
264
+ const down = within(target, T, c)
265
+ ? [{ a: T.a0, c }]
266
+ : [
267
+ { a: a2, c },
268
+ { a: a2, c: mid(T) },
269
+ { a: T.a0, c: mid(T) },
270
+ ]
271
+ routes.push([{ a: midA(S), c: side }, { a: midA(S), c }, ...down])
272
+ }
273
+ }
274
+ } else {
275
+ for (const c of candidates) {
276
+ const side = c >= T.c1 ? T.c1 : c <= T.c0 ? T.c0 : undefined
277
+ if (side === undefined) continue
278
+ // Out of the source's back, up, and into the target's side.
279
+ if (within(source, S, c)) {
280
+ routes.push([
281
+ { a: S.a0, c },
282
+ { a: midA(T), c },
283
+ { a: midA(T), c: side },
284
+ ])
285
+ }
286
+ // Out of the source's side, along, up, and into the target's side.
287
+ const out = side === T.c1 ? S.c1 : S.c0
288
+ routes.push([
289
+ { a: midA(S), c: out },
290
+ { a: midA(S), c },
291
+ { a: midA(T), c },
292
+ { a: midA(T), c: side },
293
+ ])
294
+ }
295
+ }
296
+
297
+ let best: Pt[] | undefined
298
+ let bestCost = Number.POSITIVE_INFINITY
299
+ for (const route of routes) {
300
+ const path = simplify(route)
301
+ if (path.length < 2 || !valid(path)) continue
302
+ // A decision's branches fan out of its sides; leaving by the bottom only
303
+ // when nothing else fits.
304
+ const stem =
305
+ source.shape === 'diamond' && path[0]!.a === S.a1 ? DECISION_STEM : 0
306
+ const cost = length(path) + BEND_COST * (path.length - 2) + stem
307
+ if (cost < bestCost) {
308
+ best = path
309
+ bestCost = cost
310
+ }
311
+ }
312
+ return best?.map(frame.toPoint)
313
+ }
314
+
315
+ /** The middle of a path's longest segment, where an edge label sits. */
316
+ export function labelSpot(points: readonly Point[]): Point {
317
+ let best = { x: points[0]!.x, y: points[0]!.y }
318
+ let longest = -1
319
+ for (let i = 1; i < points.length; i++) {
320
+ const p = points[i - 1]!
321
+ const q = points[i]!
322
+ const len = Math.abs(q.x - p.x) + Math.abs(q.y - p.y)
323
+ if (len > longest) {
324
+ longest = len
325
+ best = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }
326
+ }
327
+ }
328
+ return best
329
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Extra instructions a caller can give the layout pipeline for one run, for
3
+ * things a `MermaidGraph` has no way to say. Used by the compound layout
4
+ * (`compound-flat.ts`), which adds invisible helper nodes and edges to the
5
+ * graph and has to size, order and weight them.
6
+ */
7
+ export interface LayoutHints {
8
+ /** Node widths in px, used instead of the size worked out from the label. */
9
+ fixedWidths?: ReadonlyMap<string, number>
10
+ /**
11
+ * Indices of edges that only shape the layout and are never drawn. They are
12
+ * not straightened, so they can't drag the nodes they join into line.
13
+ */
14
+ looseEdges?: ReadonlySet<number>
15
+ /**
16
+ * Indices of real edges to leave out of the layout. The caller draws them
17
+ * itself, so ELK doesn't route them round the helper nodes.
18
+ */
19
+ detachedEdges?: ReadonlySet<number>
20
+ /**
21
+ * The order a back-edge search starts from, when it must differ from the
22
+ * graph's node order (which is also the order ELK is given the nodes in).
23
+ */
24
+ walkOrder?: readonly string[]
25
+ /** Keep every layer's nodes in the order the graph lists them. */
26
+ forceNodeOrder?: boolean
27
+ }
@@ -17,6 +17,8 @@ import type { FontSizes } from '../styles.ts'
17
17
  import { FONT_WEIGHTS, NODE_PADDING } from '../styles.ts'
18
18
  import { measureMultilineText } from '@zombie-mermaid/core'
19
19
  import { DEFAULTS } from './constants.ts'
20
+ import { findBackEdgeIndexes } from './back-edges.ts'
21
+ import type { LayoutHints } from './layout-hints.ts'
20
22
  import {
21
23
  ELK_DIRECTION_FALLBACK,
22
24
  INLINE_CENTERED_EDGE_LABEL,
@@ -29,6 +31,100 @@ import {
29
31
  type ElkPaddingSides,
30
32
  } from './elk-graph-builder.ts'
31
33
 
34
+ /**
35
+ * ELK layer constraint for a top-level `[*]` marker: the start marker is
36
+ * pinned to the first layer so a back-edge can't drag it into the middle
37
+ * (#1240). The end marker is deliberately left unconstrained: it settles in
38
+ * the layer right after the state(s) that transition into it, instead of a
39
+ * layer of its own after every other node (`LAST_SEPARATE`), which drew
40
+ * `Closed --> [*]` as a long line down the page edge when `Closed` is near
41
+ * the top. Keyed by node shape.
42
+ */
43
+ const STATE_MARKER_LAYER_CONSTRAINT: Partial<Record<string, string>> = {
44
+ 'state-start': 'FIRST',
45
+ }
46
+
47
+ /**
48
+ * Root-graph ELK options applied to state diagrams only (flowcharts keep
49
+ * their tuned defaults).
50
+ *
51
+ * - `cycleBreaking: MODEL_ORDER` reverses the edge that points *against*
52
+ * declaration order instead of whichever edge ELK's greedy heuristic picks.
53
+ * The greedy default reversed the main chain of `[*] --> Idle`,
54
+ * `Idle --> Active`, `Active --> Idle`, drawing it bottom-to-top with the
55
+ * start marker beside a state.
56
+ * - `postCompaction: NONE`: LEFT_RIGHT_CONSTRAINT_LOCKING compaction slides
57
+ * nodes sideways after edges are routed, which leaves state edges ending
58
+ * in a horizontal run along the target's top border (arrowhead sideways).
59
+ */
60
+ const STATE_LAYOUT_OPTIONS: LayoutOptions = {
61
+ 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
62
+ 'elk.layered.compaction.postCompaction.strategy': 'NONE',
63
+ }
64
+
65
+ /**
66
+ * Whether `graph` is a `stateDiagram` (it has `[*]` markers, the only thing
67
+ * that distinguishes a state graph from a flowchart here). Composite states
68
+ * are included: their compound nodes are placed among their siblings at
69
+ * first-mention position (see `firstMentionRank`), so `MODEL_ORDER` reads
70
+ * them in source order instead of after every leaf (#1287).
71
+ */
72
+ export function isStateGraph(graph: MermaidGraph): boolean {
73
+ for (const node of graph.nodes.values()) {
74
+ if (node.shape === 'state-start' || node.shape === 'state-end') return true
75
+ }
76
+ return false
77
+ }
78
+
79
+ /**
80
+ * Room a subgraph's box leaves around its contents, in px. The top is the
81
+ * title bar (28) plus a gap (16) to match the bottom. Shared with the compound
82
+ * layout, which draws the boxes itself.
83
+ */
84
+ export const SUBGRAPH_PADDING = { top: 44, left: 16, bottom: 16, right: 16 }
85
+
86
+ const reversedCache = new WeakMap<MermaidGraph, Set<number>>()
87
+
88
+ /**
89
+ * Indices of the edges handed to ELK reversed, so the layout breaks cycles the
90
+ * way mermaid.js (dagre) does: see `findBackEdgeIndexes`. `elkToPositioned`
91
+ * flips each one's points back so they still run source to target.
92
+ *
93
+ * Flowcharts only. State diagrams have their own cycle breaking (above), and a
94
+ * graph with a subgraph direction override goes through hop edges and ports
95
+ * that this does not rewrite.
96
+ */
97
+ export function edgesReversedForLayout(
98
+ graph: MermaidGraph,
99
+ hints?: LayoutHints,
100
+ ): Set<number> {
101
+ // Worked out once per graph object, by whichever of `mermaidToElk` (which
102
+ // has the hints) and `elkToPositioned` (which doesn't need them) asks first.
103
+ let set = reversedCache.get(graph)
104
+ if (!set) {
105
+ set =
106
+ isStateGraph(graph) || hasAnyDirectionOverride(graph.subgraphs)
107
+ ? new Set()
108
+ : findBackEdgeIndexes(graph, hints?.walkOrder)
109
+ reversedCache.set(graph, set)
110
+ }
111
+ return set
112
+ }
113
+
114
+ /**
115
+ * Source position of a root-level state or composite: the index of the first
116
+ * edge that names it. A composite no edge names itself falls back to the first
117
+ * edge naming a state inside it. Items no edge mentions rank last, keeping
118
+ * their relative order.
119
+ */
120
+ function firstMentionRank(graph: MermaidGraph, ids: Set<string>): number {
121
+ for (let i = 0; i < graph.edges.length; i++) {
122
+ const e = graph.edges[i]!
123
+ if (ids.has(e.source) || ids.has(e.target)) return i
124
+ }
125
+ return Number.POSITIVE_INFINITY
126
+ }
127
+
32
128
  /**
33
129
  * Convert a Mermaid direction to an ELK direction, flowchart/state-style
34
130
  * (no direction at all → `DOWN`; see `ELK_DIRECTION_FALLBACK`).
@@ -156,7 +252,7 @@ const ROOT_CONTAINER = ' root'
156
252
  * compound node's own `elk.direction`) for exactly the diagrams that need
157
253
  * `SEPARATE` the most.
158
254
  */
159
- function hasAnyDirectionOverride(subgraphs: MermaidSubgraph[]): boolean {
255
+ export function hasAnyDirectionOverride(subgraphs: MermaidSubgraph[]): boolean {
160
256
  for (const sg of subgraphs) {
161
257
  if (sg.direction !== undefined) return true
162
258
  if (hasAnyDirectionOverride(sg.children)) return true
@@ -231,10 +327,12 @@ function commonPrefixLength(a: string[], b: string[]): number {
231
327
  */
232
328
  export function mermaidToElk(
233
329
  graph: MermaidGraph,
234
- opts: Required<
330
+ givenOpts: Required<
235
331
  Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>
236
332
  > & { fontSizes: FontSizes },
333
+ hints?: LayoutHints,
237
334
  ): ElkGraphNode {
335
+ const opts = givenOpts
238
336
  // Collect all node IDs that belong to subgraphs
239
337
  const subgraphNodeIds = new Set<string>()
240
338
  const subgraphIds = new Set<string>()
@@ -325,6 +423,12 @@ export function mermaidToElk(
325
423
  'elk.layered.compaction.postCompaction.strategy':
326
424
  'LEFT_RIGHT_CONSTRAINT_LOCKING',
327
425
  'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
426
+ // Every layer keeps the nodes in the order the graph lists them: the
427
+ // compound layout relies on it to keep a subgraph's nodes together.
428
+ ...(hints?.forceNodeOrder
429
+ ? { 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true' }
430
+ : {}),
431
+ ...(isStateGraph(graph) ? STATE_LAYOUT_OPTIONS : {}),
328
432
  'elk.layered.wrapping.strategy': 'OFF',
329
433
  // Use SEPARATE when subgraphs have direction overrides (enables proper direction handling)
330
434
  // Use INCLUDE_CHILDREN otherwise (simpler cross-hierarchy edge routing)
@@ -332,7 +436,6 @@ export function mermaidToElk(
332
436
  ? 'SEPARATE'
333
437
  : 'INCLUDE_CHILDREN',
334
438
  }
335
-
336
439
  // Ports to declare on each subgraph's ELK node, keyed by subgraph ID.
337
440
  const portsBySubgraph = new Map<string, Set<string>>()
338
441
  // Hop/bridge edges to inject into each container's own `edges` array.
@@ -432,7 +535,19 @@ export function mermaidToElk(
432
535
  node.shape,
433
536
  opts.fontSizes.nodeLabel,
434
537
  )
435
- rootChildren.push(buildElkLeafNode(id, size, node.label))
538
+ const fixedWidth = hints?.fixedWidths?.get(id)
539
+ if (fixedWidth !== undefined) size.width = fixedWidth
540
+ const leaf = buildElkLeafNode(id, size, node.label)
541
+ // A top-level start `[*]` marks where reading starts: pin it to the
542
+ // first layer so a back-edge can't drag it into the middle. The end
543
+ // marker stays free (see STATE_MARKER_LAYER_CONSTRAINT).
544
+ const pin = isStateGraph(graph)
545
+ ? STATE_MARKER_LAYER_CONSTRAINT[node.shape]
546
+ : undefined
547
+ if (pin !== undefined) {
548
+ leaf.layoutOptions = { 'elk.layered.layering.layerConstraint': pin }
549
+ }
550
+ rootChildren.push(leaf)
436
551
  }
437
552
  }
438
553
 
@@ -460,7 +575,14 @@ export function mermaidToElk(
460
575
  // subgraph in a cyclic flow (e.g. "CI/CD Pipeline"), reordering the
461
576
  // entire rank structure rather than just left-right sibling position —
462
577
  // a much bigger, unreviewed blast radius than the reported bug needs.
463
- for (const sg of [...graph.subgraphs].reverse()) {
578
+ const stateGraph = isStateGraph(graph)
579
+ const leafCount = rootChildren.length
580
+ // State diagrams read in source order, so composites keep forward
581
+ // declaration order and are interleaved with the leaves below.
582
+ const subgraphOrder = stateGraph
583
+ ? graph.subgraphs
584
+ : [...graph.subgraphs].reverse()
585
+ for (const sg of subgraphOrder) {
464
586
  rootChildren.push(
465
587
  subgraphToElk(
466
588
  sg,
@@ -473,22 +595,50 @@ export function mermaidToElk(
473
595
  ),
474
596
  )
475
597
  }
598
+ if (stateGraph && graph.subgraphs.length > 0) {
599
+ // ELK's MODEL_ORDER cycle breaking and crossing minimisation follow the
600
+ // child order, so a composite must sit at its first-mention position
601
+ // among its siblings rather than after every leaf (#1287). Array#sort is
602
+ // stable, so equal ranks keep leaves-then-composites order.
603
+ const rank = rootChildren.map((child, i) => {
604
+ if (i < leafCount) return firstMentionRank(graph, new Set([child.id]))
605
+ const sg = graph.subgraphs[i - leafCount]!
606
+ const own = firstMentionRank(graph, new Set([sg.id]))
607
+ if (own !== Number.POSITIVE_INFINITY) return own
608
+ const members = new Set<string>()
609
+ collectAllMemberNodeIds(sg, members)
610
+ return firstMentionRank(graph, members)
611
+ })
612
+ const order = rootChildren
613
+ .map((_, i) => i)
614
+ .sort((a, b) => rank[a]! - rank[b]! || a - b)
615
+ const sorted = order.map((i) => rootChildren[i]!)
616
+ rootChildren.splice(0, rootChildren.length, ...sorted)
617
+ }
476
618
 
477
619
  // Add root-level edges. Self-loops (edge.source === edge.target) are
478
620
  // excluded — ELK has no native self-loop layout and produces a degenerate
479
621
  // zero-length-span polyline for them; from-elk.ts synthesizes a proper
480
622
  // side loop for these once node positions are known instead.
623
+ const reversed = edgesReversedForLayout(graph, hints)
481
624
  for (const { index, edge } of edgesBySubgraph.get(null)!) {
482
625
  if (edge.source === edge.target) continue
483
- rootEdges.push(
484
- buildElkEdge({
485
- id: `e${index}`,
486
- source: edge.source,
487
- target: edge.target,
488
- label: edge.label,
489
- labelStyle: edgeLabelStyle(opts),
490
- }),
491
- )
626
+ if (hints?.detachedEdges?.has(index)) continue
627
+ const elkEdge = buildElkEdge({
628
+ id: `e${index}`,
629
+ source: reversed.has(index) ? edge.target : edge.source,
630
+ target: reversed.has(index) ? edge.source : edge.target,
631
+ label: edge.label,
632
+ labelStyle: edgeLabelStyle(opts),
633
+ })
634
+ if (hints?.looseEdges?.has(index)) {
635
+ // Priority 0: the straightening pass leaves this edge alone.
636
+ elkEdge.layoutOptions = {
637
+ ...elkEdge.layoutOptions,
638
+ 'elk.layered.priority.straightness': '0',
639
+ }
640
+ }
641
+ rootEdges.push(elkEdge)
492
642
  }
493
643
 
494
644
  if (hasDirectionOverride) {
@@ -506,8 +656,8 @@ export function mermaidToElk(
506
656
  rootEdges.push(
507
657
  buildElkEdge({
508
658
  id: `e${index}`,
509
- source: edge.source,
510
- target: edge.target,
659
+ source: reversed.has(index) ? edge.target : edge.source,
660
+ target: reversed.has(index) ? edge.source : edge.target,
511
661
  label: edge.label,
512
662
  labelStyle: edgeLabelStyle(opts),
513
663
  }),
@@ -567,8 +717,7 @@ function subgraphToElk(
567
717
  direction: directionToElk(effectiveDirection),
568
718
  nodeSpacing: opts.nodeSpacing,
569
719
  layerSpacing: opts.layerSpacing,
570
- // Top = headerHeight(28) + gap(16) to match bottom padding
571
- padding: { top: 44, left: 16, bottom: 16, right: 16 },
720
+ padding: SUBGRAPH_PADDING,
572
721
  })
573
722
 
574
723
  // Ports, built before children/edges since they don't depend on them.
@@ -619,13 +768,14 @@ function subgraphToElk(
619
768
  // Self-loops are excluded — see the matching comment in mermaidToElk.
620
769
  const edges: ElkExtendedEdge[] = []
621
770
  const internalEdges = edgesBySubgraph.get(sg.id) ?? []
771
+ const reversed = edgesReversedForLayout(graph)
622
772
  for (const { index, edge } of internalEdges) {
623
773
  if (edge.source === edge.target) continue
624
774
  edges.push(
625
775
  buildElkEdge({
626
776
  id: `e${index}`,
627
- source: edge.source,
628
- target: edge.target,
777
+ source: reversed.has(index) ? edge.target : edge.source,
778
+ target: reversed.has(index) ? edge.source : edge.target,
629
779
  label: edge.label,
630
780
  labelStyle: edgeLabelStyle(opts),
631
781
  }),