@zombie-mermaid/svg-renderer 2.2.1 → 3.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 +69 -0
- package/dist/index.cjs +29 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +89 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +1238 -701
- package/dist/index.js.map +1 -1
- package/package.json +11 -5
- package/src/__tests__/render-mermaid-svg.test.ts +64 -0
- package/src/index.ts +222 -5
- package/src/layout-engine/from-elk.ts +80 -5
- package/src/registry.ts +351 -0
- package/src/layout.ts +0 -8
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zombie-mermaid/svg-renderer",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "SVG rendering primitives and the ELK-backed layout engine behind zombie-mermaid's SVG output.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/dfadler/zombie-mermaid",
|
|
9
|
+
"directory": "packages/svg-renderer"
|
|
10
|
+
},
|
|
6
11
|
"type": "module",
|
|
7
12
|
"sideEffects": false,
|
|
8
13
|
"main": "dist/index.cjs",
|
|
@@ -30,8 +35,9 @@
|
|
|
30
35
|
"LICENSE"
|
|
31
36
|
],
|
|
32
37
|
"dependencies": {
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
38
|
+
"elkjs": "^0.11.0",
|
|
39
|
+
"entities": "^7.0.1",
|
|
40
|
+
"@zombie-mermaid/core": "3.0.0",
|
|
41
|
+
"@zombie-mermaid/mermaid-parser": "3.0.0"
|
|
36
42
|
}
|
|
37
|
-
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for `renderMermaidSVG` — the "Mermaid text in, SVG out" front
|
|
3
|
+
* door moved into this package under #1111, for parity with
|
|
4
|
+
* `@zombie-mermaid/ascii-renderer`'s `renderMermaidASCII`. The umbrella's own
|
|
5
|
+
* extensive `src/__tests__/*` suite already exercises this function
|
|
6
|
+
* end-to-end (through the re-export in `src/index.ts`), so this file only
|
|
7
|
+
* checks what's specific to *this* package: every registered diagram type
|
|
8
|
+
* dispatches to real SVG output through `./registry.ts`'s `diagramRegistry`
|
|
9
|
+
* when called directly from `@zombie-mermaid/svg-renderer`, not just via the
|
|
10
|
+
* umbrella.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from 'vitest'
|
|
13
|
+
import {
|
|
14
|
+
renderMermaidSVG,
|
|
15
|
+
renderMermaidSVGAsync,
|
|
16
|
+
renderMermaidSync,
|
|
17
|
+
renderMermaid,
|
|
18
|
+
themeCssVariables,
|
|
19
|
+
} from '@zombie-mermaid/svg-renderer'
|
|
20
|
+
|
|
21
|
+
describe('renderMermaidSVG', () => {
|
|
22
|
+
it.each([
|
|
23
|
+
['flowchart', 'graph TD\n A --> B'],
|
|
24
|
+
['state', 'stateDiagram-v2\n [*] --> A'],
|
|
25
|
+
['sequence', 'sequenceDiagram\n A->>B: hi'],
|
|
26
|
+
['class', 'classDiagram\n Animal <|-- Dog'],
|
|
27
|
+
['er', 'erDiagram\n A ||--o{ B : has'],
|
|
28
|
+
['xychart', 'xychart-beta\n line [1, 2, 3]'],
|
|
29
|
+
])('renders a %s diagram to an SVG string', (_label, source) => {
|
|
30
|
+
const svg = renderMermaidSVG(source)
|
|
31
|
+
expect(svg).toContain('<svg')
|
|
32
|
+
expect(svg).toContain('</svg>')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('embeds the original source when embedSource is set', () => {
|
|
36
|
+
const svg = renderMermaidSVG('graph TD\n A --> B', { embedSource: true })
|
|
37
|
+
expect(svg).toContain('data-src=')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('renderMermaidSVGAsync', () => {
|
|
42
|
+
it('resolves to the same output as the sync render', async () => {
|
|
43
|
+
const source = 'graph TD\n A --> B'
|
|
44
|
+
expect(await renderMermaidSVGAsync(source)).toBe(renderMermaidSVG(source))
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('themeCssVariables', () => {
|
|
49
|
+
it('includes the resolved --bg/--fg custom properties', () => {
|
|
50
|
+
const css = themeCssVariables({ bg: '#111111', fg: '#eeeeee' })
|
|
51
|
+
expect(css).toContain('--bg:#111111')
|
|
52
|
+
expect(css).toContain('--fg:#eeeeee')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('deprecated aliases', () => {
|
|
57
|
+
it('renderMermaidSync matches renderMermaidSVG', () => {
|
|
58
|
+
expect(renderMermaidSync).toBe(renderMermaidSVG)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renderMermaid matches renderMermaidSVGAsync', () => {
|
|
62
|
+
expect(renderMermaid).toBe(renderMermaidSVGAsync)
|
|
63
|
+
})
|
|
64
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -23,10 +23,9 @@
|
|
|
23
23
|
// #624) call its primitives directly (zombie-mermaid#616), so it is part
|
|
24
24
|
// of the public API alongside `elk-adapter-utils.ts`.
|
|
25
25
|
//
|
|
26
|
-
// `layout.ts` re-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// being folded away here) because #625 is a move, not a redesign.
|
|
26
|
+
// `layout.ts` used to be a thin re-export of `layoutGraphSync` from
|
|
27
|
+
// `layout-engine.ts`; it was folded into `layout-engine.ts` directly
|
|
28
|
+
// (#1109), so `layoutGraphSync` now comes from the one star-export below.
|
|
30
29
|
//
|
|
31
30
|
// `class/`, `er/`, `sequence/`, `xychart/` hold each diagram type's
|
|
32
31
|
// renderer half (`layout.ts` + `renderer.ts`) — the other half
|
|
@@ -35,11 +34,18 @@
|
|
|
35
34
|
// `@zombie-mermaid/mermaid-parser` under the same issue (#624), per the
|
|
36
35
|
// scoping doc's finding 1: each per-type directory used to mix both halves
|
|
37
36
|
// in one place, and the split point already existed at the file level.
|
|
37
|
+
//
|
|
38
|
+
// `renderMermaidSVG` (below) is the single "Mermaid text in, SVG out" front
|
|
39
|
+
// door — moved here from the umbrella's `src/index.ts` under issue #1111,
|
|
40
|
+
// for parity with `@zombie-mermaid/ascii-renderer`'s `renderMermaidASCII`.
|
|
41
|
+
// `./registry.ts` (not re-exported — same as ascii-renderer's own
|
|
42
|
+
// `registry.ts` — it's this front door's internal dispatch table, not part
|
|
43
|
+
// of the public API) drives per-diagram-type dispatch; see that file's
|
|
44
|
+
// header for why it's a separate module from the ASCII side's table.
|
|
38
45
|
// ============================================================================
|
|
39
46
|
|
|
40
47
|
export * from './edge-curves.ts'
|
|
41
48
|
export * from './elk-instance.ts'
|
|
42
|
-
export * from './layout.ts'
|
|
43
49
|
export * from './layout-engine.ts'
|
|
44
50
|
export * from './layout-engine/elk-adapter-utils.ts'
|
|
45
51
|
export * from './layout-engine/elk-graph-builder.ts'
|
|
@@ -56,3 +62,214 @@ export * from './sequence/layout.ts'
|
|
|
56
62
|
export * from './sequence/renderer.ts'
|
|
57
63
|
export * from './xychart/layout.ts'
|
|
58
64
|
export * from './xychart/renderer.ts'
|
|
65
|
+
|
|
66
|
+
import { decodeXML } from 'entities'
|
|
67
|
+
import type {
|
|
68
|
+
RenderOptions,
|
|
69
|
+
DiagramColors,
|
|
70
|
+
SvgEmitOptions,
|
|
71
|
+
DiagramType,
|
|
72
|
+
} from '@zombie-mermaid/core'
|
|
73
|
+
import {
|
|
74
|
+
DEFAULTS,
|
|
75
|
+
themeStyleDeclarations,
|
|
76
|
+
isMonospaceFont,
|
|
77
|
+
setMonospaceMetrics,
|
|
78
|
+
detectDiagramType,
|
|
79
|
+
splitStatements,
|
|
80
|
+
} from '@zombie-mermaid/core'
|
|
81
|
+
import { resolveCssColors } from './resolve-colors.ts'
|
|
82
|
+
import { resolveFontSizes } from './styles.ts'
|
|
83
|
+
import { diagramRegistry } from './registry.ts'
|
|
84
|
+
import type { SvgRenderContext } from './registry.ts'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a DiagramColors object from render options.
|
|
88
|
+
* Uses DEFAULTS for bg/fg when not provided, and passes through
|
|
89
|
+
* optional enrichment colors (line, accent, muted, surface, border).
|
|
90
|
+
*/
|
|
91
|
+
function buildColors(options: RenderOptions): DiagramColors {
|
|
92
|
+
return {
|
|
93
|
+
bg: options.bg ?? DEFAULTS.bg,
|
|
94
|
+
fg: options.fg ?? DEFAULTS.fg,
|
|
95
|
+
line: options.line,
|
|
96
|
+
accent: options.accent,
|
|
97
|
+
muted: options.muted,
|
|
98
|
+
surface: options.surface,
|
|
99
|
+
border: options.border,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The exact CSS declaration list the root `<svg style="…">` attribute would
|
|
105
|
+
* carry for these options — `--bg`, `--fg`, whichever enrichment colours
|
|
106
|
+
* were given, and (unless `transparent`) `background: var(--bg)`.
|
|
107
|
+
*
|
|
108
|
+
* For hosts with a strict `Content-Security-Policy`: a `style=` attribute
|
|
109
|
+
* can't be nonced, so a `style-src` without `'unsafe-inline'` drops it and
|
|
110
|
+
* the diagram loses its colours. Render with `styleAttribute: false` and
|
|
111
|
+
* put this string in your own stylesheet on the SVG (or any ancestor —
|
|
112
|
+
* custom properties inherit) instead. Pass the same options object to both
|
|
113
|
+
* calls so the declarations match what the render expects. See
|
|
114
|
+
* `RenderOptions.styleAttribute` / `RenderOptions.nonce` and issue #216.
|
|
115
|
+
*
|
|
116
|
+
* Built by the same function that fills the attribute in normal renders,
|
|
117
|
+
* so there is one variable list to keep in sync. The string is compact
|
|
118
|
+
* (`--bg:#fff;--fg:#000;background:var(--bg)`) — valid inside any rule
|
|
119
|
+
* block — and the colour values are yours, unescaped, exactly as the
|
|
120
|
+
* attribute has always carried them.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* const opts = { bg: '#1a1b26', fg: '#a9b1d6', nonce, styleAttribute: false }
|
|
125
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', opts)
|
|
126
|
+
* const css = `.diagram svg { ${themeCssVariables(opts)} }`
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export function themeCssVariables(options: RenderOptions = {}): string {
|
|
130
|
+
return themeStyleDeclarations(buildColors(options), options.transparent)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the effective strict-CSP emission controls from the public
|
|
135
|
+
* options. Kept as one object so every renderer takes it as a single
|
|
136
|
+
* trailing parameter — see `SvgEmitOptions` in packages/core/src/theme.ts.
|
|
137
|
+
*/
|
|
138
|
+
function resolveSvgEmit(options: RenderOptions): SvgEmitOptions {
|
|
139
|
+
return {
|
|
140
|
+
nonce: options.nonce,
|
|
141
|
+
styleAttribute: options.styleAttribute,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Interactivity-derived render gates — whether xychart hover tooltips
|
|
146
|
+
// render, whether flowchart/state edge animation (`e1@{ animate: true }`)
|
|
147
|
+
// plays, and whether `click`-based links/`<title>` tooltips render — all
|
|
148
|
+
// now live next to their diagram type's registry entry in ./registry.ts
|
|
149
|
+
// (`resolveAnimationEnabled`/`resolveLinksEnabled` there, duplicated rather
|
|
150
|
+
// than imported since that module is imported BY this file — see those
|
|
151
|
+
// functions' own comments for why), since every diagram type's SVG dispatch
|
|
152
|
+
// is fully handled by the registry lookup below and there is no remaining
|
|
153
|
+
// switch case here for them to serve.
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render Mermaid diagram text to an SVG string — synchronously.
|
|
157
|
+
*
|
|
158
|
+
* Uses elk.bundled.js with a direct FakeWorker bypass (no setTimeout(0) delay).
|
|
159
|
+
* The ELK singleton is created lazily on first use and cached forever.
|
|
160
|
+
*
|
|
161
|
+
* Use this in React components with useMemo() to avoid flash:
|
|
162
|
+
* const svg = useMemo(() => renderMermaidSVG(code, opts), [code])
|
|
163
|
+
*
|
|
164
|
+
* @param text - Mermaid source text
|
|
165
|
+
* @param options - Rendering options (colors, font, spacing)
|
|
166
|
+
* @returns A self-contained SVG string
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```ts
|
|
170
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B')
|
|
171
|
+
*
|
|
172
|
+
* // With theme
|
|
173
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', {
|
|
174
|
+
* bg: '#1a1b26', fg: '#a9b1d6'
|
|
175
|
+
* })
|
|
176
|
+
*
|
|
177
|
+
* // With CSS variables (for live theme switching)
|
|
178
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', {
|
|
179
|
+
* bg: 'var(--background)', fg: 'var(--foreground)', transparent: true
|
|
180
|
+
* })
|
|
181
|
+
*
|
|
182
|
+
* // With the original source stamped onto the root <svg> as data-src —
|
|
183
|
+
* // handy for a "copy source" button or an "open in Mermaid Live" link
|
|
184
|
+
* // without re-attaching it via string surgery on the output.
|
|
185
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', { embedSource: true })
|
|
186
|
+
*
|
|
187
|
+
* // With an accessible name — role="img" + aria-labelledby pointing at a
|
|
188
|
+
* // <title> child, so assistive tech announces the diagram instead of
|
|
189
|
+
* // reading every node label individually (see issue #215).
|
|
190
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', {
|
|
191
|
+
* title: 'Flowchart: Build → Test → Ship'
|
|
192
|
+
* })
|
|
193
|
+
*
|
|
194
|
+
* // Decorative diagram — already described in surrounding prose, so it's
|
|
195
|
+
* // hidden from assistive tech (aria-hidden="true") instead of named.
|
|
196
|
+
* const svg = renderMermaidSVG('graph TD\n A --> B', { decorative: true })
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export function renderMermaidSVG(
|
|
200
|
+
text: string,
|
|
201
|
+
options: RenderOptions = {},
|
|
202
|
+
): string {
|
|
203
|
+
const svg = renderMermaidSVGRaw(text, options)
|
|
204
|
+
return options.resolveColors
|
|
205
|
+
? resolveCssColors(svg, buildColors(options))
|
|
206
|
+
: svg
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The renderer proper — `renderMermaidSVG` minus the optional `resolveColors` post-pass. */
|
|
210
|
+
function renderMermaidSVGRaw(text: string, options: RenderOptions): string {
|
|
211
|
+
// Decode XML entities that may leak from markdown parsers (e.g. rehype-raw).
|
|
212
|
+
// Without this, escapeXml() double-encodes them: < → &lt; → literal "<" in SVG.
|
|
213
|
+
// `text` itself is left untouched so `embedSource` below stamps the exact
|
|
214
|
+
// string the caller passed in, not this entity-decoded copy used
|
|
215
|
+
// internally for parsing.
|
|
216
|
+
const decoded = decodeXML(text)
|
|
217
|
+
|
|
218
|
+
const colors = buildColors(options)
|
|
219
|
+
const font = options.font ?? 'Inter'
|
|
220
|
+
// Box sizing depends on the metrics model, so pick it before any layout runs.
|
|
221
|
+
setMonospaceMetrics(isMonospaceFont(font))
|
|
222
|
+
const transparent = options.transparent ?? false
|
|
223
|
+
const fontSizes = resolveFontSizes(options.fontSizes)
|
|
224
|
+
const diagramType: DiagramType = detectDiagramType(decoded)
|
|
225
|
+
const embedSource = options.embedSource ? text : undefined
|
|
226
|
+
const title = options.title
|
|
227
|
+
const decorative = options.decorative
|
|
228
|
+
const emit = resolveSvgEmit(options)
|
|
229
|
+
|
|
230
|
+
const lines = splitStatements(decoded)
|
|
231
|
+
|
|
232
|
+
// Registry dispatch (see ./registry.ts): every diagram type, including
|
|
233
|
+
// 'flowchart' (and the 'state' pipeline it shares), is registered there.
|
|
234
|
+
// `parse` takes both `lines` (what every already-registered type's parser
|
|
235
|
+
// wants) and `decoded` (the raw text flowchart/state's parser needs
|
|
236
|
+
// instead — see the `parse` doc comment on `DiagramModule` in
|
|
237
|
+
// ./registry.ts).
|
|
238
|
+
const registered = diagramRegistry[diagramType]
|
|
239
|
+
const diagram = registered.parse(lines, decoded)
|
|
240
|
+
const positioned = registered.layoutForSvg(diagram, options)
|
|
241
|
+
const ctx: SvgRenderContext = {
|
|
242
|
+
colors,
|
|
243
|
+
font,
|
|
244
|
+
transparent,
|
|
245
|
+
fontSizes,
|
|
246
|
+
embedSource,
|
|
247
|
+
title,
|
|
248
|
+
decorative,
|
|
249
|
+
emit,
|
|
250
|
+
}
|
|
251
|
+
return registered.renderSvg(positioned, ctx, options)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Render Mermaid diagram text to an SVG string — async.
|
|
256
|
+
*
|
|
257
|
+
* Same result as renderMermaidSVG() but returns a Promise.
|
|
258
|
+
* Useful in async contexts (server handlers, data loaders, etc.)
|
|
259
|
+
*/
|
|
260
|
+
export async function renderMermaidSVGAsync(
|
|
261
|
+
text: string,
|
|
262
|
+
options: RenderOptions = {},
|
|
263
|
+
): Promise<string> {
|
|
264
|
+
return renderMermaidSVG(text, options)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Backward-compatible aliases
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/** @deprecated Use `renderMermaidSVG` */
|
|
272
|
+
export const renderMermaidSync = renderMermaidSVG
|
|
273
|
+
|
|
274
|
+
/** @deprecated Use `renderMermaidSVGAsync` */
|
|
275
|
+
export const renderMermaid = renderMermaidSVGAsync
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* up ELK's raw output.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { ElkNode } from 'elkjs'
|
|
10
|
+
import type { ElkNode, ElkExtendedEdge } from 'elkjs'
|
|
11
11
|
import type {
|
|
12
12
|
MermaidGraph,
|
|
13
13
|
MermaidSubgraph,
|
|
@@ -432,9 +432,27 @@ function extractEdgesRecursively(
|
|
|
432
432
|
offsetY: number,
|
|
433
433
|
margins?: MarginInfo,
|
|
434
434
|
): void {
|
|
435
|
-
// First pass: collect all edge segments
|
|
435
|
+
// First pass: collect all edge segments.
|
|
436
|
+
//
|
|
437
|
+
// Under `hierarchyHandling: INCLUDE_CHILDREN` (see to-elk.ts — used when no
|
|
438
|
+
// subgraph has a direction override), ELK is free to leave a
|
|
439
|
+
// cross-hierarchy edge in an *ancestor* node's `edges` array while
|
|
440
|
+
// reporting its sections/labels in the coordinate space of the deeper
|
|
441
|
+
// container named by the edge's own (untyped-in-elkjs) `container`
|
|
442
|
+
// property. `collectEdgeSegments`'s tree walk assumes an edge's
|
|
443
|
+
// coordinates match the array it was found in, which breaks for exactly
|
|
444
|
+
// this case — the edge lands at the *ancestor's* offset instead of the
|
|
445
|
+
// declared container's, shifting nested-subgraph edges/labels off their
|
|
446
|
+
// node boundaries toward the root. Pre-computing every container's
|
|
447
|
+
// accumulated root offset here lets `collectEdgeSegments` prefer the
|
|
448
|
+
// edge's declared container when present, falling back to the owning
|
|
449
|
+
// array's offset (the previous behavior) otherwise — see
|
|
450
|
+
// zombie-mermaid#1145, ported from a fix by Galen Suen in
|
|
451
|
+
// lukilabs/beautiful-mermaid#152.
|
|
452
|
+
const containerOffsets = new Map<string, Point>()
|
|
453
|
+
collectContainerOffsets(elkNode, containerOffsets, 0, 0)
|
|
436
454
|
const segments = new Map<number, EdgeSegmentGroup>()
|
|
437
|
-
collectEdgeSegments(elkNode, segments, 0, 0)
|
|
455
|
+
collectEdgeSegments(elkNode, segments, 0, 0, containerOffsets)
|
|
438
456
|
|
|
439
457
|
// Track margin-routed edge count for spacing offsets
|
|
440
458
|
let marginEdgeIndex = 0
|
|
@@ -518,6 +536,35 @@ function extractEdgesRecursively(
|
|
|
518
536
|
}
|
|
519
537
|
}
|
|
520
538
|
|
|
539
|
+
/**
|
|
540
|
+
* Build a map from every ELK node's own id to its accumulated root-relative
|
|
541
|
+
* offset, by walking the same `elkNode.children` tree `collectEdgeSegments`
|
|
542
|
+
* walks. Used to resolve a cross-hierarchy edge's declared `container`
|
|
543
|
+
* (an ELK output property not modeled in elkjs's types) to the offset of
|
|
544
|
+
* the container ELK actually reported its coordinates in, rather than the
|
|
545
|
+
* offset of whichever ancestor array the edge object happened to be left
|
|
546
|
+
* in — see the comment on `extractEdgesRecursively`'s call site.
|
|
547
|
+
*/
|
|
548
|
+
function collectContainerOffsets(
|
|
549
|
+
elkNode: ElkNode,
|
|
550
|
+
offsets: Map<string, Point>,
|
|
551
|
+
offsetX: number,
|
|
552
|
+
offsetY: number,
|
|
553
|
+
): void {
|
|
554
|
+
offsets.set(elkNode.id, { x: offsetX, y: offsetY })
|
|
555
|
+
for (const child of elkNode.children ?? []) {
|
|
556
|
+
// `x`/`y` are optional in elkjs's types, but ELK always assigns both to
|
|
557
|
+
// every laid-out child node — the `?? 0` fallback mirrors the same
|
|
558
|
+
// defensive pattern already used for this walk in `collectEdgeSegments`
|
|
559
|
+
// below, and is equally unreachable from real ELK output.
|
|
560
|
+
/* v8 ignore next */
|
|
561
|
+
const childX = child.x ?? 0
|
|
562
|
+
/* v8 ignore next */
|
|
563
|
+
const childY = child.y ?? 0
|
|
564
|
+
collectContainerOffsets(child, offsets, offsetX + childX, offsetY + childY)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
521
568
|
/**
|
|
522
569
|
* Post-process edge points to ensure all segments are purely orthogonal.
|
|
523
570
|
*
|
|
@@ -596,6 +643,7 @@ function collectEdgeSegments(
|
|
|
596
643
|
segments: Map<number, EdgeSegmentGroup>,
|
|
597
644
|
offsetX: number,
|
|
598
645
|
offsetY: number,
|
|
646
|
+
containerOffsets: Map<string, Point>,
|
|
599
647
|
): void {
|
|
600
648
|
if (elkNode.edges) {
|
|
601
649
|
for (const elkEdge of elkNode.edges) {
|
|
@@ -605,9 +653,35 @@ function collectEdgeSegments(
|
|
|
605
653
|
if (!parsed) continue
|
|
606
654
|
const { edgeIndex } = parsed
|
|
607
655
|
|
|
656
|
+
// Prefer the edge's declared container offset (see
|
|
657
|
+
// `collectContainerOffsets`'s comment) over the owning array's
|
|
658
|
+
// offset. `container` and `containerOffsets.get(container)` were
|
|
659
|
+
// verified (by direct instrumentation against real elk.bundled.js
|
|
660
|
+
// output, across both flat and multiply-nested subgraphs) to always
|
|
661
|
+
// be defined in practice — ELK tags every edge with its container,
|
|
662
|
+
// even at the root, and `collectContainerOffsets` walks the same
|
|
663
|
+
// tree so every container id it can name is already in the map. The
|
|
664
|
+
// `container?`/`?? offset{X,Y}` fallbacks below exist only because
|
|
665
|
+
// `container` is untyped in elkjs and `Map.get` is typed to allow a
|
|
666
|
+
// miss; kept as defensive narrowing rather than assumed impossible.
|
|
667
|
+
const container = (elkEdge as ElkExtendedEdge & { container?: string })
|
|
668
|
+
.container
|
|
669
|
+
/* v8 ignore next */
|
|
670
|
+
const containerOffset = container
|
|
671
|
+
? containerOffsets.get(container)
|
|
672
|
+
: undefined
|
|
673
|
+
/* v8 ignore next */
|
|
674
|
+
const edgeOffsetX = containerOffset?.x ?? offsetX
|
|
675
|
+
/* v8 ignore next */
|
|
676
|
+
const edgeOffsetY = containerOffset?.y ?? offsetY
|
|
677
|
+
|
|
608
678
|
// Extract points and label position
|
|
609
|
-
const points = extractEdgePoints(elkEdge,
|
|
610
|
-
const labelPosition = extractEdgeLabelPosition(
|
|
679
|
+
const points = extractEdgePoints(elkEdge, edgeOffsetX, edgeOffsetY)
|
|
680
|
+
const labelPosition = extractEdgeLabelPosition(
|
|
681
|
+
elkEdge,
|
|
682
|
+
edgeOffsetX,
|
|
683
|
+
edgeOffsetY,
|
|
684
|
+
)
|
|
611
685
|
|
|
612
686
|
// Store segment
|
|
613
687
|
const seg: EdgeSegmentGroup = segments.get(edgeIndex) ?? {
|
|
@@ -635,6 +709,7 @@ function collectEdgeSegments(
|
|
|
635
709
|
segments,
|
|
636
710
|
offsetX + (child.x ?? 0),
|
|
637
711
|
offsetY + (child.y ?? 0),
|
|
712
|
+
containerOffsets,
|
|
638
713
|
)
|
|
639
714
|
}
|
|
640
715
|
}
|