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