@zombie-mermaid/svg-renderer 2.2.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.
@@ -0,0 +1,695 @@
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 {
21
+ ELK_DIRECTION_FALLBACK,
22
+ INLINE_CENTERED_EDGE_LABEL,
23
+ baseElkLayoutOptions,
24
+ buildElkEdge,
25
+ buildElkLeafNode,
26
+ directionToElk as sharedDirectionToElk,
27
+ type ElkDirection,
28
+ type ElkEdgeLabelStyle,
29
+ type ElkPaddingSides,
30
+ } from './elk-graph-builder.ts'
31
+
32
+ /**
33
+ * Convert a Mermaid direction to an ELK direction, flowchart/state-style
34
+ * (no direction at all → `DOWN`; see `ELK_DIRECTION_FALLBACK`).
35
+ *
36
+ * `MermaidGraph.direction` is required, so the fallback is unreachable
37
+ * from this path in practice — it exists so every `Direction` maps through
38
+ * the one shared table.
39
+ */
40
+ function directionToElk(dir: MermaidGraph['direction']): ElkDirection {
41
+ return sharedDirectionToElk(dir, ELK_DIRECTION_FALLBACK.flowchart)
42
+ }
43
+
44
+ /**
45
+ * The `elk.*` options the root graph and every subgraph container share.
46
+ * Both add `elk.direction`, `elk.padding` and their own extras on top.
47
+ */
48
+ function containerLayoutOptions(spec: {
49
+ direction: ElkDirection
50
+ nodeSpacing: number
51
+ layerSpacing: number
52
+ padding: number | ElkPaddingSides
53
+ }): LayoutOptions {
54
+ return {
55
+ ...baseElkLayoutOptions(spec),
56
+ 'elk.spacing.edgeEdge': '12',
57
+ 'elk.layered.spacing.edgeEdgeBetweenLayers': '12',
58
+ 'elk.layered.spacing.edgeNodeBetweenLayers': '12',
59
+ 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
60
+ 'elk.contentAlignment': 'H_CENTER V_CENTER',
61
+ }
62
+ }
63
+
64
+ /**
65
+ * How flowchart/state edge labels are measured and placed: at the resolved
66
+ * edge-label font size, inline and centered on the edge.
67
+ */
68
+ function edgeLabelStyle(opts: { fontSizes: FontSizes }): ElkEdgeLabelStyle {
69
+ return {
70
+ fontSize: opts.fontSizes.edgeLabel,
71
+ layoutOptions: INLINE_CENTERED_EDGE_LABEL,
72
+ }
73
+ }
74
+
75
+ // ============================================================================
76
+ // Node sizing
77
+ // ============================================================================
78
+
79
+ function estimateNodeSize(
80
+ id: string,
81
+ label: string,
82
+ shape: string,
83
+ nodeLabelFontSize: number,
84
+ ): { width: number; height: number } {
85
+ const metrics = measureMultilineText(
86
+ label,
87
+ nodeLabelFontSize,
88
+ FONT_WEIGHTS.nodeLabel,
89
+ )
90
+
91
+ let width = metrics.width + NODE_PADDING.horizontal * 2
92
+ let height = metrics.height + NODE_PADDING.vertical * 2
93
+
94
+ if (shape === 'diamond') {
95
+ const side = Math.max(width, height) + NODE_PADDING.diamondExtra
96
+ width = side
97
+ height = side
98
+ }
99
+
100
+ if (shape === 'circle' || shape === 'doublecircle') {
101
+ const diameter = Math.ceil(Math.sqrt(width * width + height * height)) + 8
102
+ width = shape === 'doublecircle' ? diameter + 12 : diameter
103
+ height = width
104
+ }
105
+
106
+ if (shape === 'hexagon') {
107
+ width += NODE_PADDING.horizontal
108
+ }
109
+
110
+ if (
111
+ shape === 'trapezoid' ||
112
+ shape === 'trapezoid-alt' ||
113
+ shape === 'parallelogram' ||
114
+ shape === 'parallelogram-alt'
115
+ ) {
116
+ // Sloped sides eat horizontal room at the label's baseline, so widen to
117
+ // keep the label inside the polygon.
118
+ width += NODE_PADDING.horizontal
119
+ }
120
+
121
+ if (shape === 'asymmetric') {
122
+ width += 12
123
+ }
124
+
125
+ if (shape === 'cylinder') {
126
+ height += 14
127
+ }
128
+
129
+ if (shape === 'state-start' || shape === 'state-end') {
130
+ return { width: 28, height: 28 }
131
+ }
132
+
133
+ width = Math.max(width, 60)
134
+ height = Math.max(height, 36)
135
+
136
+ return { width, height }
137
+ }
138
+
139
+ // ============================================================================
140
+ // Graph conversion: MermaidGraph → ELK JSON
141
+ // ============================================================================
142
+
143
+ export interface ElkGraphNode extends ElkNode {
144
+ children?: ElkGraphNode[]
145
+ edges?: ElkExtendedEdge[]
146
+ }
147
+
148
+ /** Sentinel container key for hop/bridge edges that belong at the root level. */
149
+ const ROOT_CONTAINER = ' root'
150
+
151
+ /**
152
+ * Recursively check whether any subgraph (at any nesting depth) has a
153
+ * direction override. The previous implementation only scanned top-level
154
+ * subgraphs, silently missing overrides on nested subgraphs — which meant
155
+ * `hierarchyHandling` stayed at `INCLUDE_CHILDREN` (where ELK ignores a
156
+ * compound node's own `elk.direction`) for exactly the diagrams that need
157
+ * `SEPARATE` the most.
158
+ */
159
+ function hasAnyDirectionOverride(subgraphs: MermaidSubgraph[]): boolean {
160
+ for (const sg of subgraphs) {
161
+ if (sg.direction !== undefined) return true
162
+ if (hasAnyDirectionOverride(sg.children)) return true
163
+ }
164
+ return false
165
+ }
166
+
167
+ /** Recursively collect every node ID that is a member of `sg`, including nested descendants. */
168
+ function collectAllMemberNodeIds(sg: MermaidSubgraph, out: Set<string>): void {
169
+ for (const id of sg.nodeIds) out.add(id)
170
+ for (const child of sg.children) collectAllMemberNodeIds(child, out)
171
+ }
172
+
173
+ /**
174
+ * Real mermaid.js ignores a subgraph's own `direction` override once any of
175
+ * its member nodes (including nested descendants) has an edge to something
176
+ * outside the subgraph — the subgraph then inherits its parent's direction
177
+ * instead. Per the docs: "If any of a subgraph's nodes are linked to the
178
+ * outside, subgraph direction will be ignored. Instead the subgraph will
179
+ * inherit the direction of the parent graph."
180
+ * https://mermaid.js.org/syntax/flowchart.html
181
+ *
182
+ * Verified against real mermaid.js output for nested subgraphs with
183
+ * `direction LR` and an edge crossing the boundary (e.g. `B --> X` where X
184
+ * is inside the subgraph): the crossing-linked nodes render at the same
185
+ * x-coordinate with increasing y — stacked per the parent's direction,
186
+ * despite the subgraph's own `direction LR`. Mirrors the equivalent ASCII
187
+ * fix in src/ascii/converter.ts (subgraphDirectionIsHonored, issue #445).
188
+ */
189
+ function subgraphDirectionIsHonored(
190
+ sg: MermaidSubgraph,
191
+ graph: MermaidGraph,
192
+ ): boolean {
193
+ const memberIds = new Set<string>()
194
+ collectAllMemberNodeIds(sg, memberIds)
195
+ if (memberIds.size === 0) return true
196
+
197
+ const isInside = (id: string): boolean => memberIds.has(id) || id === sg.id
198
+
199
+ return !graph.edges.some((e) => isInside(e.source) !== isInside(e.target))
200
+ }
201
+
202
+ /**
203
+ * Build a map from subgraph ID to its full ancestor chain, outermost first,
204
+ * ending with the subgraph itself (e.g. Inner nested in Outer → ["Outer", "Inner"]).
205
+ */
206
+ function buildSubgraphChains(
207
+ subgraphs: MermaidSubgraph[],
208
+ ): Map<string, string[]> {
209
+ const chains = new Map<string, string[]>()
210
+ function walk(sg: MermaidSubgraph, ancestors: string[]): void {
211
+ const chain = [...ancestors, sg.id]
212
+ chains.set(sg.id, chain)
213
+ for (const child of sg.children) walk(child, chain)
214
+ }
215
+ for (const sg of subgraphs) walk(sg, [])
216
+ return chains
217
+ }
218
+
219
+ /** Length of the shared prefix of two ancestor chains. */
220
+ function commonPrefixLength(a: string[], b: string[]): number {
221
+ let i = 0
222
+ while (i < a.length && i < b.length && a[i] === b[i]) i++
223
+ return i
224
+ }
225
+
226
+ /**
227
+ * Convert a MermaidGraph to ELK's nested JSON input format.
228
+ *
229
+ * Uses SEPARATE hierarchy handling for proper subgraph direction override support.
230
+ * Cross-hierarchy edges use hierarchical ports to connect external and internal sections.
231
+ */
232
+ export function mermaidToElk(
233
+ graph: MermaidGraph,
234
+ opts: Required<
235
+ Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>
236
+ > & { fontSizes: FontSizes },
237
+ ): ElkGraphNode {
238
+ // Collect all node IDs that belong to subgraphs
239
+ const subgraphNodeIds = new Set<string>()
240
+ const subgraphIds = new Set<string>()
241
+ for (const sg of graph.subgraphs) {
242
+ subgraphIds.add(sg.id)
243
+ collectSubgraphNodeIds(sg, subgraphNodeIds, subgraphIds)
244
+ }
245
+
246
+ // Build node-to-subgraph mapping for edge distribution
247
+ const nodeToSubgraph = buildNodeToSubgraphMap(graph.subgraphs)
248
+
249
+ // Full ancestor chain (outermost → innermost) for every subgraph, used to
250
+ // decompose edges that cross more than one subgraph boundary.
251
+ const subgraphChains = buildSubgraphChains(graph.subgraphs)
252
+ const chainOf = (sgId: string | undefined): string[] =>
253
+ sgId ? (subgraphChains.get(sgId) ?? []) : []
254
+
255
+ // Classify edges into three categories:
256
+ // 1. Internal edges (both endpoints in the same innermost subgraph)
257
+ // 2. Root-level edges (neither endpoint in a subgraph)
258
+ // 3. Cross-hierarchy edges (endpoints in different levels, possibly
259
+ // several subgraph boundaries apart)
260
+ const edgesBySubgraph = new Map<
261
+ string | null,
262
+ Array<{ index: number; edge: (typeof graph.edges)[0] }>
263
+ >()
264
+ edgesBySubgraph.set(null, []) // Root-level edges
265
+
266
+ // Cross-hierarchy edges carry the full ancestor chain for each endpoint so
267
+ // we can decompose them into a chain of sub-edges (one per crossed
268
+ // boundary) rather than assuming a single hop.
269
+ const crossHierarchyEdges: Array<{
270
+ index: number
271
+ edge: (typeof graph.edges)[0]
272
+ srcChain: string[]
273
+ tgtChain: string[]
274
+ }> = []
275
+
276
+ for (let i = 0; i < graph.edges.length; i++) {
277
+ const edge = graph.edges[i]!
278
+ const sourceSubgraph = nodeToSubgraph.get(edge.source)
279
+ const targetSubgraph = nodeToSubgraph.get(edge.target)
280
+
281
+ if (sourceSubgraph && sourceSubgraph === targetSubgraph) {
282
+ // Internal edge: both endpoints in same innermost subgraph
283
+ const internal = edgesBySubgraph.get(sourceSubgraph) ?? []
284
+ edgesBySubgraph.set(sourceSubgraph, internal)
285
+ internal.push({ index: i, edge })
286
+ } else if (!sourceSubgraph && !targetSubgraph) {
287
+ // Root-level edge: neither endpoint in a subgraph
288
+ edgesBySubgraph.get(null)!.push({ index: i, edge })
289
+ } else {
290
+ // Cross-hierarchy edge: may cross one or more subgraph boundaries
291
+ crossHierarchyEdges.push({
292
+ index: i,
293
+ edge,
294
+ srcChain: chainOf(sourceSubgraph),
295
+ tgtChain: chainOf(targetSubgraph),
296
+ })
297
+ }
298
+ }
299
+
300
+ // Determine if we need SEPARATE hierarchy handling.
301
+ // We use SEPARATE when any subgraph — at any nesting depth — has a
302
+ // direction override, since ELK's INCLUDE_CHILDREN mode ignores a nested
303
+ // compound node's own `elk.direction`.
304
+ const hasDirectionOverride = hasAnyDirectionOverride(graph.subgraphs)
305
+
306
+ // Root ELK graph's children/edges — built up below across several loops,
307
+ // then assembled into the elkGraph object literal at the very end so its
308
+ // fields never need to be re-read through the (optional-in-the-library)
309
+ // ElkNode.children/edges types.
310
+ const rootChildren: ElkGraphNode[] = []
311
+ const rootEdges: ElkExtendedEdge[] = []
312
+
313
+ // Build the root ELK graph's layout options up front — the graph object
314
+ // itself is assembled at the end, once rootChildren/rootEdges are full.
315
+ const rootLayoutOptions: LayoutOptions = {
316
+ ...containerLayoutOptions({
317
+ direction: directionToElk(graph.direction),
318
+ nodeSpacing: opts.nodeSpacing,
319
+ layerSpacing: opts.layerSpacing,
320
+ padding: opts.padding,
321
+ }),
322
+ 'elk.layered.thoroughness': String(DEFAULTS.thoroughness),
323
+ 'elk.layered.highDegreeNodes.treatment': 'true',
324
+ 'elk.layered.highDegreeNodes.threshold': '8',
325
+ 'elk.layered.compaction.postCompaction.strategy':
326
+ 'LEFT_RIGHT_CONSTRAINT_LOCKING',
327
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
328
+ 'elk.layered.wrapping.strategy': 'OFF',
329
+ // Use SEPARATE when subgraphs have direction overrides (enables proper direction handling)
330
+ // Use INCLUDE_CHILDREN otherwise (simpler cross-hierarchy edge routing)
331
+ 'elk.hierarchyHandling': hasDirectionOverride
332
+ ? 'SEPARATE'
333
+ : 'INCLUDE_CHILDREN',
334
+ }
335
+
336
+ // Ports to declare on each subgraph's ELK node, keyed by subgraph ID.
337
+ const portsBySubgraph = new Map<string, Set<string>>()
338
+ // Hop/bridge edges to inject into each container's own `edges` array.
339
+ // Keyed by subgraph ID, or ROOT_CONTAINER for the top-level graph.
340
+ const hopEdgesByContainer = new Map<string, ElkExtendedEdge[]>()
341
+
342
+ function addPort(sgId: string, portId: string): void {
343
+ const ports = portsBySubgraph.get(sgId) ?? new Set<string>()
344
+ portsBySubgraph.set(sgId, ports)
345
+ ports.add(portId)
346
+ }
347
+ function addHopEdge(containerId: string, elkEdge: ElkExtendedEdge): void {
348
+ const edges = hopEdgesByContainer.get(containerId) ?? []
349
+ hopEdgesByContainer.set(containerId, edges)
350
+ edges.push(elkEdge)
351
+ }
352
+
353
+ // Decompose each cross-hierarchy edge into a chain of sub-edges, one per
354
+ // subgraph boundary crossed, joined at explicit ELK ports. This is
355
+ // required under `hierarchyHandling: SEPARATE`: ELK only resolves an edge
356
+ // automatically when both endpoints are visible from the edge's container
357
+ // (a direct child, or a port owned by the container or a direct child of
358
+ // it) — it does not search further down the hierarchy. A single-hop port
359
+ // (as used previously) is therefore only correct when one endpoint is a
360
+ // direct child of the other endpoint's subgraph; for deeper nesting ELK
361
+ // silently fails to route the edge at all (no sections in the result).
362
+ //
363
+ // For each side that needs to cross N boundaries to reach the lowest
364
+ // common ancestor (LCA) of the source and target subgraphs, we add one
365
+ // port per boundary and chain sub-edges between them, walking outward
366
+ // from the endpoint to the LCA. The final "bridge" sub-edge (still ID
367
+ // `e{index}`, carrying the edge label) lives directly in the LCA's own
368
+ // `edges` array (or at root, if the LCA is the root graph).
369
+ if (hasDirectionOverride) {
370
+ for (const { index, edge, srcChain, tgtChain } of crossHierarchyEdges) {
371
+ const commonLen = commonPrefixLength(srcChain, tgtChain)
372
+
373
+ let sourceRef = edge.source
374
+ if (srcChain.length > commonLen) {
375
+ for (let i = srcChain.length - 1; i >= commonLen; i--) {
376
+ const sgId = srcChain[i]!
377
+ const portId = `${sgId}_out_${index}`
378
+ addPort(sgId, portId)
379
+ const fromRef =
380
+ i === srcChain.length - 1
381
+ ? edge.source
382
+ : `${srcChain[i + 1]}_out_${index}`
383
+ addHopEdge(sgId, {
384
+ id: `e${index}_s${i}`,
385
+ sources: [fromRef],
386
+ targets: [portId],
387
+ })
388
+ }
389
+ sourceRef = `${srcChain[commonLen]}_out_${index}`
390
+ }
391
+
392
+ let targetRef = edge.target
393
+ if (tgtChain.length > commonLen) {
394
+ for (let i = commonLen; i <= tgtChain.length - 1; i++) {
395
+ const sgId = tgtChain[i]!
396
+ const portId = `${sgId}_in_${index}`
397
+ addPort(sgId, portId)
398
+ const toRef =
399
+ i === tgtChain.length - 1
400
+ ? edge.target
401
+ : `${tgtChain[i + 1]}_in_${index}`
402
+ addHopEdge(sgId, {
403
+ id: `e${index}_t${i}`,
404
+ sources: [portId],
405
+ targets: [toRef],
406
+ })
407
+ }
408
+ targetRef = `${tgtChain[commonLen]}_in_${index}`
409
+ }
410
+
411
+ const bridgeContainer =
412
+ commonLen > 0 ? srcChain[commonLen - 1]! : ROOT_CONTAINER
413
+ addHopEdge(
414
+ bridgeContainer,
415
+ buildElkEdge({
416
+ id: `e${index}`,
417
+ source: sourceRef,
418
+ target: targetRef,
419
+ label: edge.label,
420
+ labelStyle: edgeLabelStyle(opts),
421
+ }),
422
+ )
423
+ }
424
+ }
425
+
426
+ // Add top-level nodes (those not in any subgraph).
427
+ for (const [id, node] of graph.nodes) {
428
+ if (!subgraphNodeIds.has(id) && !subgraphIds.has(id)) {
429
+ const size = estimateNodeSize(
430
+ id,
431
+ node.label,
432
+ node.shape,
433
+ opts.fontSizes.nodeLabel,
434
+ )
435
+ rootChildren.push(buildElkLeafNode(id, size, node.label))
436
+ }
437
+ }
438
+
439
+ // Add subgraphs as compound nodes with children and their internal
440
+ // edges. Sibling subgraphs are appended in *reverse* declaration order
441
+ // (only relative to each other — their position as a group, before or
442
+ // after the top-level leaf nodes above, is left as-is) to match real
443
+ // mermaid.js's own sibling-subgraph order, which is what ELK's
444
+ // `considerModelOrder` uses as a tie-break during crossing minimization
445
+ // and thus what ultimately decides left-right sibling order.
446
+ //
447
+ // Verified against mermaid@11.17.2's bundled flowDb.getData()
448
+ // (node_modules/mermaid/dist/mermaid.min.js): it builds the node list fed
449
+ // to the layout engine by iterating the parsed `subGraphs` array
450
+ // *backwards* to emit cluster/subgraph nodes. Since a compound node's
451
+ // `children()` order in the resulting graph is exactly the order its
452
+ // child nodes were inserted, sibling subgraphs under the same parent end
453
+ // up in reversed declaration order — see issue #444.
454
+ //
455
+ // Deliberately scoped to *only* the sibling-subgraph reversal, not a
456
+ // wholesale "all clusters before all leaves" reordering (which mermaid's
457
+ // own mechanism also does, globally): moving the whole subgraphs group
458
+ // ahead of top-level leaf nodes changed which edge in a cycle ELK treats
459
+ // as a feedback edge for a sample mixing top-level leaves and a
460
+ // subgraph in a cyclic flow (e.g. "CI/CD Pipeline"), reordering the
461
+ // entire rank structure rather than just left-right sibling position —
462
+ // a much bigger, unreviewed blast radius than the reported bug needs.
463
+ for (const sg of [...graph.subgraphs].reverse()) {
464
+ rootChildren.push(
465
+ subgraphToElk(
466
+ sg,
467
+ graph,
468
+ opts,
469
+ edgesBySubgraph,
470
+ portsBySubgraph,
471
+ hopEdgesByContainer,
472
+ graph.direction,
473
+ ),
474
+ )
475
+ }
476
+
477
+ // Add root-level edges. Self-loops (edge.source === edge.target) are
478
+ // excluded — ELK has no native self-loop layout and produces a degenerate
479
+ // zero-length-span polyline for them; from-elk.ts synthesizes a proper
480
+ // side loop for these once node positions are known instead.
481
+ for (const { index, edge } of edgesBySubgraph.get(null)!) {
482
+ 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
+ )
492
+ }
493
+
494
+ if (hasDirectionOverride) {
495
+ // Cross-hierarchy edges were already decomposed into hop/bridge edges
496
+ // above; the ones whose LCA is the root graph belong here.
497
+ for (const elkEdge of hopEdgesByContainer.get(ROOT_CONTAINER) ?? []) {
498
+ rootEdges.push(elkEdge)
499
+ }
500
+ } else {
501
+ // No direction overrides anywhere: hierarchyHandling is INCLUDE_CHILDREN,
502
+ // which lets ELK route cross-hierarchy edges automatically as long as
503
+ // they're declared with their raw node IDs (no port decomposition
504
+ // needed).
505
+ for (const { index, edge } of crossHierarchyEdges) {
506
+ rootEdges.push(
507
+ buildElkEdge({
508
+ id: `e${index}`,
509
+ source: edge.source,
510
+ target: edge.target,
511
+ label: edge.label,
512
+ labelStyle: edgeLabelStyle(opts),
513
+ }),
514
+ )
515
+ }
516
+ }
517
+
518
+ return {
519
+ id: 'root',
520
+ layoutOptions: rootLayoutOptions,
521
+ children: rootChildren,
522
+ edges: rootEdges,
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Convert a MermaidSubgraph to an ELK compound node.
528
+ * Includes internal edges (edges where both endpoints are in this subgraph)
529
+ * so that the subgraph's direction override is respected by ELK.
530
+ *
531
+ * When using SEPARATE hierarchy handling (for direction override support),
532
+ * also adds hierarchical ports for cross-hierarchy edges.
533
+ *
534
+ * `inheritedDirection` is the effective direction of the nearest ancestor
535
+ * (a subgraph's own honored override, or ultimately the root graph's
536
+ * direction) — used when this subgraph's own override is dropped per
537
+ * `subgraphDirectionIsHonored`. Explicitly propagating it, rather than
538
+ * leaving `elk.direction` unset and relying on ELK's own property
539
+ * inheritance under `hierarchyHandling: SEPARATE`, keeps the "inherit the
540
+ * parent's direction" behavior correct and independent of ELK's inheritance
541
+ * semantics for that property.
542
+ */
543
+ function subgraphToElk(
544
+ sg: MermaidSubgraph,
545
+ graph: MermaidGraph,
546
+ opts: Required<
547
+ Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>
548
+ > & { fontSizes: FontSizes },
549
+ edgesBySubgraph: Map<
550
+ string | null,
551
+ Array<{ index: number; edge: MermaidEdge }>
552
+ >,
553
+ portsBySubgraph: Map<string, Set<string>>,
554
+ hopEdgesByContainer: Map<string, ElkExtendedEdge[]>,
555
+ inheritedDirection: MermaidGraph['direction'],
556
+ ): ElkGraphNode {
557
+ // Apply this subgraph's own direction override only if it's actually
558
+ // honored (no member node has an edge crossing the boundary); otherwise
559
+ // fall back to the inherited (parent) direction, per mermaid.js's
560
+ // documented precedence rule.
561
+ const effectiveDirection =
562
+ sg.direction !== undefined && subgraphDirectionIsHonored(sg, graph)
563
+ ? sg.direction
564
+ : inheritedDirection
565
+
566
+ const layoutOptions: LayoutOptions = containerLayoutOptions({
567
+ direction: directionToElk(effectiveDirection),
568
+ nodeSpacing: opts.nodeSpacing,
569
+ layerSpacing: opts.layerSpacing,
570
+ // Top = headerHeight(28) + gap(16) to match bottom padding
571
+ padding: { top: 44, left: 16, bottom: 16, right: 16 },
572
+ })
573
+
574
+ // Ports, built before children/edges since they don't depend on them.
575
+ let elkPorts: ElkNode['ports']
576
+ const ports = portsBySubgraph.get(sg.id)
577
+ if (ports && ports.size > 0) {
578
+ elkPorts = [...ports].map((portId) => ({
579
+ id: portId,
580
+ // Port side is determined by ELK based on edge direction
581
+ }))
582
+ }
583
+
584
+ // Add direct child (leaf) nodes, in forward declaration order.
585
+ const children: ElkGraphNode[] = []
586
+ for (const nodeId of sg.nodeIds) {
587
+ const node = graph.nodes.get(nodeId)
588
+ if (node) {
589
+ const size = estimateNodeSize(
590
+ nodeId,
591
+ node.label,
592
+ node.shape,
593
+ opts.fontSizes.nodeLabel,
594
+ )
595
+ children.push(buildElkLeafNode(nodeId, size, node.label))
596
+ }
597
+ }
598
+
599
+ // Add nested subgraphs recursively, in reverse declaration order relative
600
+ // to *each other* only (their position as a group, before or after the
601
+ // leaf nodes above, is left as-is — see the matching comment and
602
+ // rationale in `mermaidToElk`) — matching real mermaid.js's own sibling-
603
+ // subgraph order (issue #444).
604
+ for (const child of [...sg.children].reverse()) {
605
+ children.push(
606
+ subgraphToElk(
607
+ child,
608
+ graph,
609
+ opts,
610
+ edgesBySubgraph,
611
+ portsBySubgraph,
612
+ hopEdgesByContainer,
613
+ effectiveDirection,
614
+ ),
615
+ )
616
+ }
617
+
618
+ // Add internal edges (edges where both endpoints are in this subgraph).
619
+ // Self-loops are excluded — see the matching comment in mermaidToElk.
620
+ const edges: ElkExtendedEdge[] = []
621
+ const internalEdges = edgesBySubgraph.get(sg.id) ?? []
622
+ for (const { index, edge } of internalEdges) {
623
+ if (edge.source === edge.target) continue
624
+ edges.push(
625
+ buildElkEdge({
626
+ id: `e${index}`,
627
+ source: edge.source,
628
+ target: edge.target,
629
+ label: edge.label,
630
+ labelStyle: edgeLabelStyle(opts),
631
+ }),
632
+ )
633
+ }
634
+
635
+ // Add hop/bridge edges whose lowest common ancestor is this subgraph.
636
+ // These connect boundary ports to internal nodes (single-hop case), chain
637
+ // ports between two nested levels (multi-hop case), or bridge two ports
638
+ // belonging to direct children of this subgraph (the actual crossing
639
+ // point between the source-side and target-side chains).
640
+ for (const elkEdge of hopEdgesByContainer.get(sg.id) ?? []) {
641
+ edges.push(elkEdge)
642
+ }
643
+
644
+ return {
645
+ id: sg.id,
646
+ layoutOptions,
647
+ labels: sg.label ? [{ text: sg.label }] : undefined,
648
+ ports: elkPorts,
649
+ children,
650
+ edges,
651
+ }
652
+ }
653
+
654
+ /** Recursively collect all node IDs that belong to any subgraph */
655
+ function collectSubgraphNodeIds(
656
+ sg: MermaidSubgraph,
657
+ nodeIds: Set<string>,
658
+ subgraphIds: Set<string>,
659
+ ): void {
660
+ for (const id of sg.nodeIds) {
661
+ nodeIds.add(id)
662
+ }
663
+ for (const child of sg.children) {
664
+ subgraphIds.add(child.id)
665
+ collectSubgraphNodeIds(child, nodeIds, subgraphIds)
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Build a mapping from node ID to its containing subgraph ID.
671
+ * For nested subgraphs, maps to the innermost containing subgraph.
672
+ * Nodes not in any subgraph are not included in the map.
673
+ */
674
+ function buildNodeToSubgraphMap(
675
+ subgraphs: MermaidSubgraph[],
676
+ ): Map<string, string> {
677
+ const map = new Map<string, string>()
678
+
679
+ function traverse(sg: MermaidSubgraph): void {
680
+ // Map all direct child nodes to this subgraph
681
+ for (const nodeId of sg.nodeIds) {
682
+ map.set(nodeId, sg.id)
683
+ }
684
+ // Recursively process nested subgraphs (they override parent mapping)
685
+ for (const child of sg.children) {
686
+ traverse(child)
687
+ }
688
+ }
689
+
690
+ for (const sg of subgraphs) {
691
+ traverse(sg)
692
+ }
693
+
694
+ return map
695
+ }