@zombie-mermaid/svg-renderer 4.2.3 → 5.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/README.md +15 -1
- package/dist/index.cjs +52 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -4
- package/dist/index.d.ts +23 -4
- package/dist/index.js +890 -850
- package/dist/index.js.map +1 -1
- package/package.json +14 -6
- package/src/c4/arial-widths.ts +0 -59
- package/src/c4/layout.ts +0 -680
- package/src/c4/metrics.ts +0 -261
- package/src/c4/renderer.ts +0 -366
- package/src/class/layout.ts +0 -525
- package/src/class/renderer.ts +0 -669
- package/src/edge-curves.ts +0 -205
- package/src/elk-instance.ts +0 -292
- package/src/er/layout.ts +0 -216
- package/src/er/renderer.ts +0 -516
- package/src/generated/mono-font-subset.ts +0 -32
- package/src/index.ts +0 -310
- package/src/layout-engine/back-edges.ts +0 -73
- package/src/layout-engine/compound-flat.ts +0 -916
- package/src/layout-engine/constants.ts +0 -19
- package/src/layout-engine/edge-bundling.ts +0 -458
- package/src/layout-engine/elk-adapter-utils.ts +0 -81
- package/src/layout-engine/elk-graph-builder.ts +0 -245
- package/src/layout-engine/from-elk.ts +0 -767
- package/src/layout-engine/inner-edges.ts +0 -329
- package/src/layout-engine/jog-straightening.ts +0 -52
- package/src/layout-engine/layer-alignment.ts +0 -174
- package/src/layout-engine/layout-hints.ts +0 -27
- package/src/layout-engine/to-elk.ts +0 -855
- package/src/layout-engine.ts +0 -115
- package/src/pie/layout.ts +0 -234
- package/src/pie/renderer.ts +0 -161
- package/src/registry.ts +0 -441
- package/src/renderer.ts +0 -1510
- package/src/resolve-colors.ts +0 -339
- package/src/sequence/layout.ts +0 -766
- package/src/sequence/renderer.ts +0 -687
- package/src/shape-clipping.ts +0 -197
- package/src/styles.ts +0 -145
- package/src/title-gaps.ts +0 -161
- package/src/xychart/layout.ts +0 -727
- package/src/xychart/renderer.ts +0 -676
|
@@ -1,329 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Straighten sub-pixel jogs in orthogonal edge routes.
|
|
3
|
-
*
|
|
4
|
-
* ELK spreads parallel edges across each node's side relative to that node's
|
|
5
|
-
* own width, so two edges between differently sized, centered nodes leave and
|
|
6
|
-
* arrive at slightly different offsets. The route then runs straight, steps
|
|
7
|
-
* sideways by a fraction of a pixel, and runs straight again — a visible kink
|
|
8
|
-
* that also tilts the arrowhead off the edge's axis (#1388).
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { Point } from '@zombie-mermaid/core'
|
|
12
|
-
|
|
13
|
-
/** Largest sideways step, in px, treated as an artifact rather than a bend. */
|
|
14
|
-
const MAX_JOG = 1.5
|
|
15
|
-
|
|
16
|
-
/** Slack for deciding two coordinates are "the same" run. */
|
|
17
|
-
const EPS = 0.01
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Collapse `run, tiny step, run` into one straight run along the shared axis.
|
|
21
|
-
* Pattern (vertical form): A-B vertical, B-C horizontal with |dx| <= MAX_JOG,
|
|
22
|
-
* C-D vertical. Returns a new array; the input is not mutated.
|
|
23
|
-
*/
|
|
24
|
-
export function straightenTinyJogs(points: Point[]): Point[] {
|
|
25
|
-
const out = points.map((p) => ({ ...p }))
|
|
26
|
-
let i = 0
|
|
27
|
-
while (i + 3 < out.length) {
|
|
28
|
-
const [a, b, c, d] = [out[i]!, out[i + 1]!, out[i + 2]!, out[i + 3]!]
|
|
29
|
-
const vertical =
|
|
30
|
-
Math.abs(a.x - b.x) < EPS &&
|
|
31
|
-
Math.abs(c.x - d.x) < EPS &&
|
|
32
|
-
Math.abs(b.y - c.y) < EPS &&
|
|
33
|
-
Math.abs(b.x - c.x) <= MAX_JOG &&
|
|
34
|
-
Math.abs(b.x - c.x) >= EPS
|
|
35
|
-
const horizontal =
|
|
36
|
-
Math.abs(a.y - b.y) < EPS &&
|
|
37
|
-
Math.abs(c.y - d.y) < EPS &&
|
|
38
|
-
Math.abs(b.x - c.x) < EPS &&
|
|
39
|
-
Math.abs(b.y - c.y) <= MAX_JOG &&
|
|
40
|
-
Math.abs(b.y - c.y) >= EPS
|
|
41
|
-
if (vertical) {
|
|
42
|
-
const x = (a.x + d.x) / 2
|
|
43
|
-
out.splice(i, 4, { x, y: a.y }, { x, y: d.y })
|
|
44
|
-
} else if (horizontal) {
|
|
45
|
-
const y = (a.y + d.y) / 2
|
|
46
|
-
out.splice(i, 4, { x: a.x, y }, { x: d.x, y })
|
|
47
|
-
} else {
|
|
48
|
-
i++
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return out
|
|
52
|
-
}
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Layer alignment — snap same-layer nodes to a uniform position.
|
|
3
|
-
* Split out of layout-engine.ts as a self-contained post-processing pass
|
|
4
|
-
* over a PositionedGraph's nodes/edges.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type {
|
|
8
|
-
PositionedNode,
|
|
9
|
-
PositionedEdge,
|
|
10
|
-
Direction,
|
|
11
|
-
} from '@zombie-mermaid/core'
|
|
12
|
-
import { DEFAULTS } from './constants.ts'
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* ELK's orthogonal edge routing staggers nodes within the same layer to create
|
|
16
|
-
* space for edge bends. This post-processing step groups nodes into layers and
|
|
17
|
-
* snaps them to the same flow-axis coordinate (Y for TD/TB, X for LR/RL).
|
|
18
|
-
*
|
|
19
|
-
* Grouping uses proximity along the flow axis: within a layer, ELK's stagger
|
|
20
|
-
* is always less than layerSpacing (bounded by edge routing channels), while
|
|
21
|
-
* adjacent layers are separated by at least layerSpacing + nodeHeight.
|
|
22
|
-
* A threshold of 0.75 * layerSpacing cleanly separates these cases.
|
|
23
|
-
*
|
|
24
|
-
* Directly connected nodes (sharing an edge) are never merged into the same
|
|
25
|
-
* layer group as an additional safety check.
|
|
26
|
-
*
|
|
27
|
-
* Edge endpoints connected to shifted nodes are adjusted proportionally.
|
|
28
|
-
* Intermediate bend points are left unchanged — edge bundling or clipping
|
|
29
|
-
* will recalculate them afterwards.
|
|
30
|
-
*/
|
|
31
|
-
// Audited for issue #100 (non-null assertions): every `!` in this file is
|
|
32
|
-
// a bounds-checked array access, each backed by an explicit check earlier
|
|
33
|
-
// in the same scope — `sorted[0]!` and the `sorted[i]!`/`sorted[i - 1]!`
|
|
34
|
-
// loop-index accesses below are guaranteed by the `nodes.length === 0`
|
|
35
|
-
// early return plus the `for (let i = 1; i < sorted.length; ...)` loop
|
|
36
|
-
// bound; `edge.points[0]!`, `edge.points[1]!`, and the
|
|
37
|
-
// `edge.points[edge.points.length - 1]!` / `[edge.points.length - 2]!`
|
|
38
|
-
// pairs are guaranteed by the `edge.points.length < 2` early `continue`
|
|
39
|
-
// (and, redundantly, the `edge.points.length > 1` checks right above each
|
|
40
|
-
// access). This is the same idiom already accepted as justified elsewhere
|
|
41
|
-
// in this codebase (see src/parser.ts, PR #158, and this directory's
|
|
42
|
-
// edge-bundling.ts/from-elk.ts audit, PR #150, which didn't reach this
|
|
43
|
-
// file). `noUncheckedIndexedAccess` can't see any of these guarantees,
|
|
44
|
-
// but removing the `!` would only replace a proven-safe assertion with an
|
|
45
|
-
// unreachable guard. Left as-is; no behavior change.
|
|
46
|
-
export function alignLayerNodes(
|
|
47
|
-
nodes: PositionedNode[],
|
|
48
|
-
edges: PositionedEdge[],
|
|
49
|
-
direction: Direction,
|
|
50
|
-
): void {
|
|
51
|
-
if (nodes.length === 0) return
|
|
52
|
-
|
|
53
|
-
const isHorizontal = direction === 'LR' || direction === 'RL'
|
|
54
|
-
|
|
55
|
-
// Build set of directly-connected node pairs.
|
|
56
|
-
// Nodes connected by an edge must not be merged into the same layer.
|
|
57
|
-
const connectedPairs = new Set<string>()
|
|
58
|
-
for (const edge of edges) {
|
|
59
|
-
connectedPairs.add(`${edge.source}:${edge.target}`)
|
|
60
|
-
connectedPairs.add(`${edge.target}:${edge.source}`)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ELK's stagger creates small gaps between adjacent nodes in the same layer
|
|
64
|
-
// (typically edgeEdge spacing = 12px per routing channel). Adjacent layers
|
|
65
|
-
// are separated by at least layerSpacing (48px). We use single-linkage
|
|
66
|
-
// clustering: a node joins the current layer if the gap from the previous
|
|
67
|
-
// node (in sorted order) is within threshold, AND it has no direct edge to
|
|
68
|
-
// any node already in the layer.
|
|
69
|
-
const THRESHOLD = DEFAULTS.layerSpacing * 0.6
|
|
70
|
-
|
|
71
|
-
// Sort nodes by flow-axis position
|
|
72
|
-
const sorted = [...nodes].sort((a, b) =>
|
|
73
|
-
isHorizontal ? a.x - b.x : a.y - b.y,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
const layers: PositionedNode[][] = []
|
|
77
|
-
let currentLayer: PositionedNode[] = [sorted[0]!]
|
|
78
|
-
|
|
79
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
80
|
-
const pos = isHorizontal ? sorted[i]!.x : sorted[i]!.y
|
|
81
|
-
const prevPos = isHorizontal ? sorted[i - 1]!.x : sorted[i - 1]!.y
|
|
82
|
-
// Single-linkage: compare with previous node, not layer start
|
|
83
|
-
const gap = pos - prevPos
|
|
84
|
-
// Check if this node is connected to any node already in the current layer
|
|
85
|
-
const hasEdgeToLayer = currentLayer.some((n) =>
|
|
86
|
-
connectedPairs.has(`${n.id}:${sorted[i]!.id}`),
|
|
87
|
-
)
|
|
88
|
-
if (gap <= THRESHOLD && !hasEdgeToLayer) {
|
|
89
|
-
currentLayer.push(sorted[i]!)
|
|
90
|
-
} else {
|
|
91
|
-
layers.push(currentLayer)
|
|
92
|
-
currentLayer = [sorted[i]!]
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
layers.push(currentLayer)
|
|
96
|
-
|
|
97
|
-
// Snap each layer's nodes to the layer's center position
|
|
98
|
-
const deltas = new Map<string, number>() // nodeId → shift amount
|
|
99
|
-
|
|
100
|
-
for (const layer of layers) {
|
|
101
|
-
if (layer.length <= 1) continue
|
|
102
|
-
|
|
103
|
-
const positions = layer.map((n) => (isHorizontal ? n.x : n.y))
|
|
104
|
-
const min = Math.min(...positions)
|
|
105
|
-
const max = Math.max(...positions)
|
|
106
|
-
if (max - min <= 1) continue // Already aligned
|
|
107
|
-
|
|
108
|
-
// Use the center of the range as the snap target
|
|
109
|
-
const target = (min + max) / 2
|
|
110
|
-
|
|
111
|
-
for (const node of layer) {
|
|
112
|
-
const oldPos = isHorizontal ? node.x : node.y
|
|
113
|
-
const delta = target - oldPos
|
|
114
|
-
if (Math.abs(delta) > 0.5) {
|
|
115
|
-
if (isHorizontal) {
|
|
116
|
-
node.x = target
|
|
117
|
-
} else {
|
|
118
|
-
node.y = target
|
|
119
|
-
}
|
|
120
|
-
deltas.set(node.id, delta)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (deltas.size === 0) return
|
|
126
|
-
|
|
127
|
-
// Adjust edge endpoints to match shifted node positions
|
|
128
|
-
for (const edge of edges) {
|
|
129
|
-
if (edge.points.length < 2) continue
|
|
130
|
-
|
|
131
|
-
const srcDelta = deltas.get(edge.source)
|
|
132
|
-
const tgtDelta = deltas.get(edge.target)
|
|
133
|
-
|
|
134
|
-
if (srcDelta != null) {
|
|
135
|
-
// Shift first point and any subsequent points in the initial vertical/horizontal run
|
|
136
|
-
const first = edge.points[0]!
|
|
137
|
-
if (isHorizontal) {
|
|
138
|
-
first.x += srcDelta
|
|
139
|
-
// Shift second point if it's part of a straight vertical exit
|
|
140
|
-
if (
|
|
141
|
-
edge.points.length > 1 &&
|
|
142
|
-
edge.points[1]!.x === first.x - srcDelta
|
|
143
|
-
) {
|
|
144
|
-
edge.points[1]!.x += srcDelta
|
|
145
|
-
}
|
|
146
|
-
} else {
|
|
147
|
-
first.y += srcDelta
|
|
148
|
-
if (
|
|
149
|
-
edge.points.length > 1 &&
|
|
150
|
-
edge.points[1]!.y === first.y - srcDelta
|
|
151
|
-
) {
|
|
152
|
-
edge.points[1]!.y += srcDelta
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (tgtDelta != null) {
|
|
158
|
-
const last = edge.points[edge.points.length - 1]!
|
|
159
|
-
if (isHorizontal) {
|
|
160
|
-
last.x += tgtDelta
|
|
161
|
-
if (edge.points.length > 1) {
|
|
162
|
-
const prev = edge.points[edge.points.length - 2]!
|
|
163
|
-
if (prev.x === last.x - tgtDelta) prev.x += tgtDelta
|
|
164
|
-
}
|
|
165
|
-
} else {
|
|
166
|
-
last.y += tgtDelta
|
|
167
|
-
if (edge.points.length > 1) {
|
|
168
|
-
const prev = edge.points[edge.points.length - 2]!
|
|
169
|
-
if (prev.y === last.y - tgtDelta) prev.y += tgtDelta
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
}
|