@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.
- package/LICENSE +22 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +524 -0
- package/dist/index.d.ts +524 -0
- package/dist/index.js +2994 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/elk-adapter-utils.test.ts +166 -0
- package/src/class/layout.ts +360 -0
- package/src/class/renderer.ts +636 -0
- package/src/edge-curves.ts +204 -0
- package/src/elk-instance.ts +292 -0
- package/src/er/layout.ts +200 -0
- package/src/er/renderer.ts +493 -0
- package/src/index.ts +58 -0
- package/src/layout-engine/constants.ts +19 -0
- package/src/layout-engine/edge-bundling.ts +379 -0
- package/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/src/layout-engine/elk-graph-builder.ts +240 -0
- package/src/layout-engine/from-elk.ts +685 -0
- package/src/layout-engine/layer-alignment.ts +174 -0
- package/src/layout-engine/to-elk.ts +695 -0
- package/src/layout-engine.ts +74 -0
- package/src/layout.ts +8 -0
- package/src/renderer.ts +1485 -0
- package/src/resolve-colors.ts +339 -0
- package/src/sequence/layout.ts +698 -0
- package/src/sequence/renderer.ts +546 -0
- package/src/shape-clipping.ts +197 -0
- package/src/styles.ts +118 -0
- package/src/xychart/layout.ts +682 -0
- package/src/xychart/renderer.ts +684 -0
|
@@ -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
|
+
}
|