@zombie-mermaid/svg-renderer 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
+ }