@zombie-mermaid/svg-renderer 4.2.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) 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 +897 -849
  7. package/dist/index.js.map +1 -1
  8. package/package.json +14 -5
  9. package/src/__tests__/c4-label-clearance-1290.test.ts +0 -112
  10. package/src/__tests__/c4-line-ends-1208.test.ts +0 -52
  11. package/src/__tests__/c4-mermaid-reference.test.ts +0 -124
  12. package/src/__tests__/c4-svg.test.ts +0 -337
  13. package/src/__tests__/c4-text-width-1210.test.ts +0 -43
  14. package/src/__tests__/class-component-order-1249.test.ts +0 -40
  15. package/src/__tests__/class-diamond-markers-1232.test.ts +0 -59
  16. package/src/__tests__/class-member-right-padding-1238.test.ts +0 -43
  17. package/src/__tests__/class-namespace-1196.test.ts +0 -165
  18. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +0 -96
  19. package/src/__tests__/class-visibility-markers-1237.test.ts +0 -27
  20. package/src/__tests__/edge-jog-straightening-1388.test.ts +0 -57
  21. package/src/__tests__/elk-adapter-utils.test.ts +0 -166
  22. package/src/__tests__/er-attribute-right-padding-1262.test.ts +0 -47
  23. package/src/__tests__/er-cardinality-markers-1233.test.ts +0 -35
  24. package/src/__tests__/er-default-direction-1250.test.ts +0 -69
  25. package/src/__tests__/er-edge-label-contrast-1244.test.ts +0 -58
  26. package/src/__tests__/fixtures/c4-mermaid-reference.json +0 -626
  27. package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +0 -168
  28. package/src/__tests__/pie-layout.test.ts +0 -215
  29. package/src/__tests__/pie-svg.test.ts +0 -194
  30. package/src/__tests__/render-mermaid-svg.test.ts +0 -64
  31. package/src/__tests__/sequence-label-clearance-1242.test.ts +0 -162
  32. package/src/__tests__/sequence-nested-activation-1241.test.ts +0 -37
  33. package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +0 -32
  34. package/src/__tests__/xychart-smooth-curve.test.ts +0 -42
  35. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +0 -62
  36. package/src/c4/arial-widths.ts +0 -59
  37. package/src/c4/layout.ts +0 -680
  38. package/src/c4/metrics.ts +0 -261
  39. package/src/c4/renderer.ts +0 -366
  40. package/src/class/layout.ts +0 -525
  41. package/src/class/renderer.ts +0 -669
  42. package/src/edge-curves.ts +0 -205
  43. package/src/elk-instance.ts +0 -292
  44. package/src/er/layout.ts +0 -216
  45. package/src/er/renderer.ts +0 -516
  46. package/src/generated/mono-font-subset.ts +0 -32
  47. package/src/index.ts +0 -277
  48. package/src/layout-engine/back-edges.ts +0 -73
  49. package/src/layout-engine/compound-flat.ts +0 -916
  50. package/src/layout-engine/constants.ts +0 -19
  51. package/src/layout-engine/edge-bundling.ts +0 -458
  52. package/src/layout-engine/elk-adapter-utils.ts +0 -81
  53. package/src/layout-engine/elk-graph-builder.ts +0 -245
  54. package/src/layout-engine/from-elk.ts +0 -767
  55. package/src/layout-engine/inner-edges.ts +0 -329
  56. package/src/layout-engine/jog-straightening.ts +0 -52
  57. package/src/layout-engine/layer-alignment.ts +0 -174
  58. package/src/layout-engine/layout-hints.ts +0 -27
  59. package/src/layout-engine/to-elk.ts +0 -855
  60. package/src/layout-engine.ts +0 -115
  61. package/src/pie/layout.ts +0 -234
  62. package/src/pie/renderer.ts +0 -161
  63. package/src/registry.ts +0 -441
  64. package/src/renderer.ts +0 -1510
  65. package/src/resolve-colors.ts +0 -339
  66. package/src/sequence/layout.ts +0 -766
  67. package/src/sequence/renderer.ts +0 -687
  68. package/src/shape-clipping.ts +0 -197
  69. package/src/styles.ts +0 -145
  70. package/src/title-gaps.ts +0 -161
  71. package/src/xychart/layout.ts +0 -727
  72. package/src/xychart/renderer.ts +0 -676
