@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,204 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// zombie-mermaid — edge path interpolation (Mermaid's `flowchart.curve`)
|
|
3
|
+
//
|
|
4
|
+
// ELK routes an edge as a list of bend points. How those points are joined
|
|
5
|
+
// into a drawn line is a separate, purely presentational choice, which
|
|
6
|
+
// Mermaid exposes as `%%{init: {"flowchart": {"curve": "basis"}}}%%`.
|
|
7
|
+
//
|
|
8
|
+
// Every curve here consumes the same routed points, so switching styles never
|
|
9
|
+
// changes where an edge goes — only how it looks getting there.
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
import type { Point, CurveStyle } from '@zombie-mermaid/core'
|
|
13
|
+
|
|
14
|
+
/** `M x y` for the first point. */
|
|
15
|
+
function moveTo(p: Point): string {
|
|
16
|
+
return `M ${p.x} ${p.y}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Straight segments through every point — the historical default. */
|
|
20
|
+
function linearPath(points: Point[]): string {
|
|
21
|
+
return (
|
|
22
|
+
moveTo(points[0]!) +
|
|
23
|
+
points
|
|
24
|
+
.slice(1)
|
|
25
|
+
.map((p) => ` L ${p.x} ${p.y}`)
|
|
26
|
+
.join('')
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Uniform cubic B-spline, matching d3's `curveBasis` (what Mermaid uses for
|
|
32
|
+
* `basis`).
|
|
33
|
+
*
|
|
34
|
+
* A B-spline is *approximating*, not interpolating: it passes through neither
|
|
35
|
+
* the interior points nor, without the endpoint handling below, the ends. The
|
|
36
|
+
* first and last points are therefore emitted explicitly so an edge still
|
|
37
|
+
* touches the nodes it connects — otherwise it would visibly detach.
|
|
38
|
+
*
|
|
39
|
+
* This is a direct port of d3's `Basis` curve rather than a lookalike, so a
|
|
40
|
+
* diagram rendered here traces the same path Mermaid would draw. For each
|
|
41
|
+
* point d3 emits one cubic whose controls are the thirds between the two
|
|
42
|
+
* preceding points and whose end is the B-spline knot `(p0 + 4p1 + p2) / 6`;
|
|
43
|
+
* it leads in at `(5p0 + p1) / 6` and closes with a final cubic against a
|
|
44
|
+
* repeated last point, then a line to it.
|
|
45
|
+
*/
|
|
46
|
+
function basisPath(points: Point[]): string {
|
|
47
|
+
if (points.length < 3) return linearPath(points)
|
|
48
|
+
|
|
49
|
+
const last = points[points.length - 1]!
|
|
50
|
+
const parts: string[] = [moveTo(points[0]!)]
|
|
51
|
+
|
|
52
|
+
/** One B-spline segment across three successive control points. */
|
|
53
|
+
const segment = (p0: Point, p1: Point, p2: Point): string =>
|
|
54
|
+
` C ${(2 * p0.x + p1.x) / 3} ${(2 * p0.y + p1.y) / 3},` +
|
|
55
|
+
` ${(p0.x + 2 * p1.x) / 3} ${(p0.y + 2 * p1.y) / 3},` +
|
|
56
|
+
` ${(p0.x + 4 * p1.x + p2.x) / 6} ${(p0.y + 4 * p1.y + p2.y) / 6}`
|
|
57
|
+
|
|
58
|
+
// Lead-in to the first knot, one sixth of the way along the opening leg.
|
|
59
|
+
parts.push(
|
|
60
|
+
` L ${(5 * points[0]!.x + points[1]!.x) / 6}` +
|
|
61
|
+
` ${(5 * points[0]!.y + points[1]!.y) / 6}`,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
for (let i = 2; i < points.length; i++) {
|
|
65
|
+
parts.push(segment(points[i - 2]!, points[i - 1]!, points[i]!))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// d3 closes by feeding the last point twice, then drawing to it.
|
|
69
|
+
parts.push(segment(points[points.length - 2]!, last, last))
|
|
70
|
+
parts.push(` L ${last.x} ${last.y}`)
|
|
71
|
+
|
|
72
|
+
return parts.join('')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Straight runs joined by rounded corners. Mermaid calls this `natural`.
|
|
77
|
+
*
|
|
78
|
+
* NOTE: this is deliberately *not* d3's interpolating natural cubic spline.
|
|
79
|
+
* ELK routes edges orthogonally, so every bend is a right angle, and at a
|
|
80
|
+
* right angle the tangent of any C1-smooth interpolating spline is the
|
|
81
|
+
* average of the two segment directions — i.e. diagonal. The curve must then
|
|
82
|
+
* arrive at the corner travelling diagonally while approaching from directly
|
|
83
|
+
* above, so it bulges sideways past the straight line. Two mirrored edges
|
|
84
|
+
* leaving one decision node turned that bulge into a visible teardrop loop.
|
|
85
|
+
* Scaling the tangents down shrinks the overshoot but cannot remove it; it is
|
|
86
|
+
* a property of interpolating a 90° corner smoothly.
|
|
87
|
+
*
|
|
88
|
+
* Rounding the corners instead gives a smooth path that never overshoots and
|
|
89
|
+
* stays visually distinct from `basis`, which cuts corners far more deeply.
|
|
90
|
+
* The trade is that the path passes *near* each bend rather than exactly
|
|
91
|
+
* through it — documented in docs/diagrams.md.
|
|
92
|
+
*/
|
|
93
|
+
function naturalPath(points: Point[]): string {
|
|
94
|
+
if (points.length < 3) return linearPath(points)
|
|
95
|
+
|
|
96
|
+
/** Largest corner radius, in px. Kept small so edges stay readable. */
|
|
97
|
+
const MAX_RADIUS = 12
|
|
98
|
+
|
|
99
|
+
const parts: string[] = [moveTo(points[0]!)]
|
|
100
|
+
|
|
101
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
102
|
+
const prev = points[i - 1]!
|
|
103
|
+
const corner = points[i]!
|
|
104
|
+
const next = points[i + 1]!
|
|
105
|
+
|
|
106
|
+
const inLen = Math.hypot(corner.x - prev.x, corner.y - prev.y)
|
|
107
|
+
const outLen = Math.hypot(next.x - corner.x, next.y - corner.y)
|
|
108
|
+
|
|
109
|
+
// A zero-length leg means duplicated routed points; there is no corner
|
|
110
|
+
// to round, so pass straight through.
|
|
111
|
+
if (inLen === 0 || outLen === 0) {
|
|
112
|
+
parts.push(` L ${corner.x} ${corner.y}`)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Never consume more than half of either adjacent leg, so neighbouring
|
|
117
|
+
// corners cannot overlap and invert the path.
|
|
118
|
+
const radius = Math.min(MAX_RADIUS, inLen / 2, outLen / 2)
|
|
119
|
+
|
|
120
|
+
const startX = corner.x - ((corner.x - prev.x) / inLen) * radius
|
|
121
|
+
const startY = corner.y - ((corner.y - prev.y) / inLen) * radius
|
|
122
|
+
const endX = corner.x + ((next.x - corner.x) / outLen) * radius
|
|
123
|
+
const endY = corner.y + ((next.y - corner.y) / outLen) * radius
|
|
124
|
+
|
|
125
|
+
// Straight up to the fillet, then a quadratic through the corner.
|
|
126
|
+
parts.push(` L ${startX} ${startY}`)
|
|
127
|
+
parts.push(` Q ${corner.x} ${corner.y}, ${endX} ${endY}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const last = points[points.length - 1]!
|
|
131
|
+
parts.push(` L ${last.x} ${last.y}`)
|
|
132
|
+
|
|
133
|
+
return parts.join('')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Right-angle staircase between consecutive points.
|
|
138
|
+
*
|
|
139
|
+
* `where` picks when the step happens: at the midpoint (`step`), immediately
|
|
140
|
+
* (`stepBefore`), or at the end of the run (`stepAfter`) — matching d3's
|
|
141
|
+
* curveStep family.
|
|
142
|
+
*
|
|
143
|
+
* The final segment is deliberately left straight, running along the original
|
|
144
|
+
* approach direction. An SVG marker with `orient="auto"` takes its angle from
|
|
145
|
+
* the last path segment, so a staircase that ends on a horizontal leg points
|
|
146
|
+
* the arrowhead sideways into the target node instead of at it. Keeping the
|
|
147
|
+
* last leg means the arrow still aims the way the routed edge does.
|
|
148
|
+
*/
|
|
149
|
+
function stepPath(points: Point[], where: 'mid' | 'before' | 'after'): string {
|
|
150
|
+
const parts: string[] = [moveTo(points[0]!)]
|
|
151
|
+
const lastIndex = points.length - 1
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < lastIndex; i++) {
|
|
154
|
+
const a = points[i]!
|
|
155
|
+
const b = points[i + 1]!
|
|
156
|
+
|
|
157
|
+
if (i === lastIndex - 1) {
|
|
158
|
+
parts.push(` L ${b.x} ${b.y}`)
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (where === 'before') {
|
|
163
|
+
parts.push(` L ${a.x} ${b.y} L ${b.x} ${b.y}`)
|
|
164
|
+
} else if (where === 'after') {
|
|
165
|
+
parts.push(` L ${b.x} ${a.y} L ${b.x} ${b.y}`)
|
|
166
|
+
} else {
|
|
167
|
+
const midY = (a.y + b.y) / 2
|
|
168
|
+
parts.push(` L ${a.x} ${midY} L ${b.x} ${midY} L ${b.x} ${b.y}`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return parts.join('')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build the `d` attribute for an edge's routed points under `curve`.
|
|
177
|
+
*
|
|
178
|
+
* A path with fewer than two points cannot be drawn; the caller already skips
|
|
179
|
+
* those, but this degrades to an empty string rather than emitting `M`
|
|
180
|
+
* followed by nothing.
|
|
181
|
+
*/
|
|
182
|
+
export function pointsToPath(
|
|
183
|
+
points: Point[],
|
|
184
|
+
curve: CurveStyle = 'linear',
|
|
185
|
+
): string {
|
|
186
|
+
if (points.length === 0) return ''
|
|
187
|
+
if (points.length === 1) return moveTo(points[0]!)
|
|
188
|
+
|
|
189
|
+
switch (curve) {
|
|
190
|
+
case 'basis':
|
|
191
|
+
return basisPath(points)
|
|
192
|
+
case 'natural':
|
|
193
|
+
return naturalPath(points)
|
|
194
|
+
case 'step':
|
|
195
|
+
return stepPath(points, 'mid')
|
|
196
|
+
case 'stepBefore':
|
|
197
|
+
return stepPath(points, 'before')
|
|
198
|
+
case 'stepAfter':
|
|
199
|
+
return stepPath(points, 'after')
|
|
200
|
+
case 'linear':
|
|
201
|
+
default:
|
|
202
|
+
return linearPath(points)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ELK instance singleton.
|
|
3
|
+
*
|
|
4
|
+
* Uses elk.bundled.js (pure synchronous JS, ~1.6 MB) for all environments.
|
|
5
|
+
* The singleton is created lazily on first use and cached forever.
|
|
6
|
+
*
|
|
7
|
+
* ELK's FakeWorker wraps both postMessage and onmessage in setTimeout(0),
|
|
8
|
+
* making the normal API fully async. To bypass this:
|
|
9
|
+
* 1. During construction, we capture setTimeout(0) callbacks and flush them
|
|
10
|
+
* synchronously — this registers the layout algorithms immediately.
|
|
11
|
+
* 2. For layout calls, we call dispatcher.saveDispatch() directly (skipping
|
|
12
|
+
* the FakeWorker's postMessage setTimeout) and intercept the result via
|
|
13
|
+
* rawWorker.onmessage (which the dispatcher calls synchronously).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ELK, ElkNode } from 'elkjs'
|
|
17
|
+
import ELKBundled from 'elkjs/lib/elk.bundled.js'
|
|
18
|
+
import type { LayoutCache } from '@zombie-mermaid/core'
|
|
19
|
+
|
|
20
|
+
/** The message envelope ELK's FakeWorker passes to `dispatcher.saveDispatch()`
|
|
21
|
+
* to request a layout run. Mirrors the shape elk-worker.min.js expects on
|
|
22
|
+
* `data` for a `cmd: 'layout'` request — not part of elkjs's public types. */
|
|
23
|
+
interface ElkWorkerRequest {
|
|
24
|
+
id: number
|
|
25
|
+
cmd: 'layout'
|
|
26
|
+
graph: ElkNode
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The message envelope ELK's dispatcher passes back to `onmessage` once a
|
|
30
|
+
* layout run completes. Mirrors elk-worker.min.js's response shape for a
|
|
31
|
+
* `cmd: 'layout'` request — not part of elkjs's public types. */
|
|
32
|
+
interface ElkWorkerResponse {
|
|
33
|
+
id: number
|
|
34
|
+
data?: ElkNode
|
|
35
|
+
error?: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RawFakeWorker {
|
|
39
|
+
postMessage(msg: unknown): void
|
|
40
|
+
onmessage: ((e: { data: ElkWorkerResponse }) => void) | null
|
|
41
|
+
dispatcher: {
|
|
42
|
+
saveDispatch(msg: { data: ElkWorkerRequest }): void
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The shape of elkjs's bundled `ELK` instance that actually exists at
|
|
48
|
+
* runtime, including the internal `worker` handle. elkjs's public `ELK`
|
|
49
|
+
* type (from `elk-api.d.ts`) only declares `layout`/`knownLayout*`/
|
|
50
|
+
* `terminateWorker` — it deliberately doesn't expose worker internals,
|
|
51
|
+
* since those aren't a supported API. We rely on them anyway (see file
|
|
52
|
+
* header), so this extends the public type with the internal piece we
|
|
53
|
+
* touch instead of casting through `unknown`.
|
|
54
|
+
*/
|
|
55
|
+
interface ElkBundledInternal extends ELK {
|
|
56
|
+
worker: { worker: RawFakeWorker }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let elk: ELK | null = null
|
|
60
|
+
let rawWorker: RawFakeWorker | null = null
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Opt-in layout cache
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `LayoutCache`'s shape lives in `@zombie-mermaid/core`'s `types.ts`
|
|
68
|
+
* because `RenderOptions.layoutCache` references it and `core` must not
|
|
69
|
+
* type-import this package (zombie-mermaid#625). Re-exported here so this
|
|
70
|
+
* module stays the single import site for everything layout-cache-related,
|
|
71
|
+
* exactly as before the split.
|
|
72
|
+
*/
|
|
73
|
+
export type { LayoutCache }
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Concrete shape of a `LayoutCache`, restated locally.
|
|
77
|
+
*
|
|
78
|
+
* `core`'s `LayoutCache` interface marks `map`/`maxSize` `@internal`
|
|
79
|
+
* (see the comment on that interface in packages/core/src/types.ts), so
|
|
80
|
+
* api-extractor strips them from `@zombie-mermaid/core`'s *published*
|
|
81
|
+
* `dist/index.d.ts` — deliberately, so they stay invisible to
|
|
82
|
+
* `@zombie-mermaid/core` consumers and to `zombie-mermaid`'s own public
|
|
83
|
+
* types (which re-export `LayoutCache` from this package). As of #769,
|
|
84
|
+
* `@zombie-mermaid/core` is a real, independently-built dependency of this
|
|
85
|
+
* package rather than bundled source, so this module now resolves
|
|
86
|
+
* `LayoutCache` through that same trimmed public declaration too — same as
|
|
87
|
+
* any other consumer. This module is the one place, in or out of `core`,
|
|
88
|
+
* that actually builds and mutates those fields (`createLayoutCache()`
|
|
89
|
+
* below; the cache read/evict logic in `elkLayoutSync()`), so it restates
|
|
90
|
+
* the concrete shape here — kept in sync by hand with `core`'s source of
|
|
91
|
+
* truth — rather than either widening `core`'s public surface or fighting
|
|
92
|
+
* api-extractor's trimming in the build config to get it back.
|
|
93
|
+
*/
|
|
94
|
+
interface LayoutCacheShape {
|
|
95
|
+
map: Map<string, ElkNode>
|
|
96
|
+
maxSize: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DEFAULT_LAYOUT_CACHE_SIZE = 20
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create a new opt-in layout cache with a bounded size (default 20
|
|
103
|
+
* entries). Once full, the least-recently-used entry is evicted to make
|
|
104
|
+
* room for a new one.
|
|
105
|
+
*
|
|
106
|
+
* Pass the result to `elkLayoutSync()` directly, or via
|
|
107
|
+
* `RenderOptions.layoutCache` (threaded through by `layoutGraphSync()`,
|
|
108
|
+
* `layoutClassDiagramSync()`, and `layoutErDiagramSync()`) to memoize
|
|
109
|
+
* layout across repeated renders of the same diagram + options.
|
|
110
|
+
*/
|
|
111
|
+
export function createLayoutCache(
|
|
112
|
+
maxSize: number = DEFAULT_LAYOUT_CACHE_SIZE,
|
|
113
|
+
): LayoutCache {
|
|
114
|
+
if (!Number.isInteger(maxSize) || maxSize < 1) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`createLayoutCache: maxSize must be a positive integer, got ${maxSize}`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
// Typed as the concrete shape first (see `LayoutCacheShape` above), then
|
|
120
|
+
// returned as the public, trimmed `LayoutCache` — a plain widening
|
|
121
|
+
// assignment, not a cast.
|
|
122
|
+
const cache: LayoutCacheShape = { map: new Map(), maxSize }
|
|
123
|
+
return cache
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Deterministically serialize a value for use as a cache key, sorting
|
|
128
|
+
* object keys recursively so two structurally-equal ELK input graphs
|
|
129
|
+
* always produce an identical string regardless of property-insertion
|
|
130
|
+
* order. Plain `JSON.stringify()` does not guarantee that — and this
|
|
131
|
+
* function is the only thing standing between a cache hit and returning
|
|
132
|
+
* some *other* diagram's layout, so it can't be allowed to drift with
|
|
133
|
+
* insertion order.
|
|
134
|
+
*
|
|
135
|
+
* The ELK input graph built by `mermaidToElk()` is plain JSON (arrays,
|
|
136
|
+
* plain objects, strings, numbers, booleans — the shape ELK's own JSON
|
|
137
|
+
* schema requires) with every render option that affects layout already
|
|
138
|
+
* baked in (direction, spacing, per-subgraph overrides, etc.), so
|
|
139
|
+
* serializing the graph itself is a complete and correct cache key: equal
|
|
140
|
+
* serialized input always means equal ELK output, since ELK layout is a
|
|
141
|
+
* pure function of its input graph.
|
|
142
|
+
*/
|
|
143
|
+
function stableStringify(value: unknown): string {
|
|
144
|
+
if (Array.isArray(value)) {
|
|
145
|
+
return `[${value.map(stableStringify).join(',')}]`
|
|
146
|
+
}
|
|
147
|
+
if (value !== null && typeof value === 'object') {
|
|
148
|
+
const record = value as Record<string, unknown>
|
|
149
|
+
const keys = Object.keys(record).sort()
|
|
150
|
+
const entries = keys.map(
|
|
151
|
+
(key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`,
|
|
152
|
+
)
|
|
153
|
+
return `{${entries.join(',')}}`
|
|
154
|
+
}
|
|
155
|
+
return JSON.stringify(value)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Ensure the ELK singleton exists.
|
|
160
|
+
*
|
|
161
|
+
* Patches setTimeout during construction to capture and synchronously flush
|
|
162
|
+
* the algorithm registration callback that ELK queues via setTimeout(0).
|
|
163
|
+
* Without this, layout calls fail with "algorithm not found" until the
|
|
164
|
+
* next macrotask.
|
|
165
|
+
*/
|
|
166
|
+
function ensureElk(): RawFakeWorker {
|
|
167
|
+
if (elk && rawWorker) return rawWorker
|
|
168
|
+
|
|
169
|
+
// Capture setTimeout(0) callbacks queued during ELK construction
|
|
170
|
+
const pending: (() => void)[] = []
|
|
171
|
+
const origSetTimeout = globalThis.setTimeout
|
|
172
|
+
// @ts-expect-error — simplified signature for our interception, not
|
|
173
|
+
// assignment-compatible with the full `typeof setTimeout` overload set
|
|
174
|
+
globalThis.setTimeout = (fn: () => void, delay?: number) => {
|
|
175
|
+
if (delay === 0) {
|
|
176
|
+
pending.push(fn)
|
|
177
|
+
return 0
|
|
178
|
+
}
|
|
179
|
+
return origSetTimeout(fn, delay)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Bun defines `self` (= globalThis) but not `document`, which tricks
|
|
183
|
+
// elk-worker.min.js into taking the Web Worker branch instead of the
|
|
184
|
+
// CJS branch. Temporarily hide `self` so it exports {Worker: FakeWorker}.
|
|
185
|
+
// `lib` in tsconfig.json is `["ESNext"]` (no `dom`), so `self`/`document`
|
|
186
|
+
// aren't ambiently declared on `globalThis` — narrow to just the two
|
|
187
|
+
// properties this probe touches instead of a blanket `Record`.
|
|
188
|
+
const g = globalThis as { self?: unknown; document?: unknown }
|
|
189
|
+
const hadSelf = 'self' in g
|
|
190
|
+
const origSelf = g.self
|
|
191
|
+
if (hadSelf && typeof g.document === 'undefined') {
|
|
192
|
+
delete g.self
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
elk = new ELKBundled()
|
|
196
|
+
if (!elk) {
|
|
197
|
+
// Unreachable — `new ELKBundled()` always returns an instance — but
|
|
198
|
+
// makes the invariant explicit rather than letting the cast below
|
|
199
|
+
// silently paper over a null `elk` if that ever stopped being true.
|
|
200
|
+
throw new Error('ELKBundled construction unexpectedly produced no instance')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Restore self
|
|
204
|
+
if (hadSelf) g.self = origSelf
|
|
205
|
+
|
|
206
|
+
// Restore setTimeout immediately
|
|
207
|
+
globalThis.setTimeout = origSetTimeout
|
|
208
|
+
|
|
209
|
+
// Flush captured callbacks synchronously — registers layout algorithms
|
|
210
|
+
pending.forEach((fn) => fn())
|
|
211
|
+
|
|
212
|
+
// Cache the raw FakeWorker for elkLayoutSync()
|
|
213
|
+
rawWorker = (elk as ElkBundledInternal).worker.worker
|
|
214
|
+
return rawWorker
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Run ELK layout synchronously.
|
|
219
|
+
*
|
|
220
|
+
* Bypasses BOTH of ELK's setTimeout(0) wrappers:
|
|
221
|
+
* - FakeWorker.postMessage wraps dispatch in setTimeout(0) — bypassed by
|
|
222
|
+
* calling dispatcher.saveDispatch() directly
|
|
223
|
+
* - PromisedWorker.onmessage wraps receive in setTimeout(0) — bypassed by
|
|
224
|
+
* replacing rawWorker.onmessage with a direct interceptor
|
|
225
|
+
*
|
|
226
|
+
* @param cache - Optional opt-in layout cache (see `createLayoutCache()`).
|
|
227
|
+
* When provided, a cache hit returns the previous result without running
|
|
228
|
+
* ELK layout again. Omitted/undefined preserves the original
|
|
229
|
+
* always-recompute behavior exactly.
|
|
230
|
+
*/
|
|
231
|
+
export function elkLayoutSync(graph: ElkNode, cache?: LayoutCache): ElkNode {
|
|
232
|
+
// `cache`, when provided, is always an object this module itself built
|
|
233
|
+
// via `createLayoutCache()` above — never anything constructed outside
|
|
234
|
+
// this package. Restated as the concrete shape here (see
|
|
235
|
+
// `LayoutCacheShape`'s doc comment) since the public `LayoutCache` type
|
|
236
|
+
// this parameter is declared with intentionally hides `map`/`maxSize`.
|
|
237
|
+
const internalCache = cache as LayoutCacheShape | undefined
|
|
238
|
+
const cacheKey = internalCache ? stableStringify(graph) : undefined
|
|
239
|
+
if (internalCache && cacheKey !== undefined) {
|
|
240
|
+
const cached = internalCache.map.get(cacheKey)
|
|
241
|
+
if (cached) {
|
|
242
|
+
// Mark as most-recently-used: Map iteration order follows insertion
|
|
243
|
+
// order, so a delete+re-set moves this entry to the end — which is
|
|
244
|
+
// what the LRU eviction below relies on to find the *least*
|
|
245
|
+
// recently used entry (the current first key).
|
|
246
|
+
internalCache.map.delete(cacheKey)
|
|
247
|
+
internalCache.map.set(cacheKey, cached)
|
|
248
|
+
return cached
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const worker = ensureElk()
|
|
253
|
+
|
|
254
|
+
let result: ElkNode | undefined
|
|
255
|
+
let error: unknown
|
|
256
|
+
|
|
257
|
+
// Replace onmessage to intercept the result synchronously
|
|
258
|
+
// (the dispatcher calls this directly, without setTimeout)
|
|
259
|
+
const origOnmessage = worker.onmessage
|
|
260
|
+
worker.onmessage = (answer: { data: ElkWorkerResponse }) => {
|
|
261
|
+
if (answer.data.error) {
|
|
262
|
+
error = answer.data.error
|
|
263
|
+
} else {
|
|
264
|
+
result = answer.data.data
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Call dispatcher.saveDispatch directly — bypasses FakeWorker.postMessage's
|
|
269
|
+
// setTimeout(0) wrapper. The dispatcher processes the layout synchronously
|
|
270
|
+
// and calls rawWorker.onmessage with the result.
|
|
271
|
+
worker.dispatcher.saveDispatch({
|
|
272
|
+
data: { id: 0, cmd: 'layout', graph },
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// Restore original handler
|
|
276
|
+
worker.onmessage = origOnmessage
|
|
277
|
+
|
|
278
|
+
if (error) throw error
|
|
279
|
+
if (!result) throw new Error('ELK layout did not return synchronously')
|
|
280
|
+
|
|
281
|
+
if (internalCache && cacheKey !== undefined) {
|
|
282
|
+
internalCache.map.set(cacheKey, result)
|
|
283
|
+
if (internalCache.map.size > internalCache.maxSize) {
|
|
284
|
+
// Map iteration order is insertion order, so the first key is the
|
|
285
|
+
// least recently used (see the recency bump on hit, above).
|
|
286
|
+
const oldestKey = internalCache.map.keys().next().value
|
|
287
|
+
if (oldestKey !== undefined) internalCache.map.delete(oldestKey)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return result
|
|
292
|
+
}
|
package/src/er/layout.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ER diagram layout engine (ELK.js).
|
|
3
|
+
*
|
|
4
|
+
* Each entity box has:
|
|
5
|
+
* 1. Header (entity name)
|
|
6
|
+
* 2. Attribute rows (type, name, keys)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ElkNode, ElkExtendedEdge } from 'elkjs'
|
|
10
|
+
import type {
|
|
11
|
+
ErDiagram,
|
|
12
|
+
ErEntity,
|
|
13
|
+
PositionedErDiagram,
|
|
14
|
+
PositionedErEntity,
|
|
15
|
+
PositionedErRelationship,
|
|
16
|
+
} from '@zombie-mermaid/mermaid-parser'
|
|
17
|
+
import type { ErRenderOptions } from '@zombie-mermaid/core'
|
|
18
|
+
import {
|
|
19
|
+
estimateTextWidth,
|
|
20
|
+
estimateMonoTextWidth,
|
|
21
|
+
FONT_WEIGHTS,
|
|
22
|
+
resolveFontSizes,
|
|
23
|
+
} from '../styles.ts'
|
|
24
|
+
import { elkLayoutSync } from '../elk-instance.ts'
|
|
25
|
+
import { extractEdgePoints } from '../layout-engine/elk-adapter-utils.ts'
|
|
26
|
+
import {
|
|
27
|
+
ELK_DIRECTION_FALLBACK,
|
|
28
|
+
baseElkLayoutOptions,
|
|
29
|
+
buildElkEdge,
|
|
30
|
+
buildElkLeafNode,
|
|
31
|
+
directionToElk,
|
|
32
|
+
} from '../layout-engine/elk-graph-builder.ts'
|
|
33
|
+
|
|
34
|
+
/** Layout constants for ER diagrams */
|
|
35
|
+
const ER = {
|
|
36
|
+
padding: 40,
|
|
37
|
+
boxPadX: 14,
|
|
38
|
+
headerHeight: 34,
|
|
39
|
+
rowHeight: 22,
|
|
40
|
+
minWidth: 140,
|
|
41
|
+
attrFontSize: 11,
|
|
42
|
+
attrFontWeight: 400,
|
|
43
|
+
nodeSpacing: 70,
|
|
44
|
+
layerSpacing: 90,
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
type EntitySizeMap = Map<string, { width: number; height: number }>
|
|
48
|
+
|
|
49
|
+
/** Build ELK graph and size map from an ER diagram. */
|
|
50
|
+
function buildErElkGraph(
|
|
51
|
+
diagram: ErDiagram,
|
|
52
|
+
options: ErRenderOptions,
|
|
53
|
+
): { elkGraph: ElkNode; entitySizes: EntitySizeMap } {
|
|
54
|
+
const entitySizes: EntitySizeMap = new Map()
|
|
55
|
+
const fontSizes = resolveFontSizes(options.fontSizes)
|
|
56
|
+
|
|
57
|
+
for (const entity of diagram.entities) {
|
|
58
|
+
const headerTextW = estimateTextWidth(
|
|
59
|
+
entity.label,
|
|
60
|
+
fontSizes.nodeLabel,
|
|
61
|
+
FONT_WEIGHTS.nodeLabel,
|
|
62
|
+
)
|
|
63
|
+
let maxAttrW = 0
|
|
64
|
+
for (const attr of entity.attributes) {
|
|
65
|
+
const attrText = `${attr.type} ${attr.name}${attr.keys.length > 0 ? ' ' + attr.keys.join(',') : ''}`
|
|
66
|
+
const w = estimateMonoTextWidth(attrText, ER.attrFontSize)
|
|
67
|
+
if (w > maxAttrW) maxAttrW = w
|
|
68
|
+
}
|
|
69
|
+
const width = Math.max(
|
|
70
|
+
ER.minWidth,
|
|
71
|
+
headerTextW + ER.boxPadX * 2,
|
|
72
|
+
maxAttrW + ER.boxPadX * 2,
|
|
73
|
+
)
|
|
74
|
+
const height =
|
|
75
|
+
ER.headerHeight + Math.max(entity.attributes.length, 1) * ER.rowHeight
|
|
76
|
+
entitySizes.set(entity.id, { width, height })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Iterate entitySizes directly (populated above, in diagram.entities order)
|
|
80
|
+
// rather than looking each entity back up by id — sidesteps needing an
|
|
81
|
+
// assertion or invariant check for a lookup that can't actually miss.
|
|
82
|
+
const children: ElkNode[] = []
|
|
83
|
+
for (const [id, size] of entitySizes) {
|
|
84
|
+
children.push(buildElkLeafNode(id, size))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ER edge labels carry no per-label layout options — placement is set
|
|
88
|
+
// once on the root graph below (`elk.edgeLabels.placement: CENTER`).
|
|
89
|
+
const labelStyle = { fontSize: fontSizes.edgeLabel }
|
|
90
|
+
|
|
91
|
+
const edges: ElkExtendedEdge[] = []
|
|
92
|
+
for (const [i, rel] of diagram.relationships.entries()) {
|
|
93
|
+
edges.push(
|
|
94
|
+
buildElkEdge({
|
|
95
|
+
id: `e${i}`,
|
|
96
|
+
source: rel.entity1,
|
|
97
|
+
target: rel.entity2,
|
|
98
|
+
label: rel.label,
|
|
99
|
+
labelStyle,
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const elkGraph: ElkNode = {
|
|
105
|
+
id: 'root',
|
|
106
|
+
layoutOptions: {
|
|
107
|
+
...baseElkLayoutOptions({
|
|
108
|
+
// A source with no `direction` statement lays out left-to-right —
|
|
109
|
+
// ER's own default, unlike flowchart/class's DOWN. See
|
|
110
|
+
// ELK_DIRECTION_FALLBACK.
|
|
111
|
+
direction: directionToElk(diagram.direction, ELK_DIRECTION_FALLBACK.er),
|
|
112
|
+
nodeSpacing: ER.nodeSpacing,
|
|
113
|
+
layerSpacing: ER.layerSpacing,
|
|
114
|
+
padding: ER.padding,
|
|
115
|
+
}),
|
|
116
|
+
'elk.edgeLabels.placement': 'CENTER',
|
|
117
|
+
},
|
|
118
|
+
children,
|
|
119
|
+
edges,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { elkGraph, entitySizes }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Extract positioned entities and relationships from ELK result. */
|
|
126
|
+
function extractErLayout(
|
|
127
|
+
result: ElkNode,
|
|
128
|
+
diagram: ErDiagram,
|
|
129
|
+
entitySizes: EntitySizeMap,
|
|
130
|
+
): PositionedErDiagram {
|
|
131
|
+
const entityLookup = new Map<string, ErEntity>()
|
|
132
|
+
for (const entity of diagram.entities) entityLookup.set(entity.id, entity)
|
|
133
|
+
|
|
134
|
+
const positionedEntities: PositionedErEntity[] = []
|
|
135
|
+
for (const child of result.children ?? []) {
|
|
136
|
+
const entity = entityLookup.get(child.id)
|
|
137
|
+
if (entity) {
|
|
138
|
+
const size = entitySizes.get(entity.id)
|
|
139
|
+
if (!size) {
|
|
140
|
+
// Unreachable — entitySizes is populated for every diagram.entities
|
|
141
|
+
// entry, and entityLookup/entity.id come from that same list.
|
|
142
|
+
/* v8 ignore next */
|
|
143
|
+
throw new Error(`Missing computed size for entity "${entity.id}"`)
|
|
144
|
+
}
|
|
145
|
+
positionedEntities.push({
|
|
146
|
+
id: entity.id,
|
|
147
|
+
label: entity.label,
|
|
148
|
+
attributes: entity.attributes,
|
|
149
|
+
x: child.x ?? 0,
|
|
150
|
+
y: child.y ?? 0,
|
|
151
|
+
width: child.width ?? size.width,
|
|
152
|
+
height: child.height ?? size.height,
|
|
153
|
+
headerHeight: ER.headerHeight,
|
|
154
|
+
rowHeight: ER.rowHeight,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const relationships: PositionedErRelationship[] = []
|
|
160
|
+
for (const [i, elkEdge] of (result.edges ?? []).entries()) {
|
|
161
|
+
// diagram.relationships[i] is guaranteed by construction: buildErElkGraph
|
|
162
|
+
// creates exactly one ELK edge per relationship, in the same order.
|
|
163
|
+
const rel = diagram.relationships[i]!
|
|
164
|
+
|
|
165
|
+
const points = extractEdgePoints(elkEdge)
|
|
166
|
+
|
|
167
|
+
relationships.push({
|
|
168
|
+
entity1: rel.entity1,
|
|
169
|
+
entity2: rel.entity2,
|
|
170
|
+
cardinality1: rel.cardinality1,
|
|
171
|
+
cardinality2: rel.cardinality2,
|
|
172
|
+
label: rel.label,
|
|
173
|
+
identifying: rel.identifying,
|
|
174
|
+
points,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
width: result.width ?? 600,
|
|
180
|
+
height: result.height ?? 400,
|
|
181
|
+
entities: positionedEntities,
|
|
182
|
+
relationships,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Lay out a parsed ER diagram using ELK.js (synchronous).
|
|
188
|
+
*/
|
|
189
|
+
export function layoutErDiagramSync(
|
|
190
|
+
diagram: ErDiagram,
|
|
191
|
+
options: ErRenderOptions = {},
|
|
192
|
+
): PositionedErDiagram {
|
|
193
|
+
if (diagram.entities.length === 0) {
|
|
194
|
+
return { width: 0, height: 0, entities: [], relationships: [] }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const { elkGraph, entitySizes } = buildErElkGraph(diagram, options)
|
|
198
|
+
const result = elkLayoutSync(elkGraph, options.layoutCache)
|
|
199
|
+
return extractErLayout(result, diagram, entitySizes)
|
|
200
|
+
}
|