@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,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
|
+
}
|