@@ -1,767 +0,0 @@
1
- /**
2
- * Result conversion: ELK output → PositionedGraph.
3
- *
4
- * Split out of layout-engine.ts. This module handles turning ELK's layout
5
- * result back into our PositionedGraph format, including the post-processing
6
- * passes (layer alignment, edge bundling, shape-aware clipping) that clean
7
- * up ELK's raw output.
8
- */
9
-
10
- import type { ElkNode, ElkExtendedEdge } from 'elkjs'
11
- import type {
12
- MermaidGraph,
13
- MermaidSubgraph,
14
- PositionedGraph,
15
- PositionedNode,
16
- PositionedEdge,
17
- PositionedGroup,
18
- Point,
19
- } from '@zombie-mermaid/core'
20
- import { ARROW_HEAD } from '../styles.ts'
21
- import { clipEdgeToShape } from '../shape-clipping.ts'
22
- import { DEFAULTS } from './constants.ts'
23
- import { alignLayerNodes } from './layer-alignment.ts'
24
- import { bundleEdgePaths } from './edge-bundling.ts'
25
- import { straightenTinyJogs } from './jog-straightening.ts'
26
- import { edgesReversedForLayout } from './to-elk.ts'
27
- import {
28
- extractEdgePoints,
29
- extractEdgeLabelPosition,
30
- } from './elk-adapter-utils.ts'
31
- import { resolveNodeStyle } from '@zombie-mermaid/core'
32
-
33
- /** Margin routing info for cross-hierarchy edges */
34
- interface MarginInfo {
35
- leftX: number
36
- rightX: number
37
- }
38
-
39
- // ============================================================================
40
- // Self-loop synthesis
41
- //
42
- // ELK has no native self-loop layout — to-elk.ts excludes edges where
43
- // `source === target` from the graph it hands to ELK entirely (see the
44
- // comments in `mermaidToElk`/`subgraphToElk`), so they never reach
45
- // ELK's own edge routing. Once node positions are final, this module
46
- // synthesizes a rounded loop bulging out from the node's right side —
47
- // approximated as a half-ellipse discretized into enough points to read
48
- // as smooth even under the default `linear` (straight-segment) curve
49
- // style — rather than letting ELK's generic orthogonal router draw a
50
- // degenerate zero-span polyline on top of the node.
51
- // ============================================================================
52
-
53
- /** Half-ellipse discretization steps; higher reads smoother as straight segments. */
54
- const SELF_LOOP_SEGMENTS = 16
55
- /** Base horizontal reach of the loop bulge, in px. */
56
- const SELF_LOOP_BASE_RADIUS = 28
57
- /** Additional reach per stacked self-loop on the same node, in px. */
58
- const SELF_LOOP_STACK_SPACING = 18
59
- /** Vertical distance between the loop's two attach points, in px (clamped to node height). */
60
- const SELF_LOOP_ATTACH_GAP = 28
61
- /**
62
- * Gap between the loop's apex and its label, in px. Wide enough that the
63
- * label's own background box (which paints over whatever it sits on top
64
- * of) doesn't swallow the loop's visible curve.
65
- */
66
- const SELF_LOOP_LABEL_GAP = 10
67
-
68
- /**
69
- * Build the routed points and label position for one self-loop edge on
70
- * `node`, as a half-ellipse bulging out from the node's right side.
71
- *
72
- * `stackIndex` (0 for the first self-loop on this node, 1 for the second,
73
- * ...) widens each successive loop so multiple self-loops on the same node
74
- * nest rather than overlap.
75
- */
76
- function buildSelfLoopGeometry(
77
- node: PositionedNode,
78
- stackIndex: number,
79
- ): { points: Point[]; labelPosition: Point } {
80
- const attachX = node.x + node.width
81
- const midY = node.y + node.height / 2
82
- const halfGap = Math.min(SELF_LOOP_ATTACH_GAP, node.height * 0.6) / 2
83
- const radius = SELF_LOOP_BASE_RADIUS + stackIndex * SELF_LOOP_STACK_SPACING
84
-
85
- // Half-ellipse from the upper attach point (t=0), out to the apex
86
- // (t=PI/2), back to the lower attach point (t=PI). Both attach points
87
- // sit exactly on the node's right edge, so shape-clipping (a no-op for
88
- // rectangle/rounded/stadium, the common cases) leaves them untouched.
89
- const points: Point[] = []
90
- for (let i = 0; i <= SELF_LOOP_SEGMENTS; i++) {
91
- const t = (Math.PI * i) / SELF_LOOP_SEGMENTS
92
- points.push({
93
- x: attachX + radius * Math.sin(t),
94
- y: midY - halfGap * Math.cos(t),
95
- })
96
- }
97
-
98
- return {
99
- points,
100
- labelPosition: { x: attachX + radius + SELF_LOOP_LABEL_GAP, y: midY },
101
- }
102
- }
103
-
104
- /**
105
- * Append a synthesized `PositionedEdge` for every self-loop
106
- * (`edge.source === edge.target`) in `graph.edges` onto `edges`.
107
- *
108
- * Must run after node positions are final (i.e. after `alignLayerNodes`) —
109
- * the loop geometry is derived directly from the node's laid-out
110
- * x/y/width/height, so computing it any earlier would have it drift out of
111
- * sync with a node that layer-alignment later shifts.
112
- */
113
- function synthesizeSelfLoopEdges(
114
- graph: MermaidGraph,
115
- nodeMap: Map<string, PositionedNode>,
116
- edges: PositionedEdge[],
117
- ): void {
118
- const stackIndexByNode = new Map<string, number>()
119
-
120
- for (let index = 0; index < graph.edges.length; index++) {
121
- const edge = graph.edges[index]!
122
- if (edge.source !== edge.target) continue
123
-
124
- const node = nodeMap.get(edge.source)
125
- if (!node) continue
126
-
127
- const stackIndex = stackIndexByNode.get(node.id) ?? 0
128
- stackIndexByNode.set(node.id, stackIndex + 1)
129
-
130
- const { points, labelPosition } = buildSelfLoopGeometry(node, stackIndex)
131
-
132
- edges.push({
133
- source: edge.source,
134
- target: edge.target,
135
- label: edge.label,
136
- style: edge.style,
137
- hasArrowStart: edge.hasArrowStart,
138
- hasArrowEnd: edge.hasArrowEnd,
139
- points,
140
- labelPosition: edge.label ? labelPosition : undefined,
141
- inlineStyle: resolveEdgeStyle(index, graph),
142
- id: edge.id,
143
- animate: edge.animate,
144
- })
145
- }
146
- }
147
-
148
- /** Recursively flatten all group bounding boxes (including nested children) */
149
- function flattenGroupBounds(
150
- groups: PositionedGroup[],
151
- ): Array<{ x: number; y: number; right: number; bottom: number }> {
152
- const bounds: Array<{ x: number; y: number; right: number; bottom: number }> =
153
- []
154
- for (const g of groups) {
155
- bounds.push({
156
- x: g.x,
157
- y: g.y,
158
- right: g.x + g.width,
159
- bottom: g.y + g.height,
160
- })
161
- bounds.push(...flattenGroupBounds(g.children))
162
- }
163
- return bounds
164
- }
165
-
166
- /**
167
- * Convert ELK layout result to our PositionedGraph format.
168
- */
169
- export function elkToPositioned(
170
- elkResult: ElkNode,
171
- graph: MermaidGraph,
172
- mergeEdges: boolean = false,
173
- ): PositionedGraph {
174
- const nodes: PositionedNode[] = []
175
- const edges: PositionedEdge[] = []
176
- const groups: PositionedGroup[] = []
177
-
178
- // Build set of subgraph IDs for distinguishing compound nodes from leaf nodes
179
- const subgraphIds = new Set<string>()
180
- for (const sg of graph.subgraphs) {
181
- collectAllSubgraphIds(sg, subgraphIds)
182
- }
183
-
184
- // Extract nodes and groups recursively
185
- extractNodesAndGroups(elkResult, graph, subgraphIds, nodes, groups, 0, 0)
186
-
187
- // Compute margin positions for cross-hierarchy edge routing.
188
- // Margins sit outside all group bounding boxes so edges don't cross through subgraphs.
189
- const allBounds = flattenGroupBounds(groups)
190
- const margins: MarginInfo | undefined =
191
- allBounds.length > 0
192
- ? {
193
- leftX: Math.min(...allBounds.map((b) => b.x)) - 20,
194
- rightX: Math.max(...allBounds.map((b) => b.right)) + 20,
195
- }
196
- : undefined
197
-
198
- // Extract edges recursively from all levels (root and subgraphs)
199
- // Edges are distributed to subgraphs for direction override to work,
200
- // so we need to collect them from all children with proper offsets
201
- extractEdgesRecursively(elkResult, graph, edges, 0, 0, margins)
202
-
203
- // Snap same-layer nodes to the same position along the flow axis.
204
- // ELK's orthogonal routing staggers nodes within a layer to create room for
205
- // edge bends, but this looks bad. We fix it by aligning layers, then let
206
- // edge bundling and clipping recalculate edge paths from corrected positions.
207
- alignLayerNodes(nodes, edges, graph.direction)
208
-
209
- // Synthesize self-loop edges now that node positions are final. These
210
- // were excluded from the graph handed to ELK (see to-elk.ts) since ELK
211
- // has no native self-loop layout; must run after alignLayerNodes so the
212
- // loop geometry is derived from each node's final, not pre-alignment,
213
- // position.
214
- const nodeMap = new Map(nodes.map((n) => [n.id, n]))
215
- synthesizeSelfLoopEdges(graph, nodeMap, edges)
216
-
217
- // Bundle fan-out/fan-in edge paths into shared trunks when mergeEdges is enabled
218
- if (mergeEdges) {
219
- bundleEdgePaths(edges, nodes, groups, graph.direction)
220
- }
221
-
222
- // Apply shape-aware edge clipping for non-rectangular shapes.
223
- // ELK treats all nodes as rectangles, so we need to clip edge endpoints
224
- // to the actual shape boundaries (e.g., diamond vertices).
225
- for (const edge of edges) {
226
- edge.points = straightenTinyJogs(edge.points)
227
- const sourceNode = nodeMap.get(edge.source)
228
- const targetNode = nodeMap.get(edge.target)
229
-
230
- if (sourceNode) {
231
- edge.points = clipEdgeToShape(edge.points, sourceNode, true)
232
- }
233
- if (targetNode) {
234
- edge.points = clipEdgeToShape(edge.points, targetNode, false)
235
- }
236
- }
237
-
238
- // Calculate final bounds including all edge points
239
- // ELK should include edges in its dimensions, but we verify and expand if needed
240
- let width = elkResult.width ?? 800
241
- let height = elkResult.height ?? 600
242
- const arrowMargin = ARROW_HEAD.width
243
- const padding = DEFAULTS.padding
244
-
245
- for (const edge of edges) {
246
- for (const p of edge.points) {
247
- width = Math.max(width, p.x + arrowMargin + padding)
248
- height = Math.max(height, p.y + arrowMargin + padding)
249
- }
250
- if (edge.labelPosition) {
251
- width = Math.max(width, edge.labelPosition.x + 60 + padding)
252
- height = Math.max(height, edge.labelPosition.y + 20 + padding)
253
- }
254
- }
255
-
256
- return {
257
- width,
258
- height,
259
- nodes,
260
- edges,
261
- groups,
262
- }
263
- }
264
-
265
- /**
266
- * Recursively extract positioned nodes and groups from ELK result.
267
- */
268
- function extractNodesAndGroups(
269
- elkNode: ElkNode,
270
- graph: MermaidGraph,
271
- subgraphIds: Set<string>,
272
- nodes: PositionedNode[],
273
- groups: PositionedGroup[],
274
- offsetX: number,
275
- offsetY: number,
276
- ): void {
277
- if (!elkNode.children) return
278
-
279
- for (const child of elkNode.children) {
280
- const x = (child.x ?? 0) + offsetX
281
- const y = (child.y ?? 0) + offsetY
282
- const width = child.width ?? 0
283
- const height = child.height ?? 0
284
-
285
- if (subgraphIds.has(child.id)) {
286
- // This is a subgraph/group
287
- const childGroups: PositionedGroup[] = []
288
-
289
- // Recursively process children
290
- extractNodesAndGroups(child, graph, subgraphIds, nodes, childGroups, x, y)
291
-
292
- const mermaidSg = findSubgraph(graph.subgraphs, child.id)
293
- groups.push({
294
- id: child.id,
295
- label: mermaidSg?.label ?? '',
296
- x,
297
- y,
298
- width,
299
- height,
300
- children: childGroups,
301
- })
302
- } else {
303
- // This is a leaf node
304
- const mNode = graph.nodes.get(child.id)
305
- if (mNode) {
306
- // Resolve inline styles from nodeStyles map and classDefs
307
- const inlineStyle = resolveNodeStyle(child.id, graph)
308
- // Custom class name from `class A className` or `:::className` —
309
- // tracked separately from inlineStyle so it's still emitted onto the
310
- // rendered element even when the class has no matching classDef.
311
- const className = graph.classAssignments.get(child.id)
312
-
313
- nodes.push({
314
- id: child.id,
315
- label: mNode.label,
316
- shape: mNode.shape,
317
- x,
318
- y,
319
- width,
320
- height,
321
- inlineStyle,
322
- className,
323
- interaction: graph.interactions.get(child.id),
324
- })
325
- }
326
-
327
- // Also check for nested children (shouldn't happen for leaf nodes, but be safe)
328
- if (child.children && child.children.length > 0) {
329
- extractNodesAndGroups(child, graph, subgraphIds, nodes, groups, x, y)
330
- }
331
- }
332
- }
333
- }
334
-
335
- /**
336
- * Edge segment extracted from ELK result.
337
- * Used to combine the bridge and hop segments of a decomposed
338
- * cross-hierarchy edge back into one continuous path.
339
- */
340
- interface EdgeSegment {
341
- edgeIndex: number
342
- points: Point[]
343
- labelPosition?: Point
344
- }
345
-
346
- /**
347
- * All segments belonging to one original edge, keyed by role:
348
- * - `bridge`: the sub-edge at the lowest common ancestor of source/target
349
- * (id `e{index}`) — the only segment for edges that don't cross a
350
- * subgraph boundary at all.
351
- * - `sourceHops`: source-side boundary crossings (id `e{index}_s{level}`),
352
- * keyed by ancestor-chain level. Walking from the innermost level
353
- * (closest to the source node) outward to the LCA.
354
- * - `targetHops`: target-side boundary crossings (id `e{index}_t{level}`),
355
- * keyed by ancestor-chain level. Walking from the LCA inward to the
356
- * innermost level (closest to the target node).
357
- * See packages/svg-renderer/src/layout-engine/to-elk.ts (mermaidToElk) for how these are produced.
358
- */
359
- interface EdgeSegmentGroup {
360
- bridge?: EdgeSegment
361
- sourceHops: Map<number, EdgeSegment>
362
- targetHops: Map<number, EdgeSegment>
363
- }
364
-
365
- /** Parses an ELK edge id produced by the cross-hierarchy decomposition in to-elk.ts. */
366
- function parseHopEdgeId(
367
- id: string,
368
- ):
369
- | { edgeIndex: number; role: 'bridge' }
370
- | { edgeIndex: number; role: 'source' | 'target'; level: number }
371
- | undefined {
372
- const bridgeMatch = /^e(\d+)$/.exec(id)
373
- if (bridgeMatch) {
374
- return { edgeIndex: parseInt(bridgeMatch[1]!, 10), role: 'bridge' }
375
- }
376
- const hopMatch = /^e(\d+)_([st])(\d+)$/.exec(id)
377
- if (hopMatch) {
378
- return {
379
- edgeIndex: parseInt(hopMatch[1]!, 10),
380
- role: hopMatch[2] === 's' ? 'source' : 'target',
381
- level: parseInt(hopMatch[3]!, 10),
382
- }
383
- }
384
- return undefined
385
- }
386
-
387
- /**
388
- * Calculate the midpoint along a polyline path.
389
- * Walks the path to find the point at half the total length.
390
- */
391
- function calculatePathMidpoint(points: Point[]): Point {
392
- if (points.length === 0) return { x: 0, y: 0 }
393
- if (points.length === 1) return points[0]!
394
-
395
- // Calculate total length
396
- let totalLength = 0
397
- for (let i = 1; i < points.length; i++) {
398
- const dx = points[i]!.x - points[i - 1]!.x
399
- const dy = points[i]!.y - points[i - 1]!.y
400
- totalLength += Math.sqrt(dx * dx + dy * dy)
401
- }
402
-
403
- // Walk to halfway point
404
- let remaining = totalLength / 2
405
- for (let i = 1; i < points.length; i++) {
406
- const dx = points[i]!.x - points[i - 1]!.x
407
- const dy = points[i]!.y - points[i - 1]!.y
408
- const segLen = Math.sqrt(dx * dx + dy * dy)
409
- if (remaining <= segLen) {
410
- const t = remaining / segLen
411
- return {
412
- x: points[i - 1]!.x + t * dx,
413
- y: points[i - 1]!.y + t * dy,
414
- }
415
- }
416
- remaining -= segLen
417
- }
418
-
419
- return points[points.length - 1]!
420
- }
421
-
422
- /**
423
- * Recursively extract edges from ELK result including those inside subgraphs.
424
- * Edges are distributed to subgraphs for direction override to work,
425
- * so we need to collect them from all levels with proper coordinate offsets.
426
- *
427
- * For hierarchical edges (cross-hierarchy with ports), combines external and
428
- * internal segments into a single continuous edge path.
429
- */
430
- function extractEdgesRecursively(
431
- elkNode: ElkNode,
432
- graph: MermaidGraph,
433
- edges: PositionedEdge[],
434
- offsetX: number,
435
- offsetY: number,
436
- margins?: MarginInfo,
437
- ): void {
438
- // First pass: collect all edge segments.
439
- //
440
- // Under `hierarchyHandling: INCLUDE_CHILDREN` (see to-elk.ts — used when no
441
- // subgraph has a direction override), ELK is free to leave a
442
- // cross-hierarchy edge in an *ancestor* node's `edges` array while
443
- // reporting its sections/labels in the coordinate space of the deeper
444
- // container named by the edge's own (untyped-in-elkjs) `container`
445
- // property. `collectEdgeSegments`'s tree walk assumes an edge's
446
- // coordinates match the array it was found in, which breaks for exactly
447
- // this case — the edge lands at the *ancestor's* offset instead of the
448
- // declared container's, shifting nested-subgraph edges/labels off their
449
- // node boundaries toward the root. Pre-computing every container's
450
- // accumulated root offset here lets `collectEdgeSegments` prefer the
451
- // edge's declared container when present, falling back to the owning
452
- // array's offset (the previous behavior) otherwise — see
453
- // zombie-mermaid#1145, ported from a fix by Galen Suen in
454
- // lukilabs/beautiful-mermaid#152.
455
- const containerOffsets = new Map<string, Point>()
456
- collectContainerOffsets(elkNode, containerOffsets, 0, 0)
457
- const segments = new Map<number, EdgeSegmentGroup>()
458
- collectEdgeSegments(elkNode, segments, 0, 0, containerOffsets)
459
-
460
- // Track margin-routed edge count for spacing offsets
461
- let marginEdgeIndex = 0
462
-
463
- // Second pass: combine segments and create positioned edges
464
- for (const [edgeIndex, seg] of segments) {
465
- const originalEdge = graph.edges[edgeIndex]
466
- if (!originalEdge) continue
467
-
468
- // Combine points from all segments in path order:
469
- // source-side hops (innermost → outermost, i.e. descending level)
470
- // → bridge (the sub-edge at the lowest common ancestor)
471
- // → target-side hops (outermost → innermost, i.e. ascending level)
472
- // Edges that don't cross any subgraph boundary have only a bridge
473
- // segment. Edges crossing exactly one boundary have a bridge plus a
474
- // single hop on one side — this is the shape the previous single-hop
475
- // implementation always produced. Deeper nesting simply adds more hops
476
- // on either side, each joined at the shared boundary point.
477
- const allPoints: Point[] = []
478
- const appendSegment = (segment: EdgeSegment | undefined): void => {
479
- if (!segment || segment.points.length === 0) return
480
- if (allPoints.length > 0) {
481
- // Skip first point to avoid duplicating the shared boundary point.
482
- allPoints.push(...segment.points.slice(1))
483
- } else {
484
- allPoints.push(...segment.points)
485
- }
486
- }
487
-
488
- const sourceLevels = [...seg.sourceHops.keys()].sort((a, b) => b - a)
489
- for (const level of sourceLevels) appendSegment(seg.sourceHops.get(level))
490
- appendSegment(seg.bridge)
491
- const targetLevels = [...seg.targetHops.keys()].sort((a, b) => a - b)
492
- for (const level of targetLevels) appendSegment(seg.targetHops.get(level))
493
-
494
- // Label position: use ELK's inline label position (on-edge with collision avoidance)
495
- // Fall back to midpoint for hierarchical edges or when ELK position unavailable
496
- let labelPosition: Point | undefined
497
- if (originalEdge.label && allPoints.length >= 2) {
498
- const elkLabelPos = seg.bridge?.labelPosition
499
- labelPosition = elkLabelPos ?? calculatePathMidpoint(allPoints)
500
- }
501
-
502
- // Ensure all edge segments are orthogonal (horizontal or vertical only).
503
- // In SEPARATE hierarchy mode, ELK may produce diagonal segments for
504
- // cross-hierarchy edges where it only returns start/end points without
505
- // proper orthogonal bend points.
506
- // When margins are available, route through the diagram margins instead
507
- // of Z-paths through the middle (which cross through subgraphs).
508
- const orthogonalPoints = orthogonalizeEdgePoints(
509
- allPoints,
510
- margins,
511
- marginEdgeIndex,
512
- )
513
- if (orthogonalPoints !== allPoints) {
514
- marginEdgeIndex++
515
- }
516
-
517
- // Recalculate label position for margin-routed edges
518
- if (
519
- originalEdge.label &&
520
- orthogonalPoints !== allPoints &&
521
- orthogonalPoints.length >= 2
522
- ) {
523
- labelPosition = calculatePathMidpoint(orthogonalPoints)
524
- }
525
-
526
- edges.push({
527
- source: originalEdge.source,
528
- target: originalEdge.target,
529
- label: originalEdge.label,
530
- style: originalEdge.style,
531
- hasArrowStart: originalEdge.hasArrowStart,
532
- hasArrowEnd: originalEdge.hasArrowEnd,
533
- // An edge laid out reversed (see `edgesReversedForLayout`) comes back
534
- // running target to source; flip it so it runs source to target again.
535
- points: edgesReversedForLayout(graph).has(edgeIndex)
536
- ? [...orthogonalPoints].reverse()
537
- : orthogonalPoints,
538
- labelPosition,
539
- inlineStyle: resolveEdgeStyle(edgeIndex, graph),
540
- id: originalEdge.id,
541
- animate: originalEdge.animate,
542
- })
543
- }
544
- }
545
-
546
- /**
547
- * Build a map from every ELK node's own id to its accumulated root-relative
548
- * offset, by walking the same `elkNode.children` tree `collectEdgeSegments`
549
- * walks. Used to resolve a cross-hierarchy edge's declared `container`
550
- * (an ELK output property not modeled in elkjs's types) to the offset of
551
- * the container ELK actually reported its coordinates in, rather than the
552
- * offset of whichever ancestor array the edge object happened to be left
553
- * in — see the comment on `extractEdgesRecursively`'s call site.
554
- */
555
- function collectContainerOffsets(
556
- elkNode: ElkNode,
557
- offsets: Map<string, Point>,
558
- offsetX: number,
559
- offsetY: number,
560
- ): void {
561
- offsets.set(elkNode.id, { x: offsetX, y: offsetY })
562
- for (const child of elkNode.children ?? []) {
563
- // `x`/`y` are optional in elkjs's types, but ELK always assigns both to
564
- // every laid-out child node — the `?? 0` fallback mirrors the same
565
- // defensive pattern already used for this walk in `collectEdgeSegments`
566
- // below, and is equally unreachable from real ELK output.
567
- /* v8 ignore next */
568
- const childX = child.x ?? 0
569
- /* v8 ignore next */
570
- const childY = child.y ?? 0
571
- collectContainerOffsets(child, offsets, offsetX + childX, offsetY + childY)
572
- }
573
- }
574
-
575
- /**
576
- * Post-process edge points to ensure all segments are purely orthogonal.
577
- *
578
- * When ELK uses SEPARATE hierarchy handling (required for subgraph direction
579
- * overrides), cross-hierarchy edges may only get start/end coordinates without
580
- * intermediate bend points, producing diagonal lines.
581
- *
582
- * When margins are provided, routes diagonal segments through the left or right
583
- * margin of the diagram (outside all subgraphs). Alternates sides and adds
584
- * spacing offsets to prevent overlapping parallel edges.
585
- *
586
- * Without margins, falls back to Z-path through the vertical midpoint.
587
- *
588
- * Returns the original array reference (identity) if no changes were needed,
589
- * so callers can detect whether routing was applied.
590
- */
591
- function orthogonalizeEdgePoints(
592
- points: Point[],
593
- margins?: MarginInfo,
594
- edgeIndex: number = 0,
595
- ): Point[] {
596
- if (points.length < 2) return points
597
-
598
- // Check if any segment needs orthogonalization
599
- let needsWork = false
600
- for (let i = 1; i < points.length; i++) {
601
- const dx = Math.abs(points[i]!.x - points[i - 1]!.x)
602
- const dy = Math.abs(points[i]!.y - points[i - 1]!.y)
603
- if (dx > 1 && dy > 1) {
604
- needsWork = true
605
- break
606
- }
607
- }
608
- if (!needsWork) return points
609
-
610
- const EDGE_SPACING = 12
611
- const result: Point[] = [points[0]!]
612
-
613
- for (let i = 1; i < points.length; i++) {
614
- const prev = result[result.length - 1]!
615
- const curr = points[i]!
616
- const dx = Math.abs(curr.x - prev.x)
617
- const dy = Math.abs(curr.y - prev.y)
618
-
619
- if (dx > 1 && dy > 1) {
620
- if (margins) {
621
- // Margin routing: exit horizontally → travel vertically along margin → enter horizontally
622
- // Alternate left/right margins and offset for parallel edge spacing
623
- const useRight = edgeIndex % 2 === 0
624
- const offset = Math.floor(edgeIndex / 2) * EDGE_SPACING
625
- const marginX = useRight
626
- ? margins.rightX + offset
627
- : margins.leftX - offset
628
-
629
- result.push({ x: marginX, y: prev.y })
630
- result.push({ x: marginX, y: curr.y })
631
- } else {
632
- // Fallback: Z-path through vertical midpoint
633
- const midY = (prev.y + curr.y) / 2
634
- result.push({ x: prev.x, y: midY })
635
- result.push({ x: curr.x, y: midY })
636
- }
637
- }
638
-
639
- result.push(curr)
640
- }
641
-
642
- return result
643
- }
644
-
645
- /**
646
- * Recursively collect edge segments from ELK result.
647
- */
648
- function collectEdgeSegments(
649
- elkNode: ElkNode,
650
- segments: Map<number, EdgeSegmentGroup>,
651
- offsetX: number,
652
- offsetY: number,
653
- containerOffsets: Map<string, Point>,
654
- ): void {
655
- if (elkNode.edges) {
656
- for (const elkEdge of elkNode.edges) {
657
- // Parse edge ID: "e{index}" (bridge), "e{index}_s{level}" (source-side
658
- // hop), or "e{index}_t{level}" (target-side hop) — see to-elk.ts.
659
- const parsed = parseHopEdgeId(elkEdge.id)
660
- if (!parsed) continue
661
- const { edgeIndex } = parsed
662
-
663
- // Prefer the edge's declared container offset (see
664
- // `collectContainerOffsets`'s comment) over the owning array's
665
- // offset. `container` and `containerOffsets.get(container)` were
666
- // verified (by direct instrumentation against real elk.bundled.js
667
- // output, across both flat and multiply-nested subgraphs) to always
668
- // be defined in practice — ELK tags every edge with its container,
669
- // even at the root, and `collectContainerOffsets` walks the same
670
- // tree so every container id it can name is already in the map. The
671
- // `container?`/`?? offset{X,Y}` fallbacks below exist only because
672
- // `container` is untyped in elkjs and `Map.get` is typed to allow a
673
- // miss; kept as defensive narrowing rather than assumed impossible.
674
- const container = (elkEdge as ElkExtendedEdge & { container?: string })
675
- .container
676
- /* v8 ignore next */
677
- const containerOffset = container
678
- ? containerOffsets.get(container)
679
- : undefined
680
- /* v8 ignore next */
681
- const edgeOffsetX = containerOffset?.x ?? offsetX
682
- /* v8 ignore next */
683
- const edgeOffsetY = containerOffset?.y ?? offsetY
684
-
685
- // Extract points and label position
686
- const points = extractEdgePoints(elkEdge, edgeOffsetX, edgeOffsetY)
687
- const labelPosition = extractEdgeLabelPosition(
688
- elkEdge,
689
- edgeOffsetX,
690
- edgeOffsetY,
691
- )
692
-
693
- // Store segment
694
- const seg: EdgeSegmentGroup = segments.get(edgeIndex) ?? {
695
- sourceHops: new Map(),
696
- targetHops: new Map(),
697
- }
698
- segments.set(edgeIndex, seg)
699
- const segment: EdgeSegment = { edgeIndex, points, labelPosition }
700
-
701
- if (parsed.role === 'bridge') {
702
- seg.bridge = segment
703
- } else if (parsed.role === 'source') {
704
- seg.sourceHops.set(parsed.level, segment)
705
- } else {
706
- seg.targetHops.set(parsed.level, segment)
707
- }
708
- }
709
- }
710
-
711
- // Recurse into children with accumulated offset
712
- if (elkNode.children) {
713
- for (const child of elkNode.children) {
714
- collectEdgeSegments(
715
- child,
716
- segments,
717
- offsetX + (child.x ?? 0),
718
- offsetY + (child.y ?? 0),
719
- containerOffsets,
720
- )
721
- }
722
- }
723
- }
724
-
725
- /** Find a subgraph by ID in a nested structure */
726
- function findSubgraph(
727
- subgraphs: MermaidSubgraph[],
728
- id: string,
729
- ): MermaidSubgraph | undefined {
730
- for (const sg of subgraphs) {
731
- if (sg.id === id) return sg
732
- const found = findSubgraph(sg.children, id)
733
- if (found) return found
734
- }
735
- return undefined
736
- }
737
-
738
- /** Recursively collect all subgraph IDs */
739
- function collectAllSubgraphIds(sg: MermaidSubgraph, out: Set<string>): void {
740
- out.add(sg.id)
741
- for (const child of sg.children) {
742
- collectAllSubgraphIds(child, out)
743
- }
744
- }
745
-
746
- /**
747
- * Resolve inline styles for an edge from linkStyles map.
748
- * Default link style is applied first, then index-specific overrides.
749
- */
750
- export function resolveEdgeStyle(
751
- edgeIndex: number,
752
- graph: MermaidGraph,
753
- ): Record<string, string> | undefined {
754
- let result: Record<string, string> | undefined
755
-
756
- const defaultStyle = graph.linkStyles.get('default')
757
- if (defaultStyle) {
758
- result = { ...defaultStyle }
759
- }
760
-
761
- const indexStyle = graph.linkStyles.get(edgeIndex)
762
- if (indexStyle) {
763
- result = result ? { ...result, ...indexStyle } : { ...indexStyle }
764
- }
765
-
766
- return result
767
- }