@zombie-mermaid/svg-renderer 3.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +45 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -4
- package/dist/index.d.ts +34 -4
- package/dist/index.js +2310 -1115
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
- package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
- package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
- package/src/__tests__/c4-text-width-1210.test.ts +43 -0
- package/src/__tests__/class-component-order-1249.test.ts +40 -0
- package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
- package/src/__tests__/class-namespace-1196.test.ts +165 -0
- package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
- package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
- package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
- package/src/__tests__/er-default-direction-1250.test.ts +69 -0
- package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
- package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
- package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
- package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
- package/src/c4/arial-widths.ts +59 -0
- package/src/c4/layout.ts +234 -44
- package/src/c4/metrics.ts +56 -30
- package/src/c4/renderer.ts +1 -1
- package/src/class/layout.ts +178 -13
- package/src/class/renderer.ts +31 -2
- package/src/er/layout.ts +20 -4
- package/src/er/renderer.ts +10 -1
- package/src/layout-engine/back-edges.ts +73 -0
- package/src/layout-engine/compound-flat.ts +916 -0
- package/src/layout-engine/elk-graph-builder.ts +5 -4
- package/src/layout-engine/from-elk.ts +7 -2
- package/src/layout-engine/inner-edges.ts +329 -0
- package/src/layout-engine/layout-hints.ts +27 -0
- package/src/layout-engine/to-elk.ts +170 -20
- package/src/layout-engine.ts +38 -1
- package/src/registry.ts +2 -2
- package/src/renderer.ts +26 -2
- package/src/sequence/layout.ts +72 -4
- package/src/sequence/renderer.ts +155 -15
- package/src/title-gaps.ts +146 -0
- package/src/xychart/layout.ts +31 -2
- package/src/xychart/renderer.ts +1 -0
|
@@ -50,10 +50,11 @@ export type ElkDirection = 'DOWN' | 'UP' | 'LEFT' | 'RIGHT'
|
|
|
50
50
|
* - **C4** — `DOWN`. `C4Diagram.direction` is never set by the parser (C4
|
|
51
51
|
* sources have no direction statement), only by `RenderOptions.direction`,
|
|
52
52
|
* so an unset one is the top-down layout every C4 diagram is drawn in.
|
|
53
|
-
* - **ER** — `
|
|
53
|
+
* - **ER** — `DOWN`. An ER diagram's `direction` is optional
|
|
54
54
|
* (`ErDiagram.direction?`), left `undefined` when the source has no
|
|
55
|
-
* `direction` statement, and
|
|
56
|
-
* left-to-right.
|
|
55
|
+
* `direction` statement, and official Mermaid lays those out top-to-bottom
|
|
56
|
+
* (#1250; this renderer used to lay them out left-to-right).
|
|
57
|
+
* `direction TB`/`LR`/`BT`/`RL` in the source (or
|
|
57
58
|
* `RenderOptions.direction`, applied via `withDirectionOverride`) still
|
|
58
59
|
* wins over it.
|
|
59
60
|
*
|
|
@@ -67,7 +68,7 @@ export const ELK_DIRECTION_FALLBACK = {
|
|
|
67
68
|
flowchart: 'DOWN',
|
|
68
69
|
state: 'DOWN',
|
|
69
70
|
class: 'DOWN',
|
|
70
|
-
er: '
|
|
71
|
+
er: 'DOWN',
|
|
71
72
|
c4: 'DOWN',
|
|
72
73
|
} as const satisfies Record<string, ElkDirection>
|
|
73
74
|
|
|
@@ -22,6 +22,7 @@ import { clipEdgeToShape } from '../shape-clipping.ts'
|
|
|
22
22
|
import { DEFAULTS } from './constants.ts'
|
|
23
23
|
import { alignLayerNodes } from './layer-alignment.ts'
|
|
24
24
|
import { bundleEdgePaths } from './edge-bundling.ts'
|
|
25
|
+
import { edgesReversedForLayout } from './to-elk.ts'
|
|
25
26
|
import {
|
|
26
27
|
extractEdgePoints,
|
|
27
28
|
extractEdgeLabelPosition,
|
|
@@ -527,7 +528,11 @@ function extractEdgesRecursively(
|
|
|
527
528
|
style: originalEdge.style,
|
|
528
529
|
hasArrowStart: originalEdge.hasArrowStart,
|
|
529
530
|
hasArrowEnd: originalEdge.hasArrowEnd,
|
|
530
|
-
|
|
531
|
+
// An edge laid out reversed (see `edgesReversedForLayout`) comes back
|
|
532
|
+
// running target to source; flip it so it runs source to target again.
|
|
533
|
+
points: edgesReversedForLayout(graph).has(edgeIndex)
|
|
534
|
+
? [...orthogonalPoints].reverse()
|
|
535
|
+
: orthogonalPoints,
|
|
531
536
|
labelPosition,
|
|
532
537
|
inlineStyle: resolveEdgeStyle(edgeIndex, graph),
|
|
533
538
|
id: originalEdge.id,
|
|
@@ -740,7 +745,7 @@ function collectAllSubgraphIds(sg: MermaidSubgraph, out: Set<string>): void {
|
|
|
740
745
|
* Resolve inline styles for an edge from linkStyles map.
|
|
741
746
|
* Default link style is applied first, then index-specific overrides.
|
|
742
747
|
*/
|
|
743
|
-
function resolveEdgeStyle(
|
|
748
|
+
export function resolveEdgeStyle(
|
|
744
749
|
edgeIndex: number,
|
|
745
750
|
graph: MermaidGraph,
|
|
746
751
|
): Record<string, string> | undefined {
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routes an edge between two members of a subgraph through the inside of its
|
|
3
|
+
* box.
|
|
4
|
+
*
|
|
5
|
+
* `compound-flat.ts` keeps outsiders out of a box with invisible spine nodes in
|
|
6
|
+
* the layers where the subgraph has no member. An edge from one member to
|
|
7
|
+
* another that spans such a layer has to get past the spine, and ELK sends it
|
|
8
|
+
* round the outside, which drags its end node, and so the box, sideways. These
|
|
9
|
+
* edges are left out of the layout and drawn afterwards by `routeInnerEdge`,
|
|
10
|
+
* which runs them down a channel between the members.
|
|
11
|
+
*
|
|
12
|
+
* Everything is worked out as (a, c): `a` along the flow, increasing with it,
|
|
13
|
+
* and `c` across it. For `TD` that is (y, x); `BT` negates `a`, `LR` is (x, y).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Direction, Point, PositionedNode } from '@zombie-mermaid/core'
|
|
17
|
+
|
|
18
|
+
/** Room kept between a route and the nodes it passes, in px. */
|
|
19
|
+
const CLEARANCE = 10
|
|
20
|
+
|
|
21
|
+
/** Least distance between two routes that run side by side, in px. */
|
|
22
|
+
const PARALLEL_GAP = 14
|
|
23
|
+
|
|
24
|
+
/** How far an edge runs out of a node before turning, in px. */
|
|
25
|
+
const STUB = 14
|
|
26
|
+
|
|
27
|
+
/** Room kept between a route and the box's border, in px. */
|
|
28
|
+
const BORDER_INSET = 10
|
|
29
|
+
|
|
30
|
+
/** Cost of each bend, in px of extra length, so a straighter route wins. */
|
|
31
|
+
const BEND_COST = 24
|
|
32
|
+
|
|
33
|
+
/** Extra cost of leaving a decision by its bottom vertex, in px. */
|
|
34
|
+
const DECISION_STEM = 400
|
|
35
|
+
|
|
36
|
+
/** Shapes whose whole edge is a straight side an edge can leave from anywhere. */
|
|
37
|
+
const FLAT_SIDED = new Set(['rectangle', 'rounded', 'subroutine'])
|
|
38
|
+
|
|
39
|
+
interface Span {
|
|
40
|
+
a0: number
|
|
41
|
+
a1: number
|
|
42
|
+
c0: number
|
|
43
|
+
c1: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Pt {
|
|
47
|
+
a: number
|
|
48
|
+
c: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Frame {
|
|
52
|
+
span: (box: BoxLike) => Span
|
|
53
|
+
toPoint: (p: Pt) => Point
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type BoxLike = { x: number; y: number; width: number; height: number }
|
|
57
|
+
|
|
58
|
+
function frameFor(direction: Direction): Frame {
|
|
59
|
+
const vertical =
|
|
60
|
+
direction === 'TD' || direction === 'TB' || direction === 'BT'
|
|
61
|
+
const flip = direction === 'BT' || direction === 'RL'
|
|
62
|
+
const sign = flip ? -1 : 1
|
|
63
|
+
return {
|
|
64
|
+
span: (b) => {
|
|
65
|
+
const [p0, p1] = vertical ? [b.y, b.y + b.height] : [b.x, b.x + b.width]
|
|
66
|
+
const a0 = sign * p0
|
|
67
|
+
const a1 = sign * p1
|
|
68
|
+
return {
|
|
69
|
+
a0: Math.min(a0, a1),
|
|
70
|
+
a1: Math.max(a0, a1),
|
|
71
|
+
c0: vertical ? b.x : b.y,
|
|
72
|
+
c1: vertical ? b.x + b.width : b.y + b.height,
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
toPoint: ({ a, c }) =>
|
|
76
|
+
vertical ? { x: c, y: sign * a } : { x: sign * a, y: c },
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether the segment `p`-`q` (one axis only) passes through the inside of `s`. */
|
|
81
|
+
function crosses(p: Pt, q: Pt, s: Span): boolean {
|
|
82
|
+
const aLo = Math.min(p.a, q.a)
|
|
83
|
+
const aHi = Math.max(p.a, q.a)
|
|
84
|
+
const cLo = Math.min(p.c, q.c)
|
|
85
|
+
const cHi = Math.max(p.c, q.c)
|
|
86
|
+
return aHi > s.a0 && aLo < s.a1 && cHi > s.c0 && cLo < s.c1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const grow = (s: Span, by: number): Span => ({
|
|
90
|
+
a0: s.a0 - by,
|
|
91
|
+
a1: s.a1 + by,
|
|
92
|
+
c0: s.c0 - by,
|
|
93
|
+
c1: s.c1 + by,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
/** The (a, c) of a point in `frame`. */
|
|
97
|
+
function toPt(frame: Frame, p: Point): Pt {
|
|
98
|
+
const s = frame.span({ x: p.x, y: p.y, width: 0, height: 0 })
|
|
99
|
+
return { a: s.a0, c: s.c0 }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Whether the segment `p`-`q` runs beside a segment of `path`, closer than `PARALLEL_GAP`. */
|
|
103
|
+
function runsAlong(p: Pt, q: Pt, path: readonly Pt[]): boolean {
|
|
104
|
+
const flowwise = p.c === q.c
|
|
105
|
+
for (let i = 1; i < path.length; i++) {
|
|
106
|
+
const r = path[i - 1]!
|
|
107
|
+
const t = path[i]!
|
|
108
|
+
if ((r.c === t.c) !== flowwise) continue
|
|
109
|
+
if (flowwise) {
|
|
110
|
+
const apart = Math.abs(p.c - r.c)
|
|
111
|
+
const shared =
|
|
112
|
+
Math.min(Math.max(p.a, q.a), Math.max(r.a, t.a)) -
|
|
113
|
+
Math.max(Math.min(p.a, q.a), Math.min(r.a, t.a))
|
|
114
|
+
if (apart < PARALLEL_GAP && shared > 0) return true
|
|
115
|
+
} else {
|
|
116
|
+
const apart = Math.abs(p.a - r.a)
|
|
117
|
+
const shared =
|
|
118
|
+
Math.min(Math.max(p.c, q.c), Math.max(r.c, t.c)) -
|
|
119
|
+
Math.max(Math.min(p.c, q.c), Math.min(r.c, t.c))
|
|
120
|
+
if (apart < PARALLEL_GAP && shared > 0) return true
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The path with repeated points and points in the middle of a straight run removed. */
|
|
127
|
+
function simplify(path: Pt[]): Pt[] {
|
|
128
|
+
const out: Pt[] = []
|
|
129
|
+
for (const p of path) {
|
|
130
|
+
const last = out[out.length - 1]
|
|
131
|
+
if (last && last.a === p.a && last.c === p.c) continue
|
|
132
|
+
out.push(p)
|
|
133
|
+
}
|
|
134
|
+
for (let i = out.length - 2; i > 0; i--) {
|
|
135
|
+
const prev = out[i - 1]!
|
|
136
|
+
const here = out[i]!
|
|
137
|
+
const next = out[i + 1]!
|
|
138
|
+
const straight =
|
|
139
|
+
(prev.a === here.a && here.a === next.a) ||
|
|
140
|
+
(prev.c === here.c && here.c === next.c)
|
|
141
|
+
if (straight) out.splice(i, 1)
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const length = (path: Pt[]): number => {
|
|
147
|
+
let total = 0
|
|
148
|
+
for (let i = 1; i < path.length; i++) {
|
|
149
|
+
total +=
|
|
150
|
+
Math.abs(path[i]!.a - path[i - 1]!.a) +
|
|
151
|
+
Math.abs(path[i]!.c - path[i - 1]!.c)
|
|
152
|
+
}
|
|
153
|
+
return total
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A route for the edge from `source` to `target`, both inside `bounds` (their
|
|
158
|
+
* subgraph's box), kept clear of `others`. The cheapest of a straight line, a
|
|
159
|
+
* Z for an edge that runs with the flow, and a hook out of the side of the
|
|
160
|
+
* source and up the side of the target for one that runs against it. `undefined`
|
|
161
|
+
* if nothing fits.
|
|
162
|
+
*/
|
|
163
|
+
export function routeInnerEdge(
|
|
164
|
+
source: PositionedNode,
|
|
165
|
+
target: PositionedNode,
|
|
166
|
+
others: readonly PositionedNode[],
|
|
167
|
+
bounds: BoxLike,
|
|
168
|
+
direction: Direction,
|
|
169
|
+
drawn: readonly (readonly Point[])[] = [],
|
|
170
|
+
): Point[] | undefined {
|
|
171
|
+
const frame = frameFor(direction)
|
|
172
|
+
const S = frame.span(source)
|
|
173
|
+
const T = frame.span(target)
|
|
174
|
+
const forward = S.a1 <= T.a0
|
|
175
|
+
const backward = T.a1 <= S.a0
|
|
176
|
+
if (!forward && !backward) return undefined
|
|
177
|
+
|
|
178
|
+
const inner = frame.span(bounds)
|
|
179
|
+
const room: Span = {
|
|
180
|
+
a0: inner.a0 + BORDER_INSET,
|
|
181
|
+
a1: inner.a1 - BORDER_INSET,
|
|
182
|
+
c0: inner.c0 + BORDER_INSET,
|
|
183
|
+
c1: inner.c1 - BORDER_INSET,
|
|
184
|
+
}
|
|
185
|
+
const before = drawn.map((path) => path.map((p) => toPt(frame, p)))
|
|
186
|
+
const blocks = others.map((n) => grow(frame.span(n), CLEARANCE))
|
|
187
|
+
const mid = (s: Span): number => (s.c0 + s.c1) / 2
|
|
188
|
+
const midA = (s: Span): number => (s.a0 + s.a1) / 2
|
|
189
|
+
const flat = (n: PositionedNode): boolean => FLAT_SIDED.has(n.shape)
|
|
190
|
+
|
|
191
|
+
/** Whether every segment is clear of the nodes, the ends touch only their own node, and it stays in the box. */
|
|
192
|
+
const valid = (path: Pt[]): boolean => {
|
|
193
|
+
for (const p of path) {
|
|
194
|
+
if (p.a < room.a0 || p.a > room.a1 || p.c < room.c0 || p.c > room.c1) {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (let i = 1; i < path.length; i++) {
|
|
199
|
+
const p = path[i - 1]!
|
|
200
|
+
const q = path[i]!
|
|
201
|
+
if (p.a !== q.a && p.c !== q.c) return false
|
|
202
|
+
if (blocks.some((b) => crosses(p, q, b))) return false
|
|
203
|
+
if (crosses(p, q, S) || crosses(p, q, T)) return false
|
|
204
|
+
if (before.some((path) => runsAlong(p, q, path))) return false
|
|
205
|
+
}
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Cross-flow positions worth trying: where the two nodes are, and just clear
|
|
210
|
+
// of every node and the box's border.
|
|
211
|
+
const candidates = new Set<number>([mid(S), mid(T), room.c0, room.c1])
|
|
212
|
+
for (const b of blocks) {
|
|
213
|
+
candidates.add(b.c0 - 1)
|
|
214
|
+
candidates.add(b.c1 + 1)
|
|
215
|
+
}
|
|
216
|
+
for (const s of [S, T]) {
|
|
217
|
+
candidates.add(s.c0 - CLEARANCE)
|
|
218
|
+
candidates.add(s.c1 + CLEARANCE)
|
|
219
|
+
}
|
|
220
|
+
const within = (n: PositionedNode, s: Span, c: number): boolean =>
|
|
221
|
+
flat(n) ? c >= s.c0 + 4 && c <= s.c1 - 4 : Math.abs(c - mid(s)) < 0.5
|
|
222
|
+
|
|
223
|
+
const routes: Pt[][] = []
|
|
224
|
+
if (forward) {
|
|
225
|
+
const gap = (T.a0 - S.a1) / 2
|
|
226
|
+
const a1 = S.a1 + Math.min(STUB, gap)
|
|
227
|
+
const a2 = T.a0 - Math.min(STUB, gap)
|
|
228
|
+
for (const c of candidates) {
|
|
229
|
+
if (within(source, S, c) && within(target, T, c)) {
|
|
230
|
+
routes.push([
|
|
231
|
+
{ a: S.a1, c },
|
|
232
|
+
{ a: T.a0, c },
|
|
233
|
+
])
|
|
234
|
+
}
|
|
235
|
+
// A Z, or an L-shaped pair of turns when one end can take the edge off centre.
|
|
236
|
+
routes.push([
|
|
237
|
+
{ a: S.a1, c: mid(S) },
|
|
238
|
+
{ a: a1, c: mid(S) },
|
|
239
|
+
{ a: a1, c },
|
|
240
|
+
{ a: a2, c },
|
|
241
|
+
{ a: a2, c: mid(T) },
|
|
242
|
+
{ a: T.a0, c: mid(T) },
|
|
243
|
+
])
|
|
244
|
+
if (within(target, T, c)) {
|
|
245
|
+
routes.push([
|
|
246
|
+
{ a: S.a1, c: mid(S) },
|
|
247
|
+
{ a: a1, c: mid(S) },
|
|
248
|
+
{ a: a1, c },
|
|
249
|
+
{ a: T.a0, c },
|
|
250
|
+
])
|
|
251
|
+
}
|
|
252
|
+
if (within(source, S, c)) {
|
|
253
|
+
routes.push([
|
|
254
|
+
{ a: S.a1, c },
|
|
255
|
+
{ a: a2, c },
|
|
256
|
+
{ a: a2, c: mid(T) },
|
|
257
|
+
{ a: T.a0, c: mid(T) },
|
|
258
|
+
])
|
|
259
|
+
}
|
|
260
|
+
// Out of the source's side and down: for a decision, whose other
|
|
261
|
+
// branches leave by the bottom.
|
|
262
|
+
const side = c >= S.c1 ? S.c1 : c <= S.c0 ? S.c0 : undefined
|
|
263
|
+
if (side !== undefined) {
|
|
264
|
+
const down = within(target, T, c)
|
|
265
|
+
? [{ a: T.a0, c }]
|
|
266
|
+
: [
|
|
267
|
+
{ a: a2, c },
|
|
268
|
+
{ a: a2, c: mid(T) },
|
|
269
|
+
{ a: T.a0, c: mid(T) },
|
|
270
|
+
]
|
|
271
|
+
routes.push([{ a: midA(S), c: side }, { a: midA(S), c }, ...down])
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
for (const c of candidates) {
|
|
276
|
+
const side = c >= T.c1 ? T.c1 : c <= T.c0 ? T.c0 : undefined
|
|
277
|
+
if (side === undefined) continue
|
|
278
|
+
// Out of the source's back, up, and into the target's side.
|
|
279
|
+
if (within(source, S, c)) {
|
|
280
|
+
routes.push([
|
|
281
|
+
{ a: S.a0, c },
|
|
282
|
+
{ a: midA(T), c },
|
|
283
|
+
{ a: midA(T), c: side },
|
|
284
|
+
])
|
|
285
|
+
}
|
|
286
|
+
// Out of the source's side, along, up, and into the target's side.
|
|
287
|
+
const out = side === T.c1 ? S.c1 : S.c0
|
|
288
|
+
routes.push([
|
|
289
|
+
{ a: midA(S), c: out },
|
|
290
|
+
{ a: midA(S), c },
|
|
291
|
+
{ a: midA(T), c },
|
|
292
|
+
{ a: midA(T), c: side },
|
|
293
|
+
])
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let best: Pt[] | undefined
|
|
298
|
+
let bestCost = Number.POSITIVE_INFINITY
|
|
299
|
+
for (const route of routes) {
|
|
300
|
+
const path = simplify(route)
|
|
301
|
+
if (path.length < 2 || !valid(path)) continue
|
|
302
|
+
// A decision's branches fan out of its sides; leaving by the bottom only
|
|
303
|
+
// when nothing else fits.
|
|
304
|
+
const stem =
|
|
305
|
+
source.shape === 'diamond' && path[0]!.a === S.a1 ? DECISION_STEM : 0
|
|
306
|
+
const cost = length(path) + BEND_COST * (path.length - 2) + stem
|
|
307
|
+
if (cost < bestCost) {
|
|
308
|
+
best = path
|
|
309
|
+
bestCost = cost
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return best?.map(frame.toPoint)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The middle of a path's longest segment, where an edge label sits. */
|
|
316
|
+
export function labelSpot(points: readonly Point[]): Point {
|
|
317
|
+
let best = { x: points[0]!.x, y: points[0]!.y }
|
|
318
|
+
let longest = -1
|
|
319
|
+
for (let i = 1; i < points.length; i++) {
|
|
320
|
+
const p = points[i - 1]!
|
|
321
|
+
const q = points[i]!
|
|
322
|
+
const len = Math.abs(q.x - p.x) + Math.abs(q.y - p.y)
|
|
323
|
+
if (len > longest) {
|
|
324
|
+
longest = len
|
|
325
|
+
best = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return best
|
|
329
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extra instructions a caller can give the layout pipeline for one run, for
|
|
3
|
+
* things a `MermaidGraph` has no way to say. Used by the compound layout
|
|
4
|
+
* (`compound-flat.ts`), which adds invisible helper nodes and edges to the
|
|
5
|
+
* graph and has to size, order and weight them.
|
|
6
|
+
*/
|
|
7
|
+
export interface LayoutHints {
|
|
8
|
+
/** Node widths in px, used instead of the size worked out from the label. */
|
|
9
|
+
fixedWidths?: ReadonlyMap<string, number>
|
|
10
|
+
/**
|
|
11
|
+
* Indices of edges that only shape the layout and are never drawn. They are
|
|
12
|
+
* not straightened, so they can't drag the nodes they join into line.
|
|
13
|
+
*/
|
|
14
|
+
looseEdges?: ReadonlySet<number>
|
|
15
|
+
/**
|
|
16
|
+
* Indices of real edges to leave out of the layout. The caller draws them
|
|
17
|
+
* itself, so ELK doesn't route them round the helper nodes.
|
|
18
|
+
*/
|
|
19
|
+
detachedEdges?: ReadonlySet<number>
|
|
20
|
+
/**
|
|
21
|
+
* The order a back-edge search starts from, when it must differ from the
|
|
22
|
+
* graph's node order (which is also the order ELK is given the nodes in).
|
|
23
|
+
*/
|
|
24
|
+
walkOrder?: readonly string[]
|
|
25
|
+
/** Keep every layer's nodes in the order the graph lists them. */
|
|
26
|
+
forceNodeOrder?: boolean
|
|
27
|
+
}
|
|
@@ -17,6 +17,8 @@ import type { FontSizes } from '../styles.ts'
|
|
|
17
17
|
import { FONT_WEIGHTS, NODE_PADDING } from '../styles.ts'
|
|
18
18
|
import { measureMultilineText } from '@zombie-mermaid/core'
|
|
19
19
|
import { DEFAULTS } from './constants.ts'
|
|
20
|
+
import { findBackEdgeIndexes } from './back-edges.ts'
|
|
21
|
+
import type { LayoutHints } from './layout-hints.ts'
|
|
20
22
|
import {
|
|
21
23
|
ELK_DIRECTION_FALLBACK,
|
|
22
24
|
INLINE_CENTERED_EDGE_LABEL,
|
|
@@ -29,6 +31,100 @@ import {
|
|
|
29
31
|
type ElkPaddingSides,
|
|
30
32
|
} from './elk-graph-builder.ts'
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* ELK layer constraint for a top-level `[*]` marker: the start marker is
|
|
36
|
+
* pinned to the first layer so a back-edge can't drag it into the middle
|
|
37
|
+
* (#1240). The end marker is deliberately left unconstrained: it settles in
|
|
38
|
+
* the layer right after the state(s) that transition into it, instead of a
|
|
39
|
+
* layer of its own after every other node (`LAST_SEPARATE`), which drew
|
|
40
|
+
* `Closed --> [*]` as a long line down the page edge when `Closed` is near
|
|
41
|
+
* the top. Keyed by node shape.
|
|
42
|
+
*/
|
|
43
|
+
const STATE_MARKER_LAYER_CONSTRAINT: Partial<Record<string, string>> = {
|
|
44
|
+
'state-start': 'FIRST',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Root-graph ELK options applied to state diagrams only (flowcharts keep
|
|
49
|
+
* their tuned defaults).
|
|
50
|
+
*
|
|
51
|
+
* - `cycleBreaking: MODEL_ORDER` reverses the edge that points *against*
|
|
52
|
+
* declaration order instead of whichever edge ELK's greedy heuristic picks.
|
|
53
|
+
* The greedy default reversed the main chain of `[*] --> Idle`,
|
|
54
|
+
* `Idle --> Active`, `Active --> Idle`, drawing it bottom-to-top with the
|
|
55
|
+
* start marker beside a state.
|
|
56
|
+
* - `postCompaction: NONE`: LEFT_RIGHT_CONSTRAINT_LOCKING compaction slides
|
|
57
|
+
* nodes sideways after edges are routed, which leaves state edges ending
|
|
58
|
+
* in a horizontal run along the target's top border (arrowhead sideways).
|
|
59
|
+
*/
|
|
60
|
+
const STATE_LAYOUT_OPTIONS: LayoutOptions = {
|
|
61
|
+
'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
|
|
62
|
+
'elk.layered.compaction.postCompaction.strategy': 'NONE',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether `graph` is a `stateDiagram` (it has `[*]` markers, the only thing
|
|
67
|
+
* that distinguishes a state graph from a flowchart here). Composite states
|
|
68
|
+
* are included: their compound nodes are placed among their siblings at
|
|
69
|
+
* first-mention position (see `firstMentionRank`), so `MODEL_ORDER` reads
|
|
70
|
+
* them in source order instead of after every leaf (#1287).
|
|
71
|
+
*/
|
|
72
|
+
export function isStateGraph(graph: MermaidGraph): boolean {
|
|
73
|
+
for (const node of graph.nodes.values()) {
|
|
74
|
+
if (node.shape === 'state-start' || node.shape === 'state-end') return true
|
|
75
|
+
}
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Room a subgraph's box leaves around its contents, in px. The top is the
|
|
81
|
+
* title bar (28) plus a gap (16) to match the bottom. Shared with the compound
|
|
82
|
+
* layout, which draws the boxes itself.
|
|
83
|
+
*/
|
|
84
|
+
export const SUBGRAPH_PADDING = { top: 44, left: 16, bottom: 16, right: 16 }
|
|
85
|
+
|
|
86
|
+
const reversedCache = new WeakMap<MermaidGraph, Set<number>>()
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Indices of the edges handed to ELK reversed, so the layout breaks cycles the
|
|
90
|
+
* way mermaid.js (dagre) does: see `findBackEdgeIndexes`. `elkToPositioned`
|
|
91
|
+
* flips each one's points back so they still run source to target.
|
|
92
|
+
*
|
|
93
|
+
* Flowcharts only. State diagrams have their own cycle breaking (above), and a
|
|
94
|
+
* graph with a subgraph direction override goes through hop edges and ports
|
|
95
|
+
* that this does not rewrite.
|
|
96
|
+
*/
|
|
97
|
+
export function edgesReversedForLayout(
|
|
98
|
+
graph: MermaidGraph,
|
|
99
|
+
hints?: LayoutHints,
|
|
100
|
+
): Set<number> {
|
|
101
|
+
// Worked out once per graph object, by whichever of `mermaidToElk` (which
|
|
102
|
+
// has the hints) and `elkToPositioned` (which doesn't need them) asks first.
|
|
103
|
+
let set = reversedCache.get(graph)
|
|
104
|
+
if (!set) {
|
|
105
|
+
set =
|
|
106
|
+
isStateGraph(graph) || hasAnyDirectionOverride(graph.subgraphs)
|
|
107
|
+
? new Set()
|
|
108
|
+
: findBackEdgeIndexes(graph, hints?.walkOrder)
|
|
109
|
+
reversedCache.set(graph, set)
|
|
110
|
+
}
|
|
111
|
+
return set
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Source position of a root-level state or composite: the index of the first
|
|
116
|
+
* edge that names it. A composite no edge names itself falls back to the first
|
|
117
|
+
* edge naming a state inside it. Items no edge mentions rank last, keeping
|
|
118
|
+
* their relative order.
|
|
119
|
+
*/
|
|
120
|
+
function firstMentionRank(graph: MermaidGraph, ids: Set<string>): number {
|
|
121
|
+
for (let i = 0; i < graph.edges.length; i++) {
|
|
122
|
+
const e = graph.edges[i]!
|
|
123
|
+
if (ids.has(e.source) || ids.has(e.target)) return i
|
|
124
|
+
}
|
|
125
|
+
return Number.POSITIVE_INFINITY
|
|
126
|
+
}
|
|
127
|
+
|
|
32
128
|
/**
|
|
33
129
|
* Convert a Mermaid direction to an ELK direction, flowchart/state-style
|
|
34
130
|
* (no direction at all → `DOWN`; see `ELK_DIRECTION_FALLBACK`).
|
|
@@ -156,7 +252,7 @@ const ROOT_CONTAINER = ' root'
|
|
|
156
252
|
* compound node's own `elk.direction`) for exactly the diagrams that need
|
|
157
253
|
* `SEPARATE` the most.
|
|
158
254
|
*/
|
|
159
|
-
function hasAnyDirectionOverride(subgraphs: MermaidSubgraph[]): boolean {
|
|
255
|
+
export function hasAnyDirectionOverride(subgraphs: MermaidSubgraph[]): boolean {
|
|
160
256
|
for (const sg of subgraphs) {
|
|
161
257
|
if (sg.direction !== undefined) return true
|
|
162
258
|
if (hasAnyDirectionOverride(sg.children)) return true
|
|
@@ -231,10 +327,12 @@ function commonPrefixLength(a: string[], b: string[]): number {
|
|
|
231
327
|
*/
|
|
232
328
|
export function mermaidToElk(
|
|
233
329
|
graph: MermaidGraph,
|
|
234
|
-
|
|
330
|
+
givenOpts: Required<
|
|
235
331
|
Pick<RenderOptions, 'font' | 'padding' | 'nodeSpacing' | 'layerSpacing'>
|
|
236
332
|
> & { fontSizes: FontSizes },
|
|
333
|
+
hints?: LayoutHints,
|
|
237
334
|
): ElkGraphNode {
|
|
335
|
+
const opts = givenOpts
|
|
238
336
|
// Collect all node IDs that belong to subgraphs
|
|
239
337
|
const subgraphNodeIds = new Set<string>()
|
|
240
338
|
const subgraphIds = new Set<string>()
|
|
@@ -325,6 +423,12 @@ export function mermaidToElk(
|
|
|
325
423
|
'elk.layered.compaction.postCompaction.strategy':
|
|
326
424
|
'LEFT_RIGHT_CONSTRAINT_LOCKING',
|
|
327
425
|
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
|
|
426
|
+
// Every layer keeps the nodes in the order the graph lists them: the
|
|
427
|
+
// compound layout relies on it to keep a subgraph's nodes together.
|
|
428
|
+
...(hints?.forceNodeOrder
|
|
429
|
+
? { 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true' }
|
|
430
|
+
: {}),
|
|
431
|
+
...(isStateGraph(graph) ? STATE_LAYOUT_OPTIONS : {}),
|
|
328
432
|
'elk.layered.wrapping.strategy': 'OFF',
|
|
329
433
|
// Use SEPARATE when subgraphs have direction overrides (enables proper direction handling)
|
|
330
434
|
// Use INCLUDE_CHILDREN otherwise (simpler cross-hierarchy edge routing)
|
|
@@ -332,7 +436,6 @@ export function mermaidToElk(
|
|
|
332
436
|
? 'SEPARATE'
|
|
333
437
|
: 'INCLUDE_CHILDREN',
|
|
334
438
|
}
|
|
335
|
-
|
|
336
439
|
// Ports to declare on each subgraph's ELK node, keyed by subgraph ID.
|
|
337
440
|
const portsBySubgraph = new Map<string, Set<string>>()
|
|
338
441
|
// Hop/bridge edges to inject into each container's own `edges` array.
|
|
@@ -432,7 +535,19 @@ export function mermaidToElk(
|
|
|
432
535
|
node.shape,
|
|
433
536
|
opts.fontSizes.nodeLabel,
|
|
434
537
|
)
|
|
435
|
-
|
|
538
|
+
const fixedWidth = hints?.fixedWidths?.get(id)
|
|
539
|
+
if (fixedWidth !== undefined) size.width = fixedWidth
|
|
540
|
+
const leaf = buildElkLeafNode(id, size, node.label)
|
|
541
|
+
// A top-level start `[*]` marks where reading starts: pin it to the
|
|
542
|
+
// first layer so a back-edge can't drag it into the middle. The end
|
|
543
|
+
// marker stays free (see STATE_MARKER_LAYER_CONSTRAINT).
|
|
544
|
+
const pin = isStateGraph(graph)
|
|
545
|
+
? STATE_MARKER_LAYER_CONSTRAINT[node.shape]
|
|
546
|
+
: undefined
|
|
547
|
+
if (pin !== undefined) {
|
|
548
|
+
leaf.layoutOptions = { 'elk.layered.layering.layerConstraint': pin }
|
|
549
|
+
}
|
|
550
|
+
rootChildren.push(leaf)
|
|
436
551
|
}
|
|
437
552
|
}
|
|
438
553
|
|
|
@@ -460,7 +575,14 @@ export function mermaidToElk(
|
|
|
460
575
|
// subgraph in a cyclic flow (e.g. "CI/CD Pipeline"), reordering the
|
|
461
576
|
// entire rank structure rather than just left-right sibling position —
|
|
462
577
|
// a much bigger, unreviewed blast radius than the reported bug needs.
|
|
463
|
-
|
|
578
|
+
const stateGraph = isStateGraph(graph)
|
|
579
|
+
const leafCount = rootChildren.length
|
|
580
|
+
// State diagrams read in source order, so composites keep forward
|
|
581
|
+
// declaration order and are interleaved with the leaves below.
|
|
582
|
+
const subgraphOrder = stateGraph
|
|
583
|
+
? graph.subgraphs
|
|
584
|
+
: [...graph.subgraphs].reverse()
|
|
585
|
+
for (const sg of subgraphOrder) {
|
|
464
586
|
rootChildren.push(
|
|
465
587
|
subgraphToElk(
|
|
466
588
|
sg,
|
|
@@ -473,22 +595,50 @@ export function mermaidToElk(
|
|
|
473
595
|
),
|
|
474
596
|
)
|
|
475
597
|
}
|
|
598
|
+
if (stateGraph && graph.subgraphs.length > 0) {
|
|
599
|
+
// ELK's MODEL_ORDER cycle breaking and crossing minimisation follow the
|
|
600
|
+
// child order, so a composite must sit at its first-mention position
|
|
601
|
+
// among its siblings rather than after every leaf (#1287). Array#sort is
|
|
602
|
+
// stable, so equal ranks keep leaves-then-composites order.
|
|
603
|
+
const rank = rootChildren.map((child, i) => {
|
|
604
|
+
if (i < leafCount) return firstMentionRank(graph, new Set([child.id]))
|
|
605
|
+
const sg = graph.subgraphs[i - leafCount]!
|
|
606
|
+
const own = firstMentionRank(graph, new Set([sg.id]))
|
|
607
|
+
if (own !== Number.POSITIVE_INFINITY) return own
|
|
608
|
+
const members = new Set<string>()
|
|
609
|
+
collectAllMemberNodeIds(sg, members)
|
|
610
|
+
return firstMentionRank(graph, members)
|
|
611
|
+
})
|
|
612
|
+
const order = rootChildren
|
|
613
|
+
.map((_, i) => i)
|
|
614
|
+
.sort((a, b) => rank[a]! - rank[b]! || a - b)
|
|
615
|
+
const sorted = order.map((i) => rootChildren[i]!)
|
|
616
|
+
rootChildren.splice(0, rootChildren.length, ...sorted)
|
|
617
|
+
}
|
|
476
618
|
|
|
477
619
|
// Add root-level edges. Self-loops (edge.source === edge.target) are
|
|
478
620
|
// excluded — ELK has no native self-loop layout and produces a degenerate
|
|
479
621
|
// zero-length-span polyline for them; from-elk.ts synthesizes a proper
|
|
480
622
|
// side loop for these once node positions are known instead.
|
|
623
|
+
const reversed = edgesReversedForLayout(graph, hints)
|
|
481
624
|
for (const { index, edge } of edgesBySubgraph.get(null)!) {
|
|
482
625
|
if (edge.source === edge.target) continue
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
)
|
|
626
|
+
if (hints?.detachedEdges?.has(index)) continue
|
|
627
|
+
const elkEdge = buildElkEdge({
|
|
628
|
+
id: `e${index}`,
|
|
629
|
+
source: reversed.has(index) ? edge.target : edge.source,
|
|
630
|
+
target: reversed.has(index) ? edge.source : edge.target,
|
|
631
|
+
label: edge.label,
|
|
632
|
+
labelStyle: edgeLabelStyle(opts),
|
|
633
|
+
})
|
|
634
|
+
if (hints?.looseEdges?.has(index)) {
|
|
635
|
+
// Priority 0: the straightening pass leaves this edge alone.
|
|
636
|
+
elkEdge.layoutOptions = {
|
|
637
|
+
...elkEdge.layoutOptions,
|
|
638
|
+
'elk.layered.priority.straightness': '0',
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
rootEdges.push(elkEdge)
|
|
492
642
|
}
|
|
493
643
|
|
|
494
644
|
if (hasDirectionOverride) {
|
|
@@ -506,8 +656,8 @@ export function mermaidToElk(
|
|
|
506
656
|
rootEdges.push(
|
|
507
657
|
buildElkEdge({
|
|
508
658
|
id: `e${index}`,
|
|
509
|
-
source: edge.source,
|
|
510
|
-
target: edge.target,
|
|
659
|
+
source: reversed.has(index) ? edge.target : edge.source,
|
|
660
|
+
target: reversed.has(index) ? edge.source : edge.target,
|
|
511
661
|
label: edge.label,
|
|
512
662
|
labelStyle: edgeLabelStyle(opts),
|
|
513
663
|
}),
|
|
@@ -567,8 +717,7 @@ function subgraphToElk(
|
|
|
567
717
|
direction: directionToElk(effectiveDirection),
|
|
568
718
|
nodeSpacing: opts.nodeSpacing,
|
|
569
719
|
layerSpacing: opts.layerSpacing,
|
|
570
|
-
|
|
571
|
-
padding: { top: 44, left: 16, bottom: 16, right: 16 },
|
|
720
|
+
padding: SUBGRAPH_PADDING,
|
|
572
721
|
})
|
|
573
722
|
|
|
574
723
|
// Ports, built before children/edges since they don't depend on them.
|
|
@@ -619,13 +768,14 @@ function subgraphToElk(
|
|
|
619
768
|
// Self-loops are excluded — see the matching comment in mermaidToElk.
|
|
620
769
|
const edges: ElkExtendedEdge[] = []
|
|
621
770
|
const internalEdges = edgesBySubgraph.get(sg.id) ?? []
|
|
771
|
+
const reversed = edgesReversedForLayout(graph)
|
|
622
772
|
for (const { index, edge } of internalEdges) {
|
|
623
773
|
if (edge.source === edge.target) continue
|
|
624
774
|
edges.push(
|
|
625
775
|
buildElkEdge({
|
|
626
776
|
id: `e${index}`,
|
|
627
|
-
source: edge.source,
|
|
628
|
-
target: edge.target,
|
|
777
|
+
source: reversed.has(index) ? edge.target : edge.source,
|
|
778
|
+
target: reversed.has(index) ? edge.source : edge.target,
|
|
629
779
|
label: edge.label,
|
|
630
780
|
labelStyle: edgeLabelStyle(opts),
|
|
631
781
|
}),
|