@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,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge bundling — merge fan-out / fan-in edge paths into shared trunks.
|
|
3
|
+
* Split out of layout-engine.ts as a self-contained post-processing pass
|
|
4
|
+
* over a PositionedGraph's nodes/edges/groups.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
PositionedEdge,
|
|
9
|
+
PositionedNode,
|
|
10
|
+
PositionedGroup,
|
|
11
|
+
Direction,
|
|
12
|
+
Point,
|
|
13
|
+
} from '@zombie-mermaid/core'
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* Shrink applied to a node box before testing it against a bundled path, in px.
|
|
17
|
+
* A trunk that merely grazes a node's border reads as clean, so only a genuine
|
|
18
|
+
* overlap should disqualify a bundle.
|
|
19
|
+
*/
|
|
20
|
+
const NODE_CLEARANCE = 0.5
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when every segment of `points` stays clear of all `nodes`, ignoring the
|
|
24
|
+
* ones named in `skipIds` (an edge's own endpoints, which it must touch).
|
|
25
|
+
*
|
|
26
|
+
* Bundling replaces a routed path with a trunk plus a straight branch to each
|
|
27
|
+
* target. That branch is only safe when it spans the gap between two adjacent
|
|
28
|
+
* layers; a target further away makes it cross every layer in between — and
|
|
29
|
+
* whatever nodes sit in them. The layout engine already routed those edges
|
|
30
|
+
* around the obstacles, so a bundle that would collide is rejected and the
|
|
31
|
+
* original routing kept.
|
|
32
|
+
*
|
|
33
|
+
* Paths here are rectilinear, so each segment is tested as a degenerate
|
|
34
|
+
* rectangle against each node's box.
|
|
35
|
+
*/
|
|
36
|
+
function pathClearOfNodes(
|
|
37
|
+
points: Point[],
|
|
38
|
+
nodes: PositionedNode[],
|
|
39
|
+
skipIds: Set<string>,
|
|
40
|
+
): boolean {
|
|
41
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
42
|
+
const a = points[i]!
|
|
43
|
+
const b = points[i + 1]!
|
|
44
|
+
const segMinX = Math.min(a.x, b.x)
|
|
45
|
+
const segMaxX = Math.max(a.x, b.x)
|
|
46
|
+
const segMinY = Math.min(a.y, b.y)
|
|
47
|
+
const segMaxY = Math.max(a.y, b.y)
|
|
48
|
+
|
|
49
|
+
for (const node of nodes) {
|
|
50
|
+
if (skipIds.has(node.id)) continue
|
|
51
|
+
const minX = node.x + NODE_CLEARANCE
|
|
52
|
+
const maxX = node.x + node.width - NODE_CLEARANCE
|
|
53
|
+
const minY = node.y + NODE_CLEARANCE
|
|
54
|
+
const maxY = node.y + node.height - NODE_CLEARANCE
|
|
55
|
+
// A node smaller than the clearance on either axis can't block anything.
|
|
56
|
+
if (minX >= maxX || minY >= maxY) continue
|
|
57
|
+
if (
|
|
58
|
+
segMinX < maxX &&
|
|
59
|
+
segMaxX > minX &&
|
|
60
|
+
segMinY < maxY &&
|
|
61
|
+
segMaxY > minY
|
|
62
|
+
) {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Find all groups (outermost first) that geometrically contain the given point.
|
|
72
|
+
*/
|
|
73
|
+
function findGroupsContainingPoint(
|
|
74
|
+
x: number,
|
|
75
|
+
y: number,
|
|
76
|
+
groups: PositionedGroup[],
|
|
77
|
+
): PositionedGroup[] {
|
|
78
|
+
const result: PositionedGroup[] = []
|
|
79
|
+
for (const g of groups) {
|
|
80
|
+
if (x >= g.x && x <= g.x + g.width && y >= g.y && y <= g.y + g.height) {
|
|
81
|
+
result.push(g)
|
|
82
|
+
result.push(...findGroupsContainingPoint(x, y, g.children))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return result
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* If `junction` falls inside a group that doesn't contain the reference node,
|
|
90
|
+
* move it just outside the outermost such group boundary.
|
|
91
|
+
*/
|
|
92
|
+
function adjustJunctionForGroups(
|
|
93
|
+
junctionMain: number, // the junction coordinate along the flow axis (Y for TD, X for LR)
|
|
94
|
+
refX: number, // reference node center X (for finding its groups)
|
|
95
|
+
refY: number, // reference node center Y
|
|
96
|
+
groups: PositionedGroup[],
|
|
97
|
+
direction: Direction,
|
|
98
|
+
): number {
|
|
99
|
+
const GAP = 12
|
|
100
|
+
const isLR = direction === 'LR'
|
|
101
|
+
const isRL = direction === 'RL'
|
|
102
|
+
const isBT = direction === 'BT'
|
|
103
|
+
const isHorizontal = isLR || isRL
|
|
104
|
+
|
|
105
|
+
// Groups containing the reference node
|
|
106
|
+
const refGroupIds = new Set(
|
|
107
|
+
findGroupsContainingPoint(refX, refY, groups).map((g) => g.id),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// Check where the junction point would be along the trunk
|
|
111
|
+
const probeX = isHorizontal ? junctionMain : refX
|
|
112
|
+
const probeY = isHorizontal ? refY : junctionMain
|
|
113
|
+
const junctionGroups = findGroupsContainingPoint(probeX, probeY, groups)
|
|
114
|
+
|
|
115
|
+
// Find outermost group containing the junction but NOT the reference node
|
|
116
|
+
const crossingGroup = junctionGroups.find((g) => !refGroupIds.has(g.id))
|
|
117
|
+
if (!crossingGroup) return junctionMain
|
|
118
|
+
|
|
119
|
+
// Move junction just outside this group
|
|
120
|
+
if (isLR) return crossingGroup.x - GAP
|
|
121
|
+
if (isRL) return crossingGroup.x + crossingGroup.width + GAP
|
|
122
|
+
if (isBT) return crossingGroup.y + crossingGroup.height + GAP
|
|
123
|
+
return crossingGroup.y - GAP // TD
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Bundle fan-out and fan-in edge paths so they share a common trunk segment.
|
|
128
|
+
*
|
|
129
|
+
* For fan-out (one source → N targets), all edges exit the source at the same
|
|
130
|
+
* point, travel along a shared trunk, then branch to their individual targets.
|
|
131
|
+
* The overlapping trunk segments render as a single visible line.
|
|
132
|
+
*
|
|
133
|
+
* Junction points are placed outside subgraph boundaries so branches split
|
|
134
|
+
* before entering a group, not inside it.
|
|
135
|
+
*
|
|
136
|
+
* Constraints: edges in a bundle must share the same style and have no labels.
|
|
137
|
+
* Self-loops and backward edges (against the graph direction) are excluded.
|
|
138
|
+
*/
|
|
139
|
+
export function bundleEdgePaths(
|
|
140
|
+
edges: PositionedEdge[],
|
|
141
|
+
nodes: PositionedNode[],
|
|
142
|
+
groups: PositionedGroup[],
|
|
143
|
+
direction: Direction,
|
|
144
|
+
): void {
|
|
145
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]))
|
|
146
|
+
const processed = new Set<PositionedEdge>()
|
|
147
|
+
|
|
148
|
+
const isLR = direction === 'LR'
|
|
149
|
+
const isRL = direction === 'RL'
|
|
150
|
+
const isBT = direction === 'BT'
|
|
151
|
+
const isHorizontal = isLR || isRL
|
|
152
|
+
|
|
153
|
+
// --- Fan-out: group edges by shared source ---
|
|
154
|
+
const fanOutGroups = new Map<string, PositionedEdge[]>()
|
|
155
|
+
for (const edge of edges) {
|
|
156
|
+
if (edge.source === edge.target) continue
|
|
157
|
+
const group = fanOutGroups.get(edge.source) ?? []
|
|
158
|
+
fanOutGroups.set(edge.source, group)
|
|
159
|
+
group.push(edge)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const [sourceId, group] of fanOutGroups) {
|
|
163
|
+
if (group.length < 2) continue
|
|
164
|
+
|
|
165
|
+
const style = group[0]!.style
|
|
166
|
+
if (group.some((e) => e.label || e.style !== style)) continue
|
|
167
|
+
|
|
168
|
+
const source = nodeMap.get(sourceId)
|
|
169
|
+
if (!source) continue
|
|
170
|
+
|
|
171
|
+
// Only bundle edges going in the forward direction. Collects the
|
|
172
|
+
// resolved target node alongside each edge as it filters, rather than
|
|
173
|
+
// re-querying nodeMap afterward — the two lookups can't be proven to
|
|
174
|
+
// agree from a map() over a separately-filtered array.
|
|
175
|
+
const targets: { edge: PositionedEdge; node: PositionedNode }[] = []
|
|
176
|
+
for (const e of group) {
|
|
177
|
+
const t = nodeMap.get(e.target)
|
|
178
|
+
if (!t) continue
|
|
179
|
+
const isForward = isLR
|
|
180
|
+
? t.x > source.x + source.width
|
|
181
|
+
: isRL
|
|
182
|
+
? t.x + t.width < source.x
|
|
183
|
+
: isBT
|
|
184
|
+
? t.y + t.height < source.y
|
|
185
|
+
: t.y > source.y + source.height // TD/TB
|
|
186
|
+
if (isForward) targets.push({ edge: e, node: t })
|
|
187
|
+
}
|
|
188
|
+
if (targets.length < 2) continue
|
|
189
|
+
|
|
190
|
+
const srcCX = source.x + source.width / 2
|
|
191
|
+
const srcCY = source.y + source.height / 2
|
|
192
|
+
|
|
193
|
+
if (isHorizontal) {
|
|
194
|
+
const exitX = isLR ? source.x + source.width : source.x
|
|
195
|
+
const exitY = srcCY
|
|
196
|
+
|
|
197
|
+
const nearestX = isLR
|
|
198
|
+
? Math.min(...targets.map((t) => t.node.x))
|
|
199
|
+
: Math.max(...targets.map((t) => t.node.x + t.node.width))
|
|
200
|
+
let junctionX = exitX + (nearestX - exitX) / 2
|
|
201
|
+
junctionX = adjustJunctionForGroups(
|
|
202
|
+
junctionX,
|
|
203
|
+
srcCX,
|
|
204
|
+
srcCY,
|
|
205
|
+
groups,
|
|
206
|
+
direction,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
const bundled: { edge: PositionedEdge; points: Point[] }[] = []
|
|
210
|
+
for (const { edge, node: target } of targets) {
|
|
211
|
+
const entryX = isLR ? target.x : target.x + target.width
|
|
212
|
+
const entryY = target.y + target.height / 2
|
|
213
|
+
const points = [
|
|
214
|
+
{ x: exitX, y: exitY },
|
|
215
|
+
{ x: junctionX, y: exitY },
|
|
216
|
+
{ x: junctionX, y: entryY },
|
|
217
|
+
{ x: entryX, y: entryY },
|
|
218
|
+
]
|
|
219
|
+
if (pathClearOfNodes(points, nodes, new Set([sourceId, target.id]))) {
|
|
220
|
+
bundled.push({ edge, points })
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// One clear branch is not a bundle — leave the whole group as routed.
|
|
224
|
+
if (bundled.length < 2) continue
|
|
225
|
+
for (const { edge, points } of bundled) {
|
|
226
|
+
edge.points = points
|
|
227
|
+
processed.add(edge)
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
const exitX = srcCX
|
|
231
|
+
const exitY = isBT ? source.y : source.y + source.height
|
|
232
|
+
|
|
233
|
+
const nearestY = isBT
|
|
234
|
+
? Math.max(...targets.map((t) => t.node.y + t.node.height))
|
|
235
|
+
: Math.min(...targets.map((t) => t.node.y))
|
|
236
|
+
let junctionY = exitY + (nearestY - exitY) / 2
|
|
237
|
+
junctionY = adjustJunctionForGroups(
|
|
238
|
+
junctionY,
|
|
239
|
+
srcCX,
|
|
240
|
+
srcCY,
|
|
241
|
+
groups,
|
|
242
|
+
direction,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
const bundled: { edge: PositionedEdge; points: Point[] }[] = []
|
|
246
|
+
for (const { edge, node: target } of targets) {
|
|
247
|
+
const entryX = target.x + target.width / 2
|
|
248
|
+
const entryY = isBT ? target.y + target.height : target.y
|
|
249
|
+
const points = [
|
|
250
|
+
{ x: exitX, y: exitY },
|
|
251
|
+
{ x: exitX, y: junctionY },
|
|
252
|
+
{ x: entryX, y: junctionY },
|
|
253
|
+
{ x: entryX, y: entryY },
|
|
254
|
+
]
|
|
255
|
+
if (pathClearOfNodes(points, nodes, new Set([sourceId, target.id]))) {
|
|
256
|
+
bundled.push({ edge, points })
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// One clear branch is not a bundle — leave the whole group as routed.
|
|
260
|
+
if (bundled.length < 2) continue
|
|
261
|
+
for (const { edge, points } of bundled) {
|
|
262
|
+
edge.points = points
|
|
263
|
+
processed.add(edge)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --- Fan-in: group edges by shared target (skip already-bundled edges) ---
|
|
269
|
+
const fanInGroups = new Map<string, PositionedEdge[]>()
|
|
270
|
+
for (const edge of edges) {
|
|
271
|
+
if (processed.has(edge) || edge.source === edge.target) continue
|
|
272
|
+
const group = fanInGroups.get(edge.target) ?? []
|
|
273
|
+
fanInGroups.set(edge.target, group)
|
|
274
|
+
group.push(edge)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const [targetId, group] of fanInGroups) {
|
|
278
|
+
if (group.length < 2) continue
|
|
279
|
+
|
|
280
|
+
const style = group[0]!.style
|
|
281
|
+
if (group.some((e) => e.label || e.style !== style)) continue
|
|
282
|
+
|
|
283
|
+
const target = nodeMap.get(targetId)
|
|
284
|
+
if (!target) continue
|
|
285
|
+
|
|
286
|
+
// Collects the resolved source node alongside each edge as it filters,
|
|
287
|
+
// rather than re-querying nodeMap afterward (see the fan-out loop above
|
|
288
|
+
// for why: a map() over a separately-filtered array can't be proven to
|
|
289
|
+
// agree with the filter's own lookup).
|
|
290
|
+
const sources: { edge: PositionedEdge; node: PositionedNode }[] = []
|
|
291
|
+
for (const e of group) {
|
|
292
|
+
const s = nodeMap.get(e.source)
|
|
293
|
+
if (!s) continue
|
|
294
|
+
const isForward = isLR
|
|
295
|
+
? s.x + s.width < target.x
|
|
296
|
+
: isRL
|
|
297
|
+
? s.x > target.x + target.width
|
|
298
|
+
: isBT
|
|
299
|
+
? s.y > target.y + target.height
|
|
300
|
+
: s.y + s.height < target.y // TD/TB
|
|
301
|
+
if (isForward) sources.push({ edge: e, node: s })
|
|
302
|
+
}
|
|
303
|
+
if (sources.length < 2) continue
|
|
304
|
+
const tgtCX = target.x + target.width / 2
|
|
305
|
+
const tgtCY = target.y + target.height / 2
|
|
306
|
+
|
|
307
|
+
if (isHorizontal) {
|
|
308
|
+
const entryX = isLR ? target.x : target.x + target.width
|
|
309
|
+
const entryY = tgtCY
|
|
310
|
+
|
|
311
|
+
const farthestX = isLR
|
|
312
|
+
? Math.max(...sources.map((s) => s.node.x + s.node.width))
|
|
313
|
+
: Math.min(...sources.map((s) => s.node.x))
|
|
314
|
+
let junctionX = farthestX + (entryX - farthestX) / 2
|
|
315
|
+
junctionX = adjustJunctionForGroups(
|
|
316
|
+
junctionX,
|
|
317
|
+
tgtCX,
|
|
318
|
+
tgtCY,
|
|
319
|
+
groups,
|
|
320
|
+
direction,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
const bundled: { edge: PositionedEdge; points: Point[] }[] = []
|
|
324
|
+
for (const { edge, node: src } of sources) {
|
|
325
|
+
const exitX = isLR ? src.x + src.width : src.x
|
|
326
|
+
const exitY = src.y + src.height / 2
|
|
327
|
+
const points = [
|
|
328
|
+
{ x: exitX, y: exitY },
|
|
329
|
+
{ x: junctionX, y: exitY },
|
|
330
|
+
{ x: junctionX, y: entryY },
|
|
331
|
+
{ x: entryX, y: entryY },
|
|
332
|
+
]
|
|
333
|
+
if (pathClearOfNodes(points, nodes, new Set([src.id, targetId]))) {
|
|
334
|
+
bundled.push({ edge, points })
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// One clear branch is not a bundle — leave the whole group as routed.
|
|
338
|
+
if (bundled.length < 2) continue
|
|
339
|
+
for (const { edge, points } of bundled) {
|
|
340
|
+
edge.points = points
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
const entryX = tgtCX
|
|
344
|
+
const entryY = isBT ? target.y + target.height : target.y
|
|
345
|
+
|
|
346
|
+
const farthestY = isBT
|
|
347
|
+
? Math.min(...sources.map((s) => s.node.y))
|
|
348
|
+
: Math.max(...sources.map((s) => s.node.y + s.node.height))
|
|
349
|
+
let junctionY = farthestY + (entryY - farthestY) / 2
|
|
350
|
+
junctionY = adjustJunctionForGroups(
|
|
351
|
+
junctionY,
|
|
352
|
+
tgtCX,
|
|
353
|
+
tgtCY,
|
|
354
|
+
groups,
|
|
355
|
+
direction,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
const bundled: { edge: PositionedEdge; points: Point[] }[] = []
|
|
359
|
+
for (const { edge, node: src } of sources) {
|
|
360
|
+
const exitX = src.x + src.width / 2
|
|
361
|
+
const exitY = isBT ? src.y : src.y + src.height
|
|
362
|
+
const points = [
|
|
363
|
+
{ x: exitX, y: exitY },
|
|
364
|
+
{ x: exitX, y: junctionY },
|
|
365
|
+
{ x: entryX, y: junctionY },
|
|
366
|
+
{ x: entryX, y: entryY },
|
|
367
|
+
]
|
|
368
|
+
if (pathClearOfNodes(points, nodes, new Set([src.id, targetId]))) {
|
|
369
|
+
bundled.push({ edge, points })
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// One clear branch is not a bundle — leave the whole group as routed.
|
|
373
|
+
if (bundled.length < 2) continue
|
|
374
|
+
for (const { edge, points } of bundled) {
|
|
375
|
+
edge.points = points
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ELK-edge-geometry extraction helpers.
|
|
3
|
+
*
|
|
4
|
+
* `from-elk.ts` (flowchart/state), `../class/layout.ts`, and
|
|
5
|
+
* `../er/layout.ts` each independently walk an ELK edge's
|
|
6
|
+
* `section.startPoint → bendPoints → endPoint` into a `Point[]`, plus
|
|
7
|
+
* near-identical edge-label-position math. This module is the single
|
|
8
|
+
* place that logic lives, so the three call sites can't drift.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ElkExtendedEdge } from 'elkjs'
|
|
12
|
+
import type { Point } from '@zombie-mermaid/core'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Walk an ELK edge's first routed section into a flat point path:
|
|
16
|
+
* `startPoint → bendPoints → endPoint`.
|
|
17
|
+
*
|
|
18
|
+
* ELK can theoretically produce multiple sections per edge (for edges
|
|
19
|
+
* split across hierarchy boundaries via ports), but all three call sites
|
|
20
|
+
* only ever read `sections[0]` — hierarchical decomposition in this
|
|
21
|
+
* codebase is handled by emitting separate ELK edges (see
|
|
22
|
+
* `to-elk.ts`/`parseHopEdgeId` in `from-elk.ts`), not multi-section edges.
|
|
23
|
+
*
|
|
24
|
+
* `offsetX`/`offsetY` translate the section's coordinates into an
|
|
25
|
+
* ancestor's coordinate space, for callers walking a nested ELK result
|
|
26
|
+
* (`from-elk.ts`). Callers with a flat (non-hierarchical) ELK graph
|
|
27
|
+
* (`class/layout.ts`, `er/layout.ts`) can omit them.
|
|
28
|
+
*
|
|
29
|
+
* Returns an empty array if the edge has no routed sections.
|
|
30
|
+
*/
|
|
31
|
+
export function extractEdgePoints(
|
|
32
|
+
elkEdge: ElkExtendedEdge,
|
|
33
|
+
offsetX = 0,
|
|
34
|
+
offsetY = 0,
|
|
35
|
+
): Point[] {
|
|
36
|
+
const points: Point[] = []
|
|
37
|
+
if (!elkEdge.sections || elkEdge.sections.length === 0) return points
|
|
38
|
+
|
|
39
|
+
const section = elkEdge.sections[0]!
|
|
40
|
+
points.push({
|
|
41
|
+
x: section.startPoint.x + offsetX,
|
|
42
|
+
y: section.startPoint.y + offsetY,
|
|
43
|
+
})
|
|
44
|
+
if (section.bendPoints) {
|
|
45
|
+
for (const bp of section.bendPoints) {
|
|
46
|
+
points.push({ x: bp.x + offsetX, y: bp.y + offsetY })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
points.push({
|
|
50
|
+
x: section.endPoint.x + offsetX,
|
|
51
|
+
y: section.endPoint.y + offsetY,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return points
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute an edge label's center position from ELK's placed label box
|
|
59
|
+
* (`label.x/y` is the box's top-left corner).
|
|
60
|
+
*
|
|
61
|
+
* `offsetX`/`offsetY` translate into an ancestor's coordinate space, same
|
|
62
|
+
* as `extractEdgePoints`.
|
|
63
|
+
*
|
|
64
|
+
* Returns `undefined` if ELK didn't place a label (no label on the edge,
|
|
65
|
+
* or ELK left `x`/`y` unset).
|
|
66
|
+
*/
|
|
67
|
+
export function extractEdgeLabelPosition(
|
|
68
|
+
elkEdge: ElkExtendedEdge,
|
|
69
|
+
offsetX = 0,
|
|
70
|
+
offsetY = 0,
|
|
71
|
+
): Point | undefined {
|
|
72
|
+
if (!elkEdge.labels || elkEdge.labels.length === 0) return undefined
|
|
73
|
+
|
|
74
|
+
const label = elkEdge.labels[0]!
|
|
75
|
+
if (label.x == null || label.y == null) return undefined
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
x: label.x + (label.width ?? 0) / 2 + offsetX,
|
|
79
|
+
y: label.y + (label.height ?? 0) / 2 + offsetY,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ELK graph-*construction* helpers.
|
|
3
|
+
*
|
|
4
|
+
* The reverse direction (reading geometry back out of an ELK result) lives
|
|
5
|
+
* in `./elk-adapter-utils.ts`; this module is its input-side counterpart.
|
|
6
|
+
*
|
|
7
|
+
* Three renderers build ELK's typed JSON input independently —
|
|
8
|
+
* `./to-elk.ts` (flowchart + state), `../class/layout.ts`, and
|
|
9
|
+
* `../er/layout.ts`. They differ in domain model and in which extra
|
|
10
|
+
* `elk.*` options they set, but the primitives underneath are identical:
|
|
11
|
+
* one direction mapping, one `elk.padding` string format, one leaf-node
|
|
12
|
+
* shape, and one measured edge-label box. Those primitives live here so
|
|
13
|
+
* the three call sites can't drift (issue #616).
|
|
14
|
+
*
|
|
15
|
+
* This module deliberately does **not** try to abstract over the three
|
|
16
|
+
* diagram types' own graph shapes (subgraph nesting, hierarchical ports,
|
|
17
|
+
* note links) — that would be an engine-neutral intermediate graph, which
|
|
18
|
+
* is the separately-scoped work in #538.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ElkExtendedEdge, ElkLabel, ElkNode, LayoutOptions } from 'elkjs'
|
|
22
|
+
import type { Direction } from '@zombie-mermaid/core'
|
|
23
|
+
import { measureMultilineText } from '@zombie-mermaid/core'
|
|
24
|
+
import { FONT_WEIGHTS } from '../styles.ts'
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Direction
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/** The four values ELK's `elk.direction` option accepts in this codebase. */
|
|
31
|
+
export type ElkDirection = 'DOWN' | 'UP' | 'LEFT' | 'RIGHT'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The `elk.direction` each diagram type falls back to when its source
|
|
35
|
+
* carries no `direction` statement.
|
|
36
|
+
*
|
|
37
|
+
* These genuinely differ per diagram type, and the difference is
|
|
38
|
+
* intentional rather than an accident of three separate implementations:
|
|
39
|
+
*
|
|
40
|
+
* - **flowchart / state** — `DOWN`. `MermaidGraph.direction` is a required
|
|
41
|
+
* field the parser always fills in (defaulting to `TD`), so the fallback
|
|
42
|
+
* is only a type-level backstop; the effective default is mermaid's own
|
|
43
|
+
* top-down flowchart default.
|
|
44
|
+
* - **class** — `DOWN`. `ClassDiagram` has no direction concept at all (see
|
|
45
|
+
* `ClassRenderOptions` in `packages/core/src/types.ts`: class diagrams
|
|
46
|
+
* "have no `direction` or `curve` concept"), so this is the renderer's
|
|
47
|
+
* single fixed orientation, matching Mermaid's own TB class rendering —
|
|
48
|
+
* which `../class/layout.ts` relies on to put a `note for X` above its
|
|
49
|
+
* class.
|
|
50
|
+
* - **ER** — `RIGHT`. An ER diagram's `direction` is optional
|
|
51
|
+
* (`ErDiagram.direction?`), left `undefined` when the source has no
|
|
52
|
+
* `direction` statement, and this renderer has always laid those out
|
|
53
|
+
* left-to-right. `direction TB`/`LR`/`BT`/`RL` in the source (or
|
|
54
|
+
* `RenderOptions.direction`, applied via `withDirectionOverride`) still
|
|
55
|
+
* wins over it.
|
|
56
|
+
*
|
|
57
|
+
* The *mappings* were never in conflict — both hand-rolled
|
|
58
|
+
* `directionToElk()` implementations agreed on all five `Direction` values
|
|
59
|
+
* (`LR`→`RIGHT`, `RL`→`LEFT`, `BT`→`UP`, `TD`/`TB`→`DOWN`). Only the
|
|
60
|
+
* no-direction fallback differed, which is why it is a parameter here
|
|
61
|
+
* rather than something to reconcile away.
|
|
62
|
+
*/
|
|
63
|
+
export const ELK_DIRECTION_FALLBACK = {
|
|
64
|
+
flowchart: 'DOWN',
|
|
65
|
+
state: 'DOWN',
|
|
66
|
+
class: 'DOWN',
|
|
67
|
+
er: 'RIGHT',
|
|
68
|
+
} as const satisfies Record<string, ElkDirection>
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Convert a Mermaid `direction` to ELK's `elk.direction` value.
|
|
72
|
+
*
|
|
73
|
+
* `fallback` covers the no-`direction`-statement case only — every member
|
|
74
|
+
* of `Direction` has an explicit mapping. Pass the diagram type's entry
|
|
75
|
+
* from `ELK_DIRECTION_FALLBACK` rather than a bare string literal, so the
|
|
76
|
+
* per-type defaults stay documented in one place.
|
|
77
|
+
*/
|
|
78
|
+
export function directionToElk(
|
|
79
|
+
dir: Direction | undefined,
|
|
80
|
+
fallback: ElkDirection,
|
|
81
|
+
): ElkDirection {
|
|
82
|
+
switch (dir) {
|
|
83
|
+
case 'LR':
|
|
84
|
+
return 'RIGHT'
|
|
85
|
+
case 'RL':
|
|
86
|
+
return 'LEFT'
|
|
87
|
+
case 'BT':
|
|
88
|
+
return 'UP'
|
|
89
|
+
case 'TD':
|
|
90
|
+
case 'TB':
|
|
91
|
+
return 'DOWN'
|
|
92
|
+
default:
|
|
93
|
+
return fallback
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// Layout options
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
/** Per-side padding, for the asymmetric case (a subgraph's header gap). */
|
|
102
|
+
export interface ElkPaddingSides {
|
|
103
|
+
top: number
|
|
104
|
+
left: number
|
|
105
|
+
bottom: number
|
|
106
|
+
right: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Format ELK's `elk.padding` value. A single number applies to all four
|
|
111
|
+
* sides; an object sets them individually.
|
|
112
|
+
*/
|
|
113
|
+
export function elkPadding(padding: number | ElkPaddingSides): string {
|
|
114
|
+
const sides: ElkPaddingSides =
|
|
115
|
+
typeof padding === 'number'
|
|
116
|
+
? { top: padding, left: padding, bottom: padding, right: padding }
|
|
117
|
+
: padding
|
|
118
|
+
return `[top=${sides.top},left=${sides.left},bottom=${sides.bottom},right=${sides.right}]`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The `elk.*` options every one of the three graph builders sets, with the
|
|
123
|
+
* values they all agree on. Callers spread the result and add their own
|
|
124
|
+
* diagram-specific options on top.
|
|
125
|
+
*/
|
|
126
|
+
export function baseElkLayoutOptions(spec: {
|
|
127
|
+
direction: ElkDirection
|
|
128
|
+
nodeSpacing: number
|
|
129
|
+
layerSpacing: number
|
|
130
|
+
padding: number | ElkPaddingSides
|
|
131
|
+
}): LayoutOptions {
|
|
132
|
+
return {
|
|
133
|
+
'elk.algorithm': 'layered',
|
|
134
|
+
'elk.direction': spec.direction,
|
|
135
|
+
'elk.spacing.nodeNode': String(spec.nodeSpacing),
|
|
136
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': String(spec.layerSpacing),
|
|
137
|
+
'elk.padding': elkPadding(spec.padding),
|
|
138
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// Nodes
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build a leaf ELK node.
|
|
148
|
+
*
|
|
149
|
+
* `label` is attached as an ELK label when supplied — the flowchart/state
|
|
150
|
+
* path does that (ELK reads it for nothing, but `from-elk.ts` reads it
|
|
151
|
+
* back); class and ER carry labels in their own side tables instead and
|
|
152
|
+
* omit it. An empty-string label is still a label, so the check is
|
|
153
|
+
* `undefined`, not truthiness.
|
|
154
|
+
*/
|
|
155
|
+
export function buildElkLeafNode(
|
|
156
|
+
id: string,
|
|
157
|
+
size: { width: number; height: number },
|
|
158
|
+
label?: string,
|
|
159
|
+
): ElkNode {
|
|
160
|
+
const node: ElkNode = { id, width: size.width, height: size.height }
|
|
161
|
+
if (label !== undefined) node.labels = [{ text: label }]
|
|
162
|
+
return node
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// Edges
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Per-label layout options the flowchart/state path sets on every edge
|
|
171
|
+
* label. Class and ER instead set `elk.edgeLabels.placement` once on the
|
|
172
|
+
* root graph, so they pass no per-label options.
|
|
173
|
+
*/
|
|
174
|
+
export const INLINE_CENTERED_EDGE_LABEL: LayoutOptions = {
|
|
175
|
+
'elk.edgeLabels.inline': 'true',
|
|
176
|
+
'elk.edgeLabels.placement': 'CENTER',
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* How a caller wants its edge labels measured and placed. `fontSize` is
|
|
181
|
+
* the resolved `fontSizes.edgeLabel`; `layoutOptions`, when given, is
|
|
182
|
+
* attached to the ELK label itself.
|
|
183
|
+
*/
|
|
184
|
+
export interface ElkEdgeLabelStyle {
|
|
185
|
+
fontSize: number
|
|
186
|
+
layoutOptions?: LayoutOptions
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Measure an edge label and build ELK's label box for it.
|
|
191
|
+
*
|
|
192
|
+
* The `+8` / `+6` are the horizontal/vertical breathing room all three
|
|
193
|
+
* builders have always added around the measured text so ELK reserves a
|
|
194
|
+
* slightly larger channel than the glyphs strictly need.
|
|
195
|
+
*/
|
|
196
|
+
export function buildElkEdgeLabel(
|
|
197
|
+
text: string,
|
|
198
|
+
style: ElkEdgeLabelStyle,
|
|
199
|
+
): ElkLabel {
|
|
200
|
+
const metrics = measureMultilineText(
|
|
201
|
+
text,
|
|
202
|
+
style.fontSize,
|
|
203
|
+
FONT_WEIGHTS.edgeLabel,
|
|
204
|
+
)
|
|
205
|
+
const label: ElkLabel = {
|
|
206
|
+
text,
|
|
207
|
+
width: metrics.width + 8,
|
|
208
|
+
height: metrics.height + 6,
|
|
209
|
+
}
|
|
210
|
+
// Copied, not aliased: every label previously got its own fresh options
|
|
211
|
+
// literal, and ELK receives this graph by reference (see
|
|
212
|
+
// `elkLayoutSync`'s `saveDispatch`) rather than through a structured
|
|
213
|
+
// clone — so a shared object would be shared into ELK's own hands too.
|
|
214
|
+
if (style.layoutOptions) label.layoutOptions = { ...style.layoutOptions }
|
|
215
|
+
return label
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build a single-source/single-target ELK edge, with a measured label when
|
|
220
|
+
* `label` is non-empty. An absent or empty label leaves `labels` off the
|
|
221
|
+
* edge entirely (rather than setting it to an empty array or `undefined`),
|
|
222
|
+
* matching what all three builders did by hand.
|
|
223
|
+
*/
|
|
224
|
+
export function buildElkEdge(spec: {
|
|
225
|
+
id: string
|
|
226
|
+
source: string
|
|
227
|
+
target: string
|
|
228
|
+
label?: string
|
|
229
|
+
labelStyle: ElkEdgeLabelStyle
|
|
230
|
+
}): ElkExtendedEdge {
|
|
231
|
+
const edge: ElkExtendedEdge = {
|
|
232
|
+
id: spec.id,
|
|
233
|
+
sources: [spec.source],
|
|
234
|
+
targets: [spec.target],
|
|
235
|
+
}
|
|
236
|
+
if (spec.label) {
|
|
237
|
+
edge.labels = [buildElkEdgeLabel(spec.label, spec.labelStyle)]
|
|
238
|
+
}
|
|
239
|
+
return edge
|
|
240
|
+
}
|