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