@zombie-mermaid/svg-renderer 4.2.3 → 5.0.1

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/README.md +15 -1
  2. package/dist/index.cjs +52 -52
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +23 -4
  5. package/dist/index.d.ts +23 -4
  6. package/dist/index.js +890 -850
  7. package/dist/index.js.map +1 -1
  8. package/package.json +14 -6
  9. package/src/c4/arial-widths.ts +0 -59
  10. package/src/c4/layout.ts +0 -680
  11. package/src/c4/metrics.ts +0 -261
  12. package/src/c4/renderer.ts +0 -366
  13. package/src/class/layout.ts +0 -525
  14. package/src/class/renderer.ts +0 -669
  15. package/src/edge-curves.ts +0 -205
  16. package/src/elk-instance.ts +0 -292
  17. package/src/er/layout.ts +0 -216
  18. package/src/er/renderer.ts +0 -516
  19. package/src/generated/mono-font-subset.ts +0 -32
  20. package/src/index.ts +0 -310
  21. package/src/layout-engine/back-edges.ts +0 -73
  22. package/src/layout-engine/compound-flat.ts +0 -916
  23. package/src/layout-engine/constants.ts +0 -19
  24. package/src/layout-engine/edge-bundling.ts +0 -458
  25. package/src/layout-engine/elk-adapter-utils.ts +0 -81
  26. package/src/layout-engine/elk-graph-builder.ts +0 -245
  27. package/src/layout-engine/from-elk.ts +0 -767
  28. package/src/layout-engine/inner-edges.ts +0 -329
  29. package/src/layout-engine/jog-straightening.ts +0 -52
  30. package/src/layout-engine/layer-alignment.ts +0 -174
  31. package/src/layout-engine/layout-hints.ts +0 -27
  32. package/src/layout-engine/to-elk.ts +0 -855
  33. package/src/layout-engine.ts +0 -115
  34. package/src/pie/layout.ts +0 -234
  35. package/src/pie/renderer.ts +0 -161
  36. package/src/registry.ts +0 -441
  37. package/src/renderer.ts +0 -1510
  38. package/src/resolve-colors.ts +0 -339
  39. package/src/sequence/layout.ts +0 -766
  40. package/src/sequence/renderer.ts +0 -687
  41. package/src/shape-clipping.ts +0 -197
  42. package/src/styles.ts +0 -145
  43. package/src/title-gaps.ts +0 -161
  44. package/src/xychart/layout.ts +0 -727
  45. package/src/xychart/renderer.ts +0 -676
@@ -1,855 +0,0 @@
1
- /**
2
- * Graph conversion: MermaidGraph → ELK JSON.
3
- *
4
- * Split out of layout-engine.ts. This module only handles building ELK's
5
- * nested JSON input format from a parsed MermaidGraph — the reverse
6
- * direction (ELK result → PositionedGraph) lives in ./from-elk.ts.
7
- */
8
-
9
- import type { ElkNode, ElkExtendedEdge, LayoutOptions } from 'elkjs'
10
- import type {
11
- MermaidGraph,
12
- MermaidSubgraph,
13
- MermaidEdge,
14
- RenderOptions,
15
- } from '@zombie-mermaid/core'
16
- import type { FontSizes } from '../styles.ts'
17
- import { FONT_WEIGHTS, NODE_PADDING } from '../styles.ts'
18
- import { measureMultilineText } from '@zombie-mermaid/core'
19
- import { DEFAULTS } from './constants.ts'
20
- import { findBackEdgeIndexes } from './back-edges.ts'
21
- import type { LayoutHints } from './layout-hints.ts'
22
- import {
23
- ELK_DIRECTION_FALLBACK,
24
- INLINE_CENTERED_EDGE_LABEL,
25
- baseElkLayoutOptions,
26
- buildElkEdge,
27
- buildElkLeafNode,
28
- directionToElk as sharedDirectionToElk,
29
- type ElkDirection,
30
- type ElkEdgeLabelStyle,
31
- type ElkPaddingSides,
32
- } from './elk-graph-builder.ts'
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
- * Flowcharts deliberately do not get `MODEL_ORDER` cycle breaking (#1307).
52
- * They already break cycles the way mermaid.js does, by reversing the
53
- * depth-first back edges before ELK runs (`edgesReversedForLayout`), so ELK
54
- * has no cycle left to break; adding `MODEL_ORDER` on top only moved nodes
55
- * away from mermaid.js. Measured against mermaid 11.17.2 (relative placement
56
- * of every node pair, `scripts/layout-oracle.ts` style): applied to every
57
- * flowchart it changed 5 of the 26 gallery samples and 49 of 104 doc/test
58
- * flowcharts, closer for 0 and 2, further for 4 and 41; applied only to
59
- * graphs with a subgraph direction override (the one case where the
60
- * pre-reversal is skipped) it changed 1 and 7, closer for 0 and 2, further
61
- * for 1 and 5.
62
- *
63
- * - `cycleBreaking: MODEL_ORDER` reverses the edge that points *against*
64
- * declaration order instead of whichever edge ELK's greedy heuristic picks.
65
- * The greedy default reversed the main chain of `[*] --> Idle`,
66
- * `Idle --> Active`, `Active --> Idle`, drawing it bottom-to-top with the
67
- * start marker beside a state.
68
- * - `postCompaction: NONE`: LEFT_RIGHT_CONSTRAINT_LOCKING compaction slides
69
- * nodes sideways after edges are routed, which leaves state edges ending
70
- * in a horizontal run along the target's top border (arrowhead sideways).
71
- */
72
- const STATE_LAYOUT_OPTIONS: LayoutOptions = {
73
- 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
74
- 'elk.layered.compaction.postCompaction.strategy': 'NONE',
75
- }
76
-
77
- /**
78
- * Whether `graph` is a `stateDiagram` (it has `[*]` markers, the only thing
79
- * that distinguishes a state graph from a flowchart here). Composite states
80
- * are included: their compound nodes are placed among their siblings at
81
- * first-mention position (see `firstMentionRank`), so `MODEL_ORDER` reads
82
- * them in source order instead of after every leaf (#1287).
83
- */
84
- export function isStateGraph(graph: MermaidGraph): boolean {
85
- for (const node of graph.nodes.values()) {
86
- if (node.shape === 'state-start' || node.shape === 'state-end') return true
87
- }
88
- return false
89
- }
90
-
91
- /**
92
- * Room a subgraph's box leaves around its contents, in px. The top is the
93
- * title bar (28) plus a gap (16) to match the bottom. Shared with the compound
94
- * layout, which draws the boxes itself.
95
- */
96
- export const SUBGRAPH_PADDING = { top: 44, left: 16, bottom: 16, right: 16 }
97
-
98
- const reversedCache = new WeakMap<MermaidGraph, Set<number>>()
99
-
100
- /**
101
- * Indices of the edges handed to ELK reversed, so the layout breaks cycles the
102
- * way mermaid.js (dagre) does: see `findBackEdgeIndexes`. `elkToPositioned`
103
- * flips each one's points back so they still run source to target.
104
- *
105
- * Flowcharts only. State diagrams have their own cycle breaking (above), and a
106
- * graph with a subgraph direction override goes through hop edges and ports
107
- * that this does not rewrite.
108
- */
109
- export function edgesReversedForLayout(
110
- graph: MermaidGraph,
111
- hints?: LayoutHints,
112
- ): Set<number> {
113
- // Worked out once per graph object, by whichever of `mermaidToElk` (which
114
- // has the hints) and `elkToPositioned` (which doesn't need them) asks first.
115
- let set = reversedCache.get(graph)
116
- if (!set) {
117
- set =
118
- isStateGraph(graph) || hasAnyDirectionOverride(graph.subgraphs)
119
- ? new Set()
120
- : findBackEdgeIndexes(graph, hints?.walkOrder)
121
- reversedCache.set(graph, set)
122
- }
123
- return set
124
- }
125
-
126
- /**
127
- * Source position of a root-level state or composite: the index of the first
128
- * edge that names it. A composite no edge names itself falls back to the first
129
- * edge naming a state inside it. Items no edge mentions rank last, keeping
130
- * their relative order.
131
- */
132
- function firstMentionRank(graph: MermaidGraph, ids: Set<string>): number {
133
- for (let i = 0; i < graph.edges.length; i++) {
134
- const e = graph.edges[i]!
135
- if (ids.has(e.source) || ids.has(e.target)) return i
136
- }
137
- return Number.POSITIVE_INFINITY
138
- }
139
-
140
- /**
141
- * Convert a Mermaid direction to an ELK direction, flowchart/state-style
142
- * (no direction at all → `DOWN`; see `ELK_DIRECTION_FALLBACK`).
143
- *
144
- * `MermaidGraph.direction` is required, so the fallback is unreachable
145
- * from this path in practice — it exists so every `Direction` maps through
146
- * the one shared table.
147
- */
148
- function directionToElk(dir: MermaidGraph['direction']): ElkDirection {
149
- return sharedDirectionToElk(dir, ELK_DIRECTION_FALLBACK.flowchart)
150
- }
151
-
152
- /**
153
- * The `elk.*` options the root graph and every subgraph container share.
154
- * Both add `elk.direction`, `elk.padding` and their own extras on top.
155
- */
156
- function containerLayoutOptions(spec: {
157
- direction: ElkDirection
158
- nodeSpacing: number
159
- layerSpacing: number
160
- padding: number | ElkPaddingSides
161
- }): LayoutOptions {
162
- return {
163
- ...baseElkLayoutOptions(spec),
164
- 'elk.spacing.edgeEdge': '12',
165
- 'elk.layered.spacing.edgeEdgeBetweenLayers': '12',
166
- 'elk.layered.spacing.edgeNodeBetweenLayers': '12',
167
- 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
168
- 'elk.contentAlignment': 'H_CENTER V_CENTER',
169
- }
170
- }
171
-
172
- /**
173
- * How flowchart/state edge labels are measured and placed: at the resolved
174
- * edge-label font size, inline and centered on the edge.
175
- */
176
- function edgeLabelStyle(opts: { fontSizes: FontSizes }): ElkEdgeLabelStyle {
177
- return {
178
- fontSize: opts.fontSizes.edgeLabel,
179
- layoutOptions: INLINE_CENTERED_EDGE_LABEL,
180
- }
181
- }
182
-
183
- // ============================================================================
184
- // Node sizing
185
- // ============================================================================
186
-
187
- function estimateNodeSize(
188
- id: string,
189
- label: string,
190
- shape: string,
191
- nodeLabelFontSize: number,
192
- ): { width: number; height: number } {
193
- const metrics = measureMultilineText(
194
- label,
195
- nodeLabelFontSize,
196
- FONT_WEIGHTS.nodeLabel,
197
- )
198
-
199
- let width = metrics.width + NODE_PADDING.horizontal * 2
200
- let height = metrics.height + NODE_PADDING.vertical * 2
201
-
202
- if (shape === 'diamond') {
203
- const side = Math.max(width, height) + NODE_PADDING.diamondExtra
204
- width = side
205
- height = side
206
- }
207
-
208
- if (shape === 'circle' || shape === 'doublecircle') {
209
- const diameter = Math.ceil(Math.sqrt(width * width + height * height)) + 8
210
- width = shape === 'doublecircle' ? diameter + 12 : diameter
211
- height = width
212
- }
213
-
214
- if (shape === 'hexagon') {
215
- width += NODE_PADDING.horizontal
216
- }
217
-
218
- if (
219
- shape === 'trapezoid' ||
220
- shape === 'trapezoid-alt' ||
221
- shape === 'parallelogram' ||
222
- shape === 'parallelogram-alt'
223
- ) {
224
- // Sloped sides eat horizontal room at the label's baseline, so widen to
225
- // keep the label inside the polygon.
226
- width += NODE_PADDING.horizontal
227
- }
228
-
229
- if (shape === 'asymmetric') {
230
- width += 12
231
- }
232
-
233
- if (shape === 'cylinder') {
234
- height += 14
235
- }
236
-
237
- if (shape === 'state-start' || shape === 'state-end') {
238
- return { width: 28, height: 28 }
239
- }
240
-
241
- width = Math.max(width, 60)
242
- height = Math.max(height, 36)
243
-
244
- return { width, height }
245
- }
246
-
247
- // ============================================================================
248
- // Graph conversion: MermaidGraph → ELK JSON
249
- // ============================================================================
250
-
251
- export interface ElkGraphNode extends ElkNode {
252
- children?: ElkGraphNode[]
253
- edges?: ElkExtendedEdge[]
254
- }
255
-
256
- /** Sentinel container key for hop/bridge edges that belong at the root level. */
257
- const ROOT_CONTAINER = ' root'
258
-
259
- /**
260
- * Recursively check whether any subgraph (at any nesting depth) has a
261
- * direction override. The previous implementation only scanned top-level
262
- * subgraphs, silently missing overrides on nested subgraphs — which meant
263
- * `hierarchyHandling` stayed at `INCLUDE_CHILDREN` (where ELK ignores a
264
- * compound node's own `elk.direction`) for exactly the diagrams that need
265
- * `SEPARATE` the most.
266
- */
267
- export function hasAnyDirectionOverride(subgraphs: MermaidSubgraph[]): boolean {
268
- for (const sg of subgraphs) {
269
- if (sg.direction !== undefined) return true
270
- if (hasAnyDirectionOverride(sg.children)) return true
271
- }
272
- return false
273
- }
274
-
275
- /** Recursively collect every node ID that is a member of `sg`, including nested descendants. */
276
- function collectAllMemberNodeIds(sg: MermaidSubgraph, out: Set<string>): void {
277
- for (const id of sg.nodeIds) out.add(id)
278
- for (const child of sg.children) collectAllMemberNodeIds(child, out)
279
- }
280
-
281
- /**
282
- * Real mermaid.js ignores a subgraph's own `direction` override once any of
283
- * its member nodes (including nested descendants) has an edge to something
284
- * outside the subgraph — the subgraph then inherits its parent's direction
285
- * instead. Per the docs: "If any of a subgraph's nodes are linked to the
286
- * outside, subgraph direction will be ignored. Instead the subgraph will
287
- * inherit the direction of the parent graph."
288
- * https://mermaid.js.org/syntax/flowchart.html
289
- *
290
- * Verified against real mermaid.js output for nested subgraphs with
291
- * `direction LR` and an edge crossing the boundary (e.g. `B --> X` where X
292
- * is inside the subgraph): the crossing-linked nodes render at the same
293
- * x-coordinate with increasing y — stacked per the parent's direction,
294
- * despite the subgraph's own `direction LR`. Mirrors the equivalent ASCII
295
- * fix in src/ascii/converter.ts (subgraphDirectionIsHonored, issue #445).
296
- */
297
- function subgraphDirectionIsHonored(
298
- sg: MermaidSubgraph,
299
- graph: MermaidGraph,
300
- ): boolean {
301
- const memberIds = new Set<string>()
302
- collectAllMemberNodeIds(sg, memberIds)
303
- if (memberIds.size === 0) return true
304
-
305
- const isInside = (id: string): boolean => memberIds.has(id) || id === sg.id
306
-
307
- return !graph.edges.some((e) => isInside(e.source) !== isInside(e.target))
308
- }
309
-
310
- /**
311
- * Build a map from subgraph ID to its full ancestor chain, outermost first,
312
- * ending with the subgraph itself (e.g. Inner nested in Outer → ["Outer", "Inner"]).
313
- */
314
- function buildSubgraphChains(
315
- subgraphs: MermaidSubgraph[],
316
- ): Map<string, string[]> {
317
- const chains = new Map<string, string[]>()
318
- function walk(sg: MermaidSubgraph, ancestors: string[]): void {
319
- const chain = [...ancestors, sg.id]
320
- chains.set(sg.id, chain)
321
- for (const child of sg.children) walk(child, chain)
322
- }
323
- for (const sg of subgraphs) walk(sg, [])
324
- return chains
325
- }
326
-
327
- /** Length of the shared prefix of two ancestor chains. */
328
- function commonPrefixLength(a: string[], b: string[]): number {
329
- let i = 0
330
- while (i < a.length && i < b.length && a[i] === b[i]) i++
331
- return i
332
- }
333
-
334
- /**
335
- * Convert a MermaidGraph to ELK's nested JSON input format.
336
- *
337
- * Uses SEPARATE hierarchy handling for proper subgraph direction override support.
338
- * Cross-hierarchy edges use hierarchical ports to connect external and internal sections.
339
- */
340
- export function mermaidToElk(
341
- graph: MermaidGraph,
342
- givenOpts: Required<
343
- Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>
344
- > & { fontSizes: FontSizes },
345
- hints?: LayoutHints,
346
- ): ElkGraphNode {
347
- const opts = givenOpts
348
- // Collect all node IDs that belong to subgraphs
349
- const subgraphNodeIds = new Set<string>()
350
- const subgraphIds = new Set<string>()
351
- for (const sg of graph.subgraphs) {
352
- subgraphIds.add(sg.id)
353
- collectSubgraphNodeIds(sg, subgraphNodeIds, subgraphIds)
354
- }
355
-
356
- // Build node-to-subgraph mapping for edge distribution
357
- const nodeToSubgraph = buildNodeToSubgraphMap(graph.subgraphs)
358
-
359
- // Full ancestor chain (outermost → innermost) for every subgraph, used to
360
- // decompose edges that cross more than one subgraph boundary.
361
- const subgraphChains = buildSubgraphChains(graph.subgraphs)
362
- const chainOf = (sgId: string | undefined): string[] =>
363
- sgId ? (subgraphChains.get(sgId) ?? []) : []
364
-
365
- // Classify edges into three categories:
366
- // 1. Internal edges (both endpoints in the same innermost subgraph)
367
- // 2. Root-level edges (neither endpoint in a subgraph)
368
- // 3. Cross-hierarchy edges (endpoints in different levels, possibly
369
- // several subgraph boundaries apart)
370
- const edgesBySubgraph = new Map<
371
- string | null,
372
- Array<{ index: number; edge: (typeof graph.edges)[0] }>
373
- >()
374
- edgesBySubgraph.set(null, []) // Root-level edges
375
-
376
- // Cross-hierarchy edges carry the full ancestor chain for each endpoint so
377
- // we can decompose them into a chain of sub-edges (one per crossed
378
- // boundary) rather than assuming a single hop.
379
- const crossHierarchyEdges: Array<{
380
- index: number
381
- edge: (typeof graph.edges)[0]
382
- srcChain: string[]
383
- tgtChain: string[]
384
- }> = []
385
-
386
- for (let i = 0; i < graph.edges.length; i++) {
387
- const edge = graph.edges[i]!
388
- const sourceSubgraph = nodeToSubgraph.get(edge.source)
389
- const targetSubgraph = nodeToSubgraph.get(edge.target)
390
-
391
- if (sourceSubgraph && sourceSubgraph === targetSubgraph) {
392
- // Internal edge: both endpoints in same innermost subgraph
393
- const internal = edgesBySubgraph.get(sourceSubgraph) ?? []
394
- edgesBySubgraph.set(sourceSubgraph, internal)
395
- internal.push({ index: i, edge })
396
- } else if (!sourceSubgraph && !targetSubgraph) {
397
- // Root-level edge: neither endpoint in a subgraph
398
- edgesBySubgraph.get(null)!.push({ index: i, edge })
399
- } else {
400
- // Cross-hierarchy edge: may cross one or more subgraph boundaries
401
- crossHierarchyEdges.push({
402
- index: i,
403
- edge,
404
- srcChain: chainOf(sourceSubgraph),
405
- tgtChain: chainOf(targetSubgraph),
406
- })
407
- }
408
- }
409
-
410
- // Determine if we need SEPARATE hierarchy handling.
411
- // We use SEPARATE when any subgraph — at any nesting depth — has a
412
- // direction override, since ELK's INCLUDE_CHILDREN mode ignores a nested
413
- // compound node's own `elk.direction`.
414
- const hasDirectionOverride = hasAnyDirectionOverride(graph.subgraphs)
415
-
416
- // Root ELK graph's children/edges — built up below across several loops,
417
- // then assembled into the elkGraph object literal at the very end so its
418
- // fields never need to be re-read through the (optional-in-the-library)
419
- // ElkNode.children/edges types.
420
- const rootChildren: ElkGraphNode[] = []
421
- const rootEdges: ElkExtendedEdge[] = []
422
-
423
- // Build the root ELK graph's layout options up front — the graph object
424
- // itself is assembled at the end, once rootChildren/rootEdges are full.
425
- const rootLayoutOptions: LayoutOptions = {
426
- ...containerLayoutOptions({
427
- direction: directionToElk(graph.direction),
428
- nodeSpacing: opts.nodeSpacing,
429
- layerSpacing: opts.layerSpacing,
430
- padding: opts.padding,
431
- }),
432
- 'elk.layered.thoroughness': String(DEFAULTS.thoroughness),
433
- 'elk.layered.highDegreeNodes.treatment': 'true',
434
- 'elk.layered.highDegreeNodes.threshold': '8',
435
- 'elk.layered.compaction.postCompaction.strategy':
436
- 'LEFT_RIGHT_CONSTRAINT_LOCKING',
437
- 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
438
- // Every layer keeps the nodes in the order the graph lists them: the
439
- // compound layout relies on it to keep a subgraph's nodes together.
440
- ...(hints?.forceNodeOrder
441
- ? { 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true' }
442
- : {}),
443
- ...(isStateGraph(graph) ? STATE_LAYOUT_OPTIONS : {}),
444
- 'elk.layered.wrapping.strategy': 'OFF',
445
- // Use SEPARATE when subgraphs have direction overrides (enables proper direction handling)
446
- // Use INCLUDE_CHILDREN otherwise (simpler cross-hierarchy edge routing)
447
- 'elk.hierarchyHandling': hasDirectionOverride
448
- ? 'SEPARATE'
449
- : 'INCLUDE_CHILDREN',
450
- }
451
- // Ports to declare on each subgraph's ELK node, keyed by subgraph ID.
452
- const portsBySubgraph = new Map<string, Set<string>>()
453
- // Hop/bridge edges to inject into each container's own `edges` array.
454
- // Keyed by subgraph ID, or ROOT_CONTAINER for the top-level graph.
455
- const hopEdgesByContainer = new Map<string, ElkExtendedEdge[]>()
456
-
457
- function addPort(sgId: string, portId: string): void {
458
- const ports = portsBySubgraph.get(sgId) ?? new Set<string>()
459
- portsBySubgraph.set(sgId, ports)
460
- ports.add(portId)
461
- }
462
- function addHopEdge(containerId: string, elkEdge: ElkExtendedEdge): void {
463
- const edges = hopEdgesByContainer.get(containerId) ?? []
464
- hopEdgesByContainer.set(containerId, edges)
465
- edges.push(elkEdge)
466
- }
467
-
468
- // Decompose each cross-hierarchy edge into a chain of sub-edges, one per
469
- // subgraph boundary crossed, joined at explicit ELK ports. This is
470
- // required under `hierarchyHandling: SEPARATE`: ELK only resolves an edge
471
- // automatically when both endpoints are visible from the edge's container
472
- // (a direct child, or a port owned by the container or a direct child of
473
- // it) — it does not search further down the hierarchy. A single-hop port
474
- // (as used previously) is therefore only correct when one endpoint is a
475
- // direct child of the other endpoint's subgraph; for deeper nesting ELK
476
- // silently fails to route the edge at all (no sections in the result).
477
- //
478
- // For each side that needs to cross N boundaries to reach the lowest
479
- // common ancestor (LCA) of the source and target subgraphs, we add one
480
- // port per boundary and chain sub-edges between them, walking outward
481
- // from the endpoint to the LCA. The final "bridge" sub-edge (still ID
482
- // `e{index}`, carrying the edge label) lives directly in the LCA's own
483
- // `edges` array (or at root, if the LCA is the root graph).
484
- if (hasDirectionOverride) {
485
- for (const { index, edge, srcChain, tgtChain } of crossHierarchyEdges) {
486
- const commonLen = commonPrefixLength(srcChain, tgtChain)
487
-
488
- let sourceRef = edge.source
489
- if (srcChain.length > commonLen) {
490
- for (let i = srcChain.length - 1; i >= commonLen; i--) {
491
- const sgId = srcChain[i]!
492
- const portId = `${sgId}_out_${index}`
493
- addPort(sgId, portId)
494
- const fromRef =
495
- i === srcChain.length - 1
496
- ? edge.source
497
- : `${srcChain[i + 1]}_out_${index}`
498
- addHopEdge(sgId, {
499
- id: `e${index}_s${i}`,
500
- sources: [fromRef],
501
- targets: [portId],
502
- })
503
- }
504
- sourceRef = `${srcChain[commonLen]}_out_${index}`
505
- }
506
-
507
- let targetRef = edge.target
508
- if (tgtChain.length > commonLen) {
509
- for (let i = commonLen; i <= tgtChain.length - 1; i++) {
510
- const sgId = tgtChain[i]!
511
- const portId = `${sgId}_in_${index}`
512
- addPort(sgId, portId)
513
- const toRef =
514
- i === tgtChain.length - 1
515
- ? edge.target
516
- : `${tgtChain[i + 1]}_in_${index}`
517
- addHopEdge(sgId, {
518
- id: `e${index}_t${i}`,
519
- sources: [portId],
520
- targets: [toRef],
521
- })
522
- }
523
- targetRef = `${tgtChain[commonLen]}_in_${index}`
524
- }
525
-
526
- const bridgeContainer =
527
- commonLen > 0 ? srcChain[commonLen - 1]! : ROOT_CONTAINER
528
- addHopEdge(
529
- bridgeContainer,
530
- buildElkEdge({
531
- id: `e${index}`,
532
- source: sourceRef,
533
- target: targetRef,
534
- label: edge.label,
535
- labelStyle: edgeLabelStyle(opts),
536
- }),
537
- )
538
- }
539
- }
540
-
541
- // Add top-level nodes (those not in any subgraph).
542
- for (const [id, node] of graph.nodes) {
543
- if (!subgraphNodeIds.has(id) && !subgraphIds.has(id)) {
544
- const size = estimateNodeSize(
545
- id,
546
- node.label,
547
- node.shape,
548
- opts.fontSizes.nodeLabel,
549
- )
550
- const fixedWidth = hints?.fixedWidths?.get(id)
551
- if (fixedWidth !== undefined) size.width = fixedWidth
552
- const leaf = buildElkLeafNode(id, size, node.label)
553
- // A top-level start `[*]` marks where reading starts: pin it to the
554
- // first layer so a back-edge can't drag it into the middle. The end
555
- // marker stays free (see STATE_MARKER_LAYER_CONSTRAINT).
556
- const pin = isStateGraph(graph)
557
- ? STATE_MARKER_LAYER_CONSTRAINT[node.shape]
558
- : undefined
559
- if (pin !== undefined) {
560
- leaf.layoutOptions = { 'elk.layered.layering.layerConstraint': pin }
561
- }
562
- rootChildren.push(leaf)
563
- }
564
- }
565
-
566
- // Add subgraphs as compound nodes with children and their internal
567
- // edges. Sibling subgraphs are appended in *reverse* declaration order
568
- // (only relative to each other — their position as a group, before or
569
- // after the top-level leaf nodes above, is left as-is) to match real
570
- // mermaid.js's own sibling-subgraph order, which is what ELK's
571
- // `considerModelOrder` uses as a tie-break during crossing minimization
572
- // and thus what ultimately decides left-right sibling order.
573
- //
574
- // Verified against mermaid@11.17.2's bundled flowDb.getData()
575
- // (node_modules/mermaid/dist/mermaid.min.js): it builds the node list fed
576
- // to the layout engine by iterating the parsed `subGraphs` array
577
- // *backwards* to emit cluster/subgraph nodes. Since a compound node's
578
- // `children()` order in the resulting graph is exactly the order its
579
- // child nodes were inserted, sibling subgraphs under the same parent end
580
- // up in reversed declaration order — see issue #444.
581
- //
582
- // Deliberately scoped to *only* the sibling-subgraph reversal, not a
583
- // wholesale "all clusters before all leaves" reordering (which mermaid's
584
- // own mechanism also does, globally): moving the whole subgraphs group
585
- // ahead of top-level leaf nodes changes which edge in a cycle ELK treats
586
- // as a feedback edge. Cycles in a flowchart are broken before ELK sees
587
- // them (`edgesReversedForLayout`), so this ordering no longer decides that.
588
- const stateGraph = isStateGraph(graph)
589
- const leafCount = rootChildren.length
590
- // State diagrams read in source order, so composites keep forward
591
- // declaration order and are interleaved with the leaves below.
592
- const subgraphOrder = stateGraph
593
- ? graph.subgraphs
594
- : [...graph.subgraphs].reverse()
595
- for (const sg of subgraphOrder) {
596
- rootChildren.push(
597
- subgraphToElk(
598
- sg,
599
- graph,
600
- opts,
601
- edgesBySubgraph,
602
- portsBySubgraph,
603
- hopEdgesByContainer,
604
- graph.direction,
605
- ),
606
- )
607
- }
608
- if (stateGraph && graph.subgraphs.length > 0) {
609
- // ELK's MODEL_ORDER cycle breaking and crossing minimisation follow the
610
- // child order, so a composite must sit at its first-mention position
611
- // among its siblings rather than after every leaf (#1287). Array#sort is
612
- // stable, so equal ranks keep leaves-then-composites order.
613
- const rank = rootChildren.map((child, i) => {
614
- if (i < leafCount) return firstMentionRank(graph, new Set([child.id]))
615
- const sg = graph.subgraphs[i - leafCount]!
616
- const own = firstMentionRank(graph, new Set([sg.id]))
617
- if (own !== Number.POSITIVE_INFINITY) return own
618
- const members = new Set<string>()
619
- collectAllMemberNodeIds(sg, members)
620
- return firstMentionRank(graph, members)
621
- })
622
- const order = rootChildren
623
- .map((_, i) => i)
624
- .sort((a, b) => rank[a]! - rank[b]! || a - b)
625
- const sorted = order.map((i) => rootChildren[i]!)
626
- rootChildren.splice(0, rootChildren.length, ...sorted)
627
- }
628
-
629
- // Add root-level edges. Self-loops (edge.source === edge.target) are
630
- // excluded — ELK has no native self-loop layout and produces a degenerate
631
- // zero-length-span polyline for them; from-elk.ts synthesizes a proper
632
- // side loop for these once node positions are known instead.
633
- const reversed = edgesReversedForLayout(graph, hints)
634
- for (const { index, edge } of edgesBySubgraph.get(null)!) {
635
- if (edge.source === edge.target) continue
636
- if (hints?.detachedEdges?.has(index)) continue
637
- const elkEdge = buildElkEdge({
638
- id: `e${index}`,
639
- source: reversed.has(index) ? edge.target : edge.source,
640
- target: reversed.has(index) ? edge.source : edge.target,
641
- label: edge.label,
642
- labelStyle: edgeLabelStyle(opts),
643
- })
644
- if (hints?.looseEdges?.has(index)) {
645
- // Priority 0: the straightening pass leaves this edge alone.
646
- elkEdge.layoutOptions = {
647
- ...elkEdge.layoutOptions,
648
- 'elk.layered.priority.straightness': '0',
649
- }
650
- }
651
- rootEdges.push(elkEdge)
652
- }
653
-
654
- if (hasDirectionOverride) {
655
- // Cross-hierarchy edges were already decomposed into hop/bridge edges
656
- // above; the ones whose LCA is the root graph belong here.
657
- for (const elkEdge of hopEdgesByContainer.get(ROOT_CONTAINER) ?? []) {
658
- rootEdges.push(elkEdge)
659
- }
660
- } else {
661
- // No direction overrides anywhere: hierarchyHandling is INCLUDE_CHILDREN,
662
- // which lets ELK route cross-hierarchy edges automatically as long as
663
- // they're declared with their raw node IDs (no port decomposition
664
- // needed).
665
- for (const { index, edge } of crossHierarchyEdges) {
666
- rootEdges.push(
667
- buildElkEdge({
668
- id: `e${index}`,
669
- source: reversed.has(index) ? edge.target : edge.source,
670
- target: reversed.has(index) ? edge.source : edge.target,
671
- label: edge.label,
672
- labelStyle: edgeLabelStyle(opts),
673
- }),
674
- )
675
- }
676
- }
677
-
678
- return {
679
- id: 'root',
680
- layoutOptions: rootLayoutOptions,
681
- children: rootChildren,
682
- edges: rootEdges,
683
- }
684
- }
685
-
686
- /**
687
- * Convert a MermaidSubgraph to an ELK compound node.
688
- * Includes internal edges (edges where both endpoints are in this subgraph)
689
- * so that the subgraph's direction override is respected by ELK.
690
- *
691
- * When using SEPARATE hierarchy handling (for direction override support),
692
- * also adds hierarchical ports for cross-hierarchy edges.
693
- *
694
- * `inheritedDirection` is the effective direction of the nearest ancestor
695
- * (a subgraph's own honored override, or ultimately the root graph's
696
- * direction) — used when this subgraph's own override is dropped per
697
- * `subgraphDirectionIsHonored`. Explicitly propagating it, rather than
698
- * leaving `elk.direction` unset and relying on ELK's own property
699
- * inheritance under `hierarchyHandling: SEPARATE`, keeps the "inherit the
700
- * parent's direction" behavior correct and independent of ELK's inheritance
701
- * semantics for that property.
702
- */
703
- function subgraphToElk(
704
- sg: MermaidSubgraph,
705
- graph: MermaidGraph,
706
- opts: Required<
707
- Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>
708
- > & { fontSizes: FontSizes },
709
- edgesBySubgraph: Map<
710
- string | null,
711
- Array<{ index: number; edge: MermaidEdge }>
712
- >,
713
- portsBySubgraph: Map<string, Set<string>>,
714
- hopEdgesByContainer: Map<string, ElkExtendedEdge[]>,
715
- inheritedDirection: MermaidGraph['direction'],
716
- ): ElkGraphNode {
717
- // Apply this subgraph's own direction override only if it's actually
718
- // honored (no member node has an edge crossing the boundary); otherwise
719
- // fall back to the inherited (parent) direction, per mermaid.js's
720
- // documented precedence rule.
721
- const effectiveDirection =
722
- sg.direction !== undefined && subgraphDirectionIsHonored(sg, graph)
723
- ? sg.direction
724
- : inheritedDirection
725
-
726
- const layoutOptions: LayoutOptions = containerLayoutOptions({
727
- direction: directionToElk(effectiveDirection),
728
- nodeSpacing: opts.nodeSpacing,
729
- layerSpacing: opts.layerSpacing,
730
- padding: SUBGRAPH_PADDING,
731
- })
732
-
733
- // Ports, built before children/edges since they don't depend on them.
734
- let elkPorts: ElkNode['ports']
735
- const ports = portsBySubgraph.get(sg.id)
736
- if (ports && ports.size > 0) {
737
- elkPorts = [...ports].map((portId) => ({
738
- id: portId,
739
- // Port side is determined by ELK based on edge direction
740
- }))
741
- }
742
-
743
- // Add direct child (leaf) nodes, in forward declaration order.
744
- const children: ElkGraphNode[] = []
745
- for (const nodeId of sg.nodeIds) {
746
- const node = graph.nodes.get(nodeId)
747
- if (node) {
748
- const size = estimateNodeSize(
749
- nodeId,
750
- node.label,
751
- node.shape,
752
- opts.fontSizes.nodeLabel,
753
- )
754
- children.push(buildElkLeafNode(nodeId, size, node.label))
755
- }
756
- }
757
-
758
- // Add nested subgraphs recursively, in reverse declaration order relative
759
- // to *each other* only (their position as a group, before or after the
760
- // leaf nodes above, is left as-is — see the matching comment and
761
- // rationale in `mermaidToElk`) — matching real mermaid.js's own sibling-
762
- // subgraph order (issue #444).
763
- for (const child of [...sg.children].reverse()) {
764
- children.push(
765
- subgraphToElk(
766
- child,
767
- graph,
768
- opts,
769
- edgesBySubgraph,
770
- portsBySubgraph,
771
- hopEdgesByContainer,
772
- effectiveDirection,
773
- ),
774
- )
775
- }
776
-
777
- // Add internal edges (edges where both endpoints are in this subgraph).
778
- // Self-loops are excluded — see the matching comment in mermaidToElk.
779
- const edges: ElkExtendedEdge[] = []
780
- const internalEdges = edgesBySubgraph.get(sg.id) ?? []
781
- const reversed = edgesReversedForLayout(graph)
782
- for (const { index, edge } of internalEdges) {
783
- if (edge.source === edge.target) continue
784
- edges.push(
785
- buildElkEdge({
786
- id: `e${index}`,
787
- source: reversed.has(index) ? edge.target : edge.source,
788
- target: reversed.has(index) ? edge.source : edge.target,
789
- label: edge.label,
790
- labelStyle: edgeLabelStyle(opts),
791
- }),
792
- )
793
- }
794
-
795
- // Add hop/bridge edges whose lowest common ancestor is this subgraph.
796
- // These connect boundary ports to internal nodes (single-hop case), chain
797
- // ports between two nested levels (multi-hop case), or bridge two ports
798
- // belonging to direct children of this subgraph (the actual crossing
799
- // point between the source-side and target-side chains).
800
- for (const elkEdge of hopEdgesByContainer.get(sg.id) ?? []) {
801
- edges.push(elkEdge)
802
- }
803
-
804
- return {
805
- id: sg.id,
806
- layoutOptions,
807
- labels: sg.label ? [{ text: sg.label }] : undefined,
808
- ports: elkPorts,
809
- children,
810
- edges,
811
- }
812
- }
813
-
814
- /** Recursively collect all node IDs that belong to any subgraph */
815
- function collectSubgraphNodeIds(
816
- sg: MermaidSubgraph,
817
- nodeIds: Set<string>,
818
- subgraphIds: Set<string>,
819
- ): void {
820
- for (const id of sg.nodeIds) {
821
- nodeIds.add(id)
822
- }
823
- for (const child of sg.children) {
824
- subgraphIds.add(child.id)
825
- collectSubgraphNodeIds(child, nodeIds, subgraphIds)
826
- }
827
- }
828
-
829
- /**
830
- * Build a mapping from node ID to its containing subgraph ID.
831
- * For nested subgraphs, maps to the innermost containing subgraph.
832
- * Nodes not in any subgraph are not included in the map.
833
- */
834
- function buildNodeToSubgraphMap(
835
- subgraphs: MermaidSubgraph[],
836
- ): Map<string, string> {
837
- const map = new Map<string, string>()
838
-
839
- function traverse(sg: MermaidSubgraph): void {
840
- // Map all direct child nodes to this subgraph
841
- for (const nodeId of sg.nodeIds) {
842
- map.set(nodeId, sg.id)
843
- }
844
- // Recursively process nested subgraphs (they override parent mapping)
845
- for (const child of sg.children) {
846
- traverse(child)
847
- }
848
- }
849
-
850
- for (const sg of subgraphs) {
851
- traverse(sg)
852
- }
853
-
854
- return map
855
- }