@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
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,1485 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PositionedGraph,
|
|
3
|
+
PositionedNode,
|
|
4
|
+
PositionedEdge,
|
|
5
|
+
PositionedGroup,
|
|
6
|
+
Point,
|
|
7
|
+
DiagramColors,
|
|
8
|
+
SvgEmitOptions,
|
|
9
|
+
CurveStyle,
|
|
10
|
+
} from '@zombie-mermaid/core'
|
|
11
|
+
import {
|
|
12
|
+
svgOpenTag,
|
|
13
|
+
buildStyleBlock,
|
|
14
|
+
styleOpenTag,
|
|
15
|
+
getReadableTextColor,
|
|
16
|
+
measureMultilineText,
|
|
17
|
+
renderMultilineText,
|
|
18
|
+
renderMultilineTextWithBackground,
|
|
19
|
+
escapeXml,
|
|
20
|
+
escapeAttr,
|
|
21
|
+
safeHref,
|
|
22
|
+
sanitizeClassName,
|
|
23
|
+
} from '@zombie-mermaid/core'
|
|
24
|
+
import type { FontSizes } from './styles.ts'
|
|
25
|
+
import {
|
|
26
|
+
FONT_SIZES,
|
|
27
|
+
FONT_WEIGHTS,
|
|
28
|
+
STROKE_WIDTHS,
|
|
29
|
+
ARROW_HEAD,
|
|
30
|
+
} from './styles.ts'
|
|
31
|
+
import { pointsToPath } from './edge-curves.ts'
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// SVG renderer — converts a PositionedGraph into an SVG string.
|
|
35
|
+
//
|
|
36
|
+
// Pure string concatenation, no DOM manipulation.
|
|
37
|
+
// Renders back-to-front: groups → edges → arrow heads → edge labels → nodes → node labels.
|
|
38
|
+
//
|
|
39
|
+
// All colors are referenced via CSS custom properties (var(--_xxx)) defined
|
|
40
|
+
// in the <style> block. The caller provides bg/fg (+ optional enrichment
|
|
41
|
+
// colors) via DiagramColors, which are set as inline CSS variables on the
|
|
42
|
+
// <svg> tag. See packages/core/src/theme.ts for the full variable system.
|
|
43
|
+
//
|
|
44
|
+
// Style spec:
|
|
45
|
+
// - All corners rx=0 ry=0 (sharp)
|
|
46
|
+
// - Stroke widths: outer box 1px, inner box 0.75px, connectors 0.75px
|
|
47
|
+
// - Arrow heads: filled triangles, 8px wide × 4.8px tall
|
|
48
|
+
// - Dashed edges: stroke-dasharray="4 4"
|
|
49
|
+
// - Font: Inter with weight per element type
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Render a positioned graph as an SVG string.
|
|
54
|
+
*
|
|
55
|
+
* @param colors - DiagramColors with bg/fg and optional enrichment variables.
|
|
56
|
+
* These are set as CSS custom properties on the <svg> tag.
|
|
57
|
+
* All element colors reference derived --_xxx variables.
|
|
58
|
+
* @param transparent - If true, renders with transparent background.
|
|
59
|
+
* @param embedSource - Original diagram source to stamp onto the root `<svg>`
|
|
60
|
+
* as `data-src` (from `options.embedSource`). Omitted
|
|
61
|
+
* when the option is off.
|
|
62
|
+
* @param animationEnabled - Whether `e1@{ animate: true }` edges actually
|
|
63
|
+
* animate (from `options.interactivity === 'full'`,
|
|
64
|
+
* see `resolveAnimationEnabled` in
|
|
65
|
+
* src/diagram-registry.ts).
|
|
66
|
+
* Default true — preserves the previously-ungated
|
|
67
|
+
* behavior for callers who don't pass it.
|
|
68
|
+
* @param linksEnabled - Whether `click`-based `<a href>` links and `<title>`
|
|
69
|
+
* tooltips render (from
|
|
70
|
+
* `options.interactivity !== 'none'`, see
|
|
71
|
+
* `resolveLinksEnabled` in src/diagram-registry.ts). Default true
|
|
72
|
+
* — preserves the previously-ungated behavior for
|
|
73
|
+
* callers who don't pass it.
|
|
74
|
+
* @param title - Accessible name (from `options.title`). See svgOpenTag() in
|
|
75
|
+
* packages/core/src/theme.ts.
|
|
76
|
+
* @param decorative - Marks the SVG decorative (from `options.decorative`).
|
|
77
|
+
* @param emit - Strict-CSP controls (from `options.nonce` /
|
|
78
|
+
* `options.styleAttribute`, see #216): a `nonce` for every
|
|
79
|
+
* `<style>` element, and whether the root `style="…"`
|
|
80
|
+
* attribute is emitted at all. Default: no nonce, attribute on.
|
|
81
|
+
*/
|
|
82
|
+
export function renderSvg(
|
|
83
|
+
graph: PositionedGraph,
|
|
84
|
+
colors: DiagramColors,
|
|
85
|
+
font: string = 'Inter',
|
|
86
|
+
transparent: boolean = false,
|
|
87
|
+
fontSizes: FontSizes = FONT_SIZES,
|
|
88
|
+
curve: CurveStyle = 'linear',
|
|
89
|
+
embedSource?: string,
|
|
90
|
+
animationEnabled: boolean = true,
|
|
91
|
+
linksEnabled: boolean = true,
|
|
92
|
+
title?: string,
|
|
93
|
+
decorative?: boolean,
|
|
94
|
+
emit: SvgEmitOptions = {},
|
|
95
|
+
): string {
|
|
96
|
+
const parts: string[] = []
|
|
97
|
+
|
|
98
|
+
// See #239: a click-based link renders as a focusable <a href> inside the
|
|
99
|
+
// SVG, which role="img"/aria-hidden would hide from assistive tech while
|
|
100
|
+
// leaving it Tab-reachable — svgOpenTag forces no root role in that case.
|
|
101
|
+
// Uses the same safeHref() check renderNode uses to decide whether an <a>
|
|
102
|
+
// is actually emitted — a rejected scheme or control character means no
|
|
103
|
+
// link renders, so it shouldn't affect the SVG's accessibility semantics.
|
|
104
|
+
// Gated by linksEnabled too: `interactivity: 'none'` strips the <a> below,
|
|
105
|
+
// so it shouldn't count toward this decision either.
|
|
106
|
+
const hasInteractiveLinks =
|
|
107
|
+
linksEnabled &&
|
|
108
|
+
graph.nodes.some((n) => Boolean(safeHref(n.interaction?.href)))
|
|
109
|
+
|
|
110
|
+
// SVG root with CSS variables + style block + defs
|
|
111
|
+
parts.push(
|
|
112
|
+
withDataSrc(
|
|
113
|
+
svgOpenTag(
|
|
114
|
+
graph.width,
|
|
115
|
+
graph.height,
|
|
116
|
+
colors,
|
|
117
|
+
transparent,
|
|
118
|
+
title,
|
|
119
|
+
decorative,
|
|
120
|
+
hasInteractiveLinks,
|
|
121
|
+
emit.styleAttribute,
|
|
122
|
+
),
|
|
123
|
+
embedSource,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
parts.push(buildStyleBlock(font, false, emit.nonce))
|
|
127
|
+
// Keyframes for animated edges (`e1@{ animate: true }`). Emitted only when
|
|
128
|
+
// an animated edge exists and animation is enabled, so an ordinary diagram
|
|
129
|
+
// (or one rendered with `interactivity: 'none'`) gains no extra markup.
|
|
130
|
+
if (animationEnabled && graph.edges.some((e) => e.animate)) {
|
|
131
|
+
parts.push(edgeAnimationStyle(emit.nonce))
|
|
132
|
+
}
|
|
133
|
+
parts.push('<defs>')
|
|
134
|
+
parts.push(arrowMarkerDefs())
|
|
135
|
+
// Per-color arrow markers for edges with custom stroke via linkStyle
|
|
136
|
+
const customStrokeColors = new Set<string>()
|
|
137
|
+
for (const edge of graph.edges) {
|
|
138
|
+
if (edge.inlineStyle?.stroke) {
|
|
139
|
+
customStrokeColors.add(edge.inlineStyle.stroke)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const color of customStrokeColors) {
|
|
143
|
+
parts.push(arrowMarkerDefsForColor(color))
|
|
144
|
+
}
|
|
145
|
+
parts.push('</defs>')
|
|
146
|
+
|
|
147
|
+
// 1. Subgraph backgrounds (group rectangles with header bands)
|
|
148
|
+
for (const group of graph.groups) {
|
|
149
|
+
parts.push(renderGroup(group, font, fontSizes))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 2. Edges (paths — rendered behind nodes)
|
|
153
|
+
// Each edge is a <path> with semantic data-* attributes
|
|
154
|
+
for (const edge of graph.edges) {
|
|
155
|
+
parts.push(renderEdge(edge, curve, animationEnabled))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 3. Edge labels (positioned at midpoint of edge)
|
|
159
|
+
// Each label is wrapped in <g class="edge-label">
|
|
160
|
+
for (const edge of graph.edges) {
|
|
161
|
+
if (edge.label) {
|
|
162
|
+
parts.push(renderEdgeLabel(edge, font, fontSizes))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4. Nodes (shape + label wrapped in <g class="node">)
|
|
167
|
+
for (const node of graph.nodes) {
|
|
168
|
+
parts.push(renderNode(node, font, fontSizes, linksEnabled))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
parts.push('</svg>')
|
|
172
|
+
|
|
173
|
+
return parts.join('\n')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// Arrow marker definitions
|
|
178
|
+
// ============================================================================
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Keyframes for `e1@{ animate: true }` edges — a marching-ants dash.
|
|
182
|
+
*
|
|
183
|
+
* CSS animation rather than SMIL: SMIL is deprecated in browsers, while a
|
|
184
|
+
* CSS `@keyframes` block inside the SVG animates in browsers and is simply
|
|
185
|
+
* ignored by static rasterizers, which render the first frame. The
|
|
186
|
+
* `prefers-reduced-motion` guard stops the animation for users who have asked
|
|
187
|
+
* the system for less movement — the edge still renders, just still.
|
|
188
|
+
*
|
|
189
|
+
* A second `<style>` element, so it takes the same `nonce` as the theme
|
|
190
|
+
* block (see `styleOpenTag`) — under a nonce-based CSP it would otherwise be
|
|
191
|
+
* the one un-nonced element on the page and get silently dropped.
|
|
192
|
+
*/
|
|
193
|
+
function edgeAnimationStyle(nonce?: string): string {
|
|
194
|
+
return [
|
|
195
|
+
styleOpenTag(nonce),
|
|
196
|
+
' @keyframes zm-edge-dash { to { stroke-dashoffset: -28; } }',
|
|
197
|
+
' .edge-animated { animation: zm-edge-dash 1s linear infinite; }',
|
|
198
|
+
' @media (prefers-reduced-motion: reduce) {',
|
|
199
|
+
' .edge-animated { animation: none; }',
|
|
200
|
+
' }',
|
|
201
|
+
'</style>',
|
|
202
|
+
].join('\n')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reusable arrow head markers — both forward (end) and reverse (start) variants.
|
|
207
|
+
* Arrow color uses the var(--_arrow) CSS variable.
|
|
208
|
+
*/
|
|
209
|
+
function arrowMarkerDefs(): string {
|
|
210
|
+
return arrowMarkerPair('var(--_arrow)', '')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Arrow markers tinted to a specific color (for linkStyle stroke overrides).
|
|
215
|
+
* IDs are suffixed with a sanitized color string to avoid collisions.
|
|
216
|
+
*/
|
|
217
|
+
function arrowMarkerDefsForColor(color: string): string {
|
|
218
|
+
return arrowMarkerPair(escapeAttr(color), `-${markerSuffix(color)}`)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build the forward (marker-end) + reverse (marker-start) arrow-head marker pair.
|
|
223
|
+
*
|
|
224
|
+
* Both heads share one polygon. The reverse head differs only by
|
|
225
|
+
* orient="auto-start-reverse", which flips it 180° to point back out of
|
|
226
|
+
* the start node — so the polygon must NOT be pre-reversed. Reversing both is
|
|
227
|
+
* a double reversal: the head points into the line and vanishes in
|
|
228
|
+
* librsvg/Inkscape/browsers.
|
|
229
|
+
*/
|
|
230
|
+
function arrowMarkerPair(color: string, idSuffix: string): string {
|
|
231
|
+
const w = ARROW_HEAD.width
|
|
232
|
+
const h = ARROW_HEAD.height
|
|
233
|
+
// Pull arrowhead back slightly (refX = w - 1) to prevent clipping at node boundaries.
|
|
234
|
+
const refX = w - 1
|
|
235
|
+
// Both fill and a thin stroke for better definition at small sizes.
|
|
236
|
+
const style = `fill="${color}" stroke="${color}" stroke-width="0.75" stroke-linejoin="round"`
|
|
237
|
+
const polygon = `<polygon points="0 0, ${w} ${h / 2}, 0 ${h}" ${style} />`
|
|
238
|
+
const marker = (id: string, orient: string) =>
|
|
239
|
+
` <marker id="${id}" markerWidth="${w}" markerHeight="${h}" refX="${refX}" refY="${h / 2}" orient="${orient}">` +
|
|
240
|
+
`\n ${polygon}` +
|
|
241
|
+
`\n </marker>`
|
|
242
|
+
return (
|
|
243
|
+
marker(`arrowhead${idSuffix}`, 'auto') +
|
|
244
|
+
'\n' +
|
|
245
|
+
marker(`arrowhead-start${idSuffix}`, 'auto-start-reverse')
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Sanitize a color value into a collision-free SVG ID suffix.
|
|
250
|
+
* Non-alphanumeric chars are hex-encoded so distinct inputs never collapse
|
|
251
|
+
* (e.g. "var(--line-1)" → "var28--line2d129", "var(--line1)" → "var28--line129"). */
|
|
252
|
+
function markerSuffix(color: string): string {
|
|
253
|
+
return color.replace(/[^a-zA-Z0-9]/g, (ch) => ch.charCodeAt(0).toString(16))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ============================================================================
|
|
257
|
+
// Group rendering (subgraph backgrounds)
|
|
258
|
+
// ============================================================================
|
|
259
|
+
|
|
260
|
+
function renderGroup(
|
|
261
|
+
group: PositionedGroup,
|
|
262
|
+
font: string,
|
|
263
|
+
fontSizes: FontSizes,
|
|
264
|
+
): string {
|
|
265
|
+
const headerHeight = fontSizes.groupHeader + 16
|
|
266
|
+
const parts: string[] = []
|
|
267
|
+
|
|
268
|
+
// Opening <g> with semantic attributes for subgraph identification
|
|
269
|
+
// data-id: original Mermaid subgraph ID
|
|
270
|
+
// data-label: display label (may differ from ID)
|
|
271
|
+
parts.push(
|
|
272
|
+
`<g class="subgraph" data-id="${escapeAttr(group.id)}" data-label="${escapeAttr(group.label)}">`,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
// Outer rectangle
|
|
276
|
+
parts.push(
|
|
277
|
+
` <rect x="${group.x}" y="${group.y}" width="${group.width}" height="${group.height}" ` +
|
|
278
|
+
`rx="0" ry="0" fill="var(--_group-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
// Header band
|
|
282
|
+
parts.push(
|
|
283
|
+
` <rect x="${group.x}" y="${group.y}" width="${group.width}" height="${headerHeight}" ` +
|
|
284
|
+
`rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
// Header label (supports multi-line via <br> tags)
|
|
288
|
+
parts.push(
|
|
289
|
+
' ' +
|
|
290
|
+
renderMultilineText(
|
|
291
|
+
group.label,
|
|
292
|
+
group.x + 12,
|
|
293
|
+
group.y + headerHeight / 2,
|
|
294
|
+
fontSizes.groupHeader,
|
|
295
|
+
`font-size="${fontSizes.groupHeader}" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)"`,
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
// Render nested groups recursively (inside this group)
|
|
300
|
+
for (const child of group.children) {
|
|
301
|
+
parts.push(renderGroup(child, font, fontSizes))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
parts.push('</g>')
|
|
305
|
+
|
|
306
|
+
return parts.join('\n')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ============================================================================
|
|
310
|
+
// Edge rendering
|
|
311
|
+
// ============================================================================
|
|
312
|
+
|
|
313
|
+
function renderEdge(
|
|
314
|
+
edge: PositionedEdge,
|
|
315
|
+
curve: CurveStyle,
|
|
316
|
+
animationEnabled: boolean = true,
|
|
317
|
+
): string {
|
|
318
|
+
if (edge.points.length < 2) return ''
|
|
319
|
+
|
|
320
|
+
// `interactivity: 'none'` strips motion: the edge still renders (id,
|
|
321
|
+
// data-* attributes, arrowheads) but never gets the animated class or dash.
|
|
322
|
+
const animate = animationEnabled && (edge.animate ?? false)
|
|
323
|
+
|
|
324
|
+
/*
|
|
325
|
+
* A curved edge must be a <path>; only a path can express the
|
|
326
|
+
* interpolations Mermaid's `flowchart.curve` selects.
|
|
327
|
+
*
|
|
328
|
+
* The default (`linear`) deliberately keeps emitting <polyline>. A path of
|
|
329
|
+
* straight `L` segments would be geometrically identical, but changing the
|
|
330
|
+
* element for every diagram would break any consumer selecting
|
|
331
|
+
* `polyline.edge` — for no benefit to a diagram that asked for no curve.
|
|
332
|
+
* So the element changes only when the author opts into a curve.
|
|
333
|
+
*/
|
|
334
|
+
const curved = curve !== 'linear'
|
|
335
|
+
|
|
336
|
+
/*
|
|
337
|
+
* An invisible link (`A ~~~ B`) still occupies its layout slot — that is
|
|
338
|
+
* the whole point of the syntax — so the element is emitted with its data
|
|
339
|
+
* attributes intact and only its paint suppressed. Omitting the element
|
|
340
|
+
* entirely would lose it from DOM inspection and from `data-style` queries.
|
|
341
|
+
*/
|
|
342
|
+
const invisible = edge.style === 'invisible'
|
|
343
|
+
|
|
344
|
+
const dashArray = edge.style === 'dotted' ? ' stroke-dasharray="4 4"' : ''
|
|
345
|
+
const baseStrokeWidth =
|
|
346
|
+
edge.style === 'thick'
|
|
347
|
+
? STROKE_WIDTHS.connector * 2
|
|
348
|
+
: STROKE_WIDTHS.connector
|
|
349
|
+
const strokeColor = invisible
|
|
350
|
+
? 'none'
|
|
351
|
+
: escapeAttr(edge.inlineStyle?.stroke ?? 'var(--_line)')
|
|
352
|
+
const strokeWidth = escapeAttr(
|
|
353
|
+
edge.inlineStyle?.['stroke-width'] ?? String(baseStrokeWidth),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
// Build marker attributes based on arrow direction flags
|
|
357
|
+
// Use color-specific markers when edge has a custom stroke from linkStyle
|
|
358
|
+
const suffix = edge.inlineStyle?.stroke
|
|
359
|
+
? `-${markerSuffix(edge.inlineStyle.stroke)}`
|
|
360
|
+
: ''
|
|
361
|
+
let markers = ''
|
|
362
|
+
if (!invisible) {
|
|
363
|
+
if (edge.hasArrowEnd) markers += ` marker-end="url(#arrowhead${suffix})"`
|
|
364
|
+
if (edge.hasArrowStart)
|
|
365
|
+
markers += ` marker-start="url(#arrowhead-start${suffix})"`
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Semantic data attributes for edge identification and inspection:
|
|
369
|
+
// - class="edge": CSS targeting and type identification
|
|
370
|
+
// - data-from/data-to: source and target node IDs
|
|
371
|
+
// - data-style: edge style (solid, dotted, thick)
|
|
372
|
+
// - data-arrow-start/end: arrow presence flags
|
|
373
|
+
// - data-label: edge label if present (for quick lookup without traversing DOM)
|
|
374
|
+
const dataAttrs = [
|
|
375
|
+
// An animated edge (`e1@{ animate: true }`, with animation enabled)
|
|
376
|
+
// carries an extra class; its keyframes live in the shared style block.
|
|
377
|
+
`class="edge${animate ? ' edge-animated' : ''}"`,
|
|
378
|
+
`data-from="${escapeAttr(edge.source)}"`,
|
|
379
|
+
`data-to="${escapeAttr(edge.target)}"`,
|
|
380
|
+
`data-style="${edge.style}"`,
|
|
381
|
+
`data-arrow-start="${edge.hasArrowStart}"`,
|
|
382
|
+
`data-arrow-end="${edge.hasArrowEnd}"`,
|
|
383
|
+
]
|
|
384
|
+
if (edge.label) {
|
|
385
|
+
dataAttrs.push(`data-label="${escapeAttr(edge.label)}"`)
|
|
386
|
+
}
|
|
387
|
+
if (edge.id) {
|
|
388
|
+
// Mermaid edge id (`A e1@--> B`), used to target the edge from CSS and
|
|
389
|
+
// to attach the animation below.
|
|
390
|
+
dataAttrs.push(`data-id="${escapeAttr(edge.id)}"`)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Marching ants need a dash pattern to march; supply one only when the
|
|
394
|
+
// edge's own style hasn't already set stroke-dasharray.
|
|
395
|
+
const animatedDash = animate && !dashArray ? ' stroke-dasharray="8 6"' : ''
|
|
396
|
+
|
|
397
|
+
const geometry = curved
|
|
398
|
+
? `d="${pointsToPath(edge.points, curve)}"`
|
|
399
|
+
: `points="${pointsToPolylinePath(edge.points)}"`
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
`<${curved ? 'path' : 'polyline'} ${dataAttrs.join(' ')} ${geometry} ` +
|
|
403
|
+
`fill="none" stroke="${strokeColor}" ` +
|
|
404
|
+
`stroke-width="${strokeWidth}"${dashArray}${animatedDash}${markers} />`
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Convert points to SVG polyline points attribute: "x1,y1 x2,y2 ..." */
|
|
409
|
+
function pointsToPolylinePath(points: Point[]): string {
|
|
410
|
+
return points.map((p) => `${p.x},${p.y}`).join(' ')
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// `_font` isn't read here but is kept to match the `(entity, font)` signature
|
|
414
|
+
// threaded through the rest of the render* functions in this file.
|
|
415
|
+
function renderEdgeLabel(
|
|
416
|
+
edge: PositionedEdge,
|
|
417
|
+
_font: string,
|
|
418
|
+
fontSizes: FontSizes,
|
|
419
|
+
): string {
|
|
420
|
+
// Only called when edge.label is set (see call site), but narrow it here
|
|
421
|
+
// too rather than trusting that invariant across the function boundary.
|
|
422
|
+
if (!edge.label) return ''
|
|
423
|
+
const label = edge.label
|
|
424
|
+
|
|
425
|
+
// Use layout-computed label position when available (layout-aware, avoids collisions).
|
|
426
|
+
// Fall back to geometric midpoint of the edge polyline.
|
|
427
|
+
const mid = edge.labelPosition ?? edgeMidpoint(edge.points)
|
|
428
|
+
const padding = 8
|
|
429
|
+
|
|
430
|
+
// Measure text (works for both single and multi-line)
|
|
431
|
+
const metrics = measureMultilineText(
|
|
432
|
+
label,
|
|
433
|
+
fontSizes.edgeLabel,
|
|
434
|
+
FONT_WEIGHTS.edgeLabel,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
// Wrap in <g class="edge-label"> with reference to the edge it belongs to
|
|
438
|
+
const content = renderMultilineTextWithBackground(
|
|
439
|
+
label,
|
|
440
|
+
mid.x,
|
|
441
|
+
mid.y,
|
|
442
|
+
metrics.width,
|
|
443
|
+
metrics.height,
|
|
444
|
+
fontSizes.edgeLabel,
|
|
445
|
+
padding,
|
|
446
|
+
// Use --_text-sec for better contrast (was --_text-muted)
|
|
447
|
+
`text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-sec)"`,
|
|
448
|
+
// Increased stroke width from 0.5 to 1 for better label separation from edges
|
|
449
|
+
`rx="2" ry="2" fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="1"`,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
// Semantic wrapper: links label to its edge via data-from/data-to
|
|
453
|
+
return (
|
|
454
|
+
`<g class="edge-label" data-from="${escapeAttr(edge.source)}" data-to="${escapeAttr(edge.target)}" data-label="${escapeAttr(label)}">\n` +
|
|
455
|
+
` ${content.replace(/\n/g, '\n ')}\n` +
|
|
456
|
+
`</g>`
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Get the point at `index`, throwing instead of silently producing
|
|
462
|
+
* `undefined` (and downstream `NaN`s). The loops in `edgeMidpoint` only ever
|
|
463
|
+
* pass indices derived from `points.length`, so this should never actually
|
|
464
|
+
* throw — it exists because `noUncheckedIndexedAccess` can't prove that from
|
|
465
|
+
* the loop bounds alone, and a loud failure here is far easier to diagnose
|
|
466
|
+
* than silently corrupted coordinates.
|
|
467
|
+
*/
|
|
468
|
+
function requirePoint(points: Point[], index: number): Point {
|
|
469
|
+
const point = points[index]
|
|
470
|
+
if (!point) {
|
|
471
|
+
// Unreachable — both call sites in edgeMidpoint only ever pass indices
|
|
472
|
+
// in [0, points.length - 1].
|
|
473
|
+
/* v8 ignore next */
|
|
474
|
+
throw new Error(`edgeMidpoint: missing point at index ${index}`)
|
|
475
|
+
}
|
|
476
|
+
return point
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Get the midpoint of a polyline (by walking segments) */
|
|
480
|
+
function edgeMidpoint(points: Point[]): Point {
|
|
481
|
+
if (points.length === 0) return { x: 0, y: 0 }
|
|
482
|
+
if (points.length === 1) return points[0]!
|
|
483
|
+
|
|
484
|
+
// Calculate total length
|
|
485
|
+
let totalLength = 0
|
|
486
|
+
for (let i = 1; i < points.length; i++) {
|
|
487
|
+
totalLength += dist(requirePoint(points, i - 1), requirePoint(points, i))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Walk to the halfway point
|
|
491
|
+
let remaining = totalLength / 2
|
|
492
|
+
for (let i = 1; i < points.length; i++) {
|
|
493
|
+
const prev = requirePoint(points, i - 1)
|
|
494
|
+
const curr = requirePoint(points, i)
|
|
495
|
+
const segLen = dist(prev, curr)
|
|
496
|
+
if (remaining <= segLen) {
|
|
497
|
+
const t = remaining / segLen
|
|
498
|
+
return {
|
|
499
|
+
x: prev.x + t * (curr.x - prev.x),
|
|
500
|
+
y: prev.y + t * (curr.y - prev.y),
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
remaining -= segLen
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return points[points.length - 1]!
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function dist(a: Point, b: Point): number {
|
|
510
|
+
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ============================================================================
|
|
514
|
+
// Node rendering
|
|
515
|
+
// ============================================================================
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Render a complete node: shape + label wrapped in a semantic <g> element.
|
|
519
|
+
*
|
|
520
|
+
* The group includes data attributes for:
|
|
521
|
+
* - data-id: original Mermaid node ID (for edge matching)
|
|
522
|
+
* - data-label: display label text
|
|
523
|
+
* - data-shape: shape type (rectangle, diamond, circle, etc.)
|
|
524
|
+
*
|
|
525
|
+
* @param linksEnabled - Whether a `click`-based `<a href>` link and `<title>`
|
|
526
|
+
* tooltip render (from `options.interactivity !==
|
|
527
|
+
* 'none'`). Default true.
|
|
528
|
+
*/
|
|
529
|
+
function renderNode(
|
|
530
|
+
node: PositionedNode,
|
|
531
|
+
font: string,
|
|
532
|
+
fontSizes: FontSizes,
|
|
533
|
+
linksEnabled: boolean = true,
|
|
534
|
+
): string {
|
|
535
|
+
const shape = renderNodeShape(node)
|
|
536
|
+
const label = renderNodeLabel(node, font, fontSizes)
|
|
537
|
+
|
|
538
|
+
// Combine shape and label inside a semantic group
|
|
539
|
+
// This enables reliable node identification without heuristics
|
|
540
|
+
const parts: string[] = []
|
|
541
|
+
const safeClassName = sanitizeClassName(node.className)
|
|
542
|
+
const classAttr = safeClassName ? `node ${safeClassName}` : 'node'
|
|
543
|
+
|
|
544
|
+
const interaction = node.interaction
|
|
545
|
+
const groupAttrs = [
|
|
546
|
+
`class="${classAttr}"`,
|
|
547
|
+
`data-id="${escapeAttr(node.id)}"`,
|
|
548
|
+
`data-label="${escapeAttr(node.label)}"`,
|
|
549
|
+
`data-shape="${node.shape}"`,
|
|
550
|
+
]
|
|
551
|
+
/*
|
|
552
|
+
* `click A call fn()` is deliberately absent from the markup. This renderer
|
|
553
|
+
* produces a static SVG string and executes nothing a diagram supplies —
|
|
554
|
+
* running diagram-authored script would make every rendered diagram an
|
|
555
|
+
* execution vector. The binding is exposed as data instead, on the
|
|
556
|
+
* `interactions` map `parseMermaid()` returns, and the `data-id` attribute
|
|
557
|
+
* above is the hook a host binds it to. The inert `data-click-callback`
|
|
558
|
+
* attribute once emitted here was removed in #216 — see
|
|
559
|
+
* docs/decisions/no-script-interactivity.md.
|
|
560
|
+
*/
|
|
561
|
+
parts.push(`<g ${groupAttrs.join(' ')}>`)
|
|
562
|
+
|
|
563
|
+
// An href becomes a real SVG link, which needs no script to work.
|
|
564
|
+
// `interactivity: 'none'` strips it — a link is meaningless in
|
|
565
|
+
// print/rasterized output, the target that level is meant for.
|
|
566
|
+
const href = linksEnabled ? safeHref(interaction?.href) : undefined
|
|
567
|
+
const indent = href ? ' ' : ' '
|
|
568
|
+
if (href) {
|
|
569
|
+
const targetAttr = interaction?.target
|
|
570
|
+
? ` target="${escapeAttr(interaction.target)}"`
|
|
571
|
+
: ''
|
|
572
|
+
parts.push(` <a href="${escapeAttr(href)}"${targetAttr}>`)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (linksEnabled && interaction?.tooltip) {
|
|
576
|
+
// <title> is SVG's native tooltip — no script, no CSS. Gated by
|
|
577
|
+
// linksEnabled alongside href above: both come from the same `click`
|
|
578
|
+
// statement, and 'none' strips both.
|
|
579
|
+
parts.push(`${indent}<title>${escapeXml(interaction.tooltip)}</title>`)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
parts.push(`${indent}${shape.replace(/\n/g, `\n${indent}`)}`)
|
|
583
|
+
if (label) {
|
|
584
|
+
parts.push(`${indent}${label.replace(/\n/g, `\n${indent}`)}`)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (href) parts.push(' </a>')
|
|
588
|
+
parts.push('</g>')
|
|
589
|
+
|
|
590
|
+
return parts.join('\n')
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function renderNodeShape(node: PositionedNode): string {
|
|
594
|
+
const { x, y, width, height, shape, inlineStyle } = node
|
|
595
|
+
|
|
596
|
+
// Resolve fill and stroke — inline styles (from mermaid `style` directives)
|
|
597
|
+
// override the CSS variable defaults. When no inline style is present, the
|
|
598
|
+
// CSS variable handles theming automatically via color-mix() derivation.
|
|
599
|
+
const fill = escapeAttr(inlineStyle?.fill ?? 'var(--_node-fill)')
|
|
600
|
+
const stroke = escapeAttr(inlineStyle?.stroke ?? 'var(--_node-stroke)')
|
|
601
|
+
const sw = escapeAttr(
|
|
602
|
+
inlineStyle?.['stroke-width'] ?? String(STROKE_WIDTHS.innerBox),
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
switch (shape) {
|
|
606
|
+
case 'diamond':
|
|
607
|
+
return renderDiamond(x, y, width, height, fill, stroke, sw)
|
|
608
|
+
case 'rounded':
|
|
609
|
+
return renderRoundedRect(x, y, width, height, fill, stroke, sw)
|
|
610
|
+
case 'stadium':
|
|
611
|
+
return renderStadium(x, y, width, height, fill, stroke, sw)
|
|
612
|
+
case 'circle':
|
|
613
|
+
return renderCircle(x, y, width, height, fill, stroke, sw)
|
|
614
|
+
case 'subroutine':
|
|
615
|
+
return renderSubroutine(x, y, width, height, fill, stroke, sw)
|
|
616
|
+
case 'doublecircle':
|
|
617
|
+
return renderDoubleCircle(x, y, width, height, fill, stroke, sw)
|
|
618
|
+
case 'hexagon':
|
|
619
|
+
return renderHexagon(x, y, width, height, fill, stroke, sw)
|
|
620
|
+
case 'cylinder':
|
|
621
|
+
return renderCylinder(x, y, width, height, fill, stroke, sw)
|
|
622
|
+
case 'asymmetric':
|
|
623
|
+
return renderAsymmetric(x, y, width, height, fill, stroke, sw)
|
|
624
|
+
case 'trapezoid':
|
|
625
|
+
return renderTrapezoid(x, y, width, height, fill, stroke, sw)
|
|
626
|
+
case 'trapezoid-alt':
|
|
627
|
+
return renderTrapezoidAlt(x, y, width, height, fill, stroke, sw)
|
|
628
|
+
case 'parallelogram':
|
|
629
|
+
return renderParallelogram(x, y, width, height, fill, stroke, sw)
|
|
630
|
+
case 'parallelogram-alt':
|
|
631
|
+
return renderParallelogramAlt(x, y, width, height, fill, stroke, sw)
|
|
632
|
+
case 'state-start':
|
|
633
|
+
return renderStateStart(x, y, width, height)
|
|
634
|
+
case 'state-end':
|
|
635
|
+
return renderStateEnd(x, y, width, height)
|
|
636
|
+
|
|
637
|
+
// --- Expanded-syntax shapes (`A@{ shape: ... }`) ---
|
|
638
|
+
case 'document':
|
|
639
|
+
return renderDocument(x, y, width, height, fill, stroke, sw)
|
|
640
|
+
case 'stacked-document':
|
|
641
|
+
return renderStacked(x, y, width, height, fill, stroke, sw, true)
|
|
642
|
+
case 'stacked-process':
|
|
643
|
+
return renderStacked(x, y, width, height, fill, stroke, sw, false)
|
|
644
|
+
case 'card':
|
|
645
|
+
return renderCard(x, y, width, height, fill, stroke, sw)
|
|
646
|
+
case 'lined-process':
|
|
647
|
+
return renderLinedProcess(x, y, width, height, fill, stroke, sw)
|
|
648
|
+
case 'divided-process':
|
|
649
|
+
return renderDividedProcess(x, y, width, height, fill, stroke, sw)
|
|
650
|
+
case 'window-pane':
|
|
651
|
+
return renderWindowPane(x, y, width, height, fill, stroke, sw)
|
|
652
|
+
case 'triangle':
|
|
653
|
+
return renderTriangle(x, y, width, height, fill, stroke, sw, false)
|
|
654
|
+
case 'flipped-triangle':
|
|
655
|
+
return renderTriangle(x, y, width, height, fill, stroke, sw, true)
|
|
656
|
+
case 'filled-circle':
|
|
657
|
+
return renderFilledCircle(x, y, width, height, stroke)
|
|
658
|
+
case 'crossed-circle':
|
|
659
|
+
return renderCrossedCircle(x, y, width, height, fill, stroke, sw)
|
|
660
|
+
case 'fork-join':
|
|
661
|
+
return renderForkJoin(x, y, width, height, stroke)
|
|
662
|
+
case 'notched-pentagon':
|
|
663
|
+
return renderNotchedPentagon(x, y, width, height, fill, stroke, sw)
|
|
664
|
+
case 'sloped-rectangle':
|
|
665
|
+
return renderSlopedRectangle(x, y, width, height, fill, stroke, sw)
|
|
666
|
+
case 'flag':
|
|
667
|
+
return renderFlag(x, y, width, height, fill, stroke, sw)
|
|
668
|
+
case 'bow-tie-rectangle':
|
|
669
|
+
return renderBowTie(x, y, width, height, fill, stroke, sw)
|
|
670
|
+
case 'half-rounded-rectangle':
|
|
671
|
+
return renderHalfRounded(x, y, width, height, fill, stroke, sw)
|
|
672
|
+
case 'brace':
|
|
673
|
+
return renderBraces(x, y, width, height, fill, stroke, sw, 'left')
|
|
674
|
+
case 'brace-right':
|
|
675
|
+
return renderBraces(x, y, width, height, fill, stroke, sw, 'right')
|
|
676
|
+
case 'braces':
|
|
677
|
+
return renderBraces(x, y, width, height, fill, stroke, sw, 'both')
|
|
678
|
+
case 'bolt':
|
|
679
|
+
return renderBolt(x, y, width, height, fill, stroke, sw)
|
|
680
|
+
case 'text':
|
|
681
|
+
case 'anchor':
|
|
682
|
+
// No outline — the label (rendered separately) is the whole node.
|
|
683
|
+
return ''
|
|
684
|
+
|
|
685
|
+
case 'rectangle':
|
|
686
|
+
default:
|
|
687
|
+
return renderRect(x, y, width, height, fill, stroke, sw)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- Basic shapes ---
|
|
692
|
+
|
|
693
|
+
function renderRect(
|
|
694
|
+
x: number,
|
|
695
|
+
y: number,
|
|
696
|
+
w: number,
|
|
697
|
+
h: number,
|
|
698
|
+
fill: string,
|
|
699
|
+
stroke: string,
|
|
700
|
+
sw: string,
|
|
701
|
+
): string {
|
|
702
|
+
return (
|
|
703
|
+
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
|
|
704
|
+
`rx="0" ry="0" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function renderRoundedRect(
|
|
709
|
+
x: number,
|
|
710
|
+
y: number,
|
|
711
|
+
w: number,
|
|
712
|
+
h: number,
|
|
713
|
+
fill: string,
|
|
714
|
+
stroke: string,
|
|
715
|
+
sw: string,
|
|
716
|
+
): string {
|
|
717
|
+
return (
|
|
718
|
+
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
|
|
719
|
+
`rx="6" ry="6" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
720
|
+
)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function renderStadium(
|
|
724
|
+
x: number,
|
|
725
|
+
y: number,
|
|
726
|
+
w: number,
|
|
727
|
+
h: number,
|
|
728
|
+
fill: string,
|
|
729
|
+
stroke: string,
|
|
730
|
+
sw: string,
|
|
731
|
+
): string {
|
|
732
|
+
const r = h / 2
|
|
733
|
+
return (
|
|
734
|
+
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
|
|
735
|
+
`rx="${r}" ry="${r}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function renderCircle(
|
|
740
|
+
x: number,
|
|
741
|
+
y: number,
|
|
742
|
+
w: number,
|
|
743
|
+
h: number,
|
|
744
|
+
fill: string,
|
|
745
|
+
stroke: string,
|
|
746
|
+
sw: string,
|
|
747
|
+
): string {
|
|
748
|
+
const cx = x + w / 2
|
|
749
|
+
const cy = y + h / 2
|
|
750
|
+
const r = Math.min(w, h) / 2
|
|
751
|
+
return (
|
|
752
|
+
`<circle cx="${cx}" cy="${cy}" r="${r}" ` +
|
|
753
|
+
`fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
754
|
+
)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function renderDiamond(
|
|
758
|
+
x: number,
|
|
759
|
+
y: number,
|
|
760
|
+
w: number,
|
|
761
|
+
h: number,
|
|
762
|
+
fill: string,
|
|
763
|
+
stroke: string,
|
|
764
|
+
sw: string,
|
|
765
|
+
): string {
|
|
766
|
+
const cx = x + w / 2
|
|
767
|
+
const cy = y + h / 2
|
|
768
|
+
const hw = w / 2
|
|
769
|
+
const hh = h / 2
|
|
770
|
+
const points = [
|
|
771
|
+
`${cx},${cy - hh}`, // top
|
|
772
|
+
`${cx + hw},${cy}`, // right
|
|
773
|
+
`${cx},${cy + hh}`, // bottom
|
|
774
|
+
`${cx - hw},${cy}`, // left
|
|
775
|
+
].join(' ')
|
|
776
|
+
|
|
777
|
+
return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// --- Batch 1 shapes ---
|
|
781
|
+
|
|
782
|
+
/** Subroutine: rectangle with double vertical borders on left and right */
|
|
783
|
+
function renderSubroutine(
|
|
784
|
+
x: number,
|
|
785
|
+
y: number,
|
|
786
|
+
w: number,
|
|
787
|
+
h: number,
|
|
788
|
+
fill: string,
|
|
789
|
+
stroke: string,
|
|
790
|
+
sw: string,
|
|
791
|
+
): string {
|
|
792
|
+
const inset = 8 // distance from edge to inner vertical line
|
|
793
|
+
return (
|
|
794
|
+
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
|
|
795
|
+
`rx="0" ry="0" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />` +
|
|
796
|
+
`\n<line x1="${x + inset}" y1="${y}" x2="${x + inset}" y2="${y + h}" ` +
|
|
797
|
+
`stroke="${stroke}" stroke-width="${sw}" />` +
|
|
798
|
+
`\n<line x1="${x + w - inset}" y1="${y}" x2="${x + w - inset}" y2="${y + h}" ` +
|
|
799
|
+
`stroke="${stroke}" stroke-width="${sw}" />`
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Double circle: two concentric circles with a gap between them */
|
|
804
|
+
function renderDoubleCircle(
|
|
805
|
+
x: number,
|
|
806
|
+
y: number,
|
|
807
|
+
w: number,
|
|
808
|
+
h: number,
|
|
809
|
+
fill: string,
|
|
810
|
+
stroke: string,
|
|
811
|
+
sw: string,
|
|
812
|
+
): string {
|
|
813
|
+
const cx = x + w / 2
|
|
814
|
+
const cy = y + h / 2
|
|
815
|
+
const outerR = Math.min(w, h) / 2
|
|
816
|
+
const innerR = outerR - 5 // 5px gap between rings
|
|
817
|
+
return (
|
|
818
|
+
`<circle cx="${cx}" cy="${cy}" r="${outerR}" ` +
|
|
819
|
+
`fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />` +
|
|
820
|
+
`\n<circle cx="${cx}" cy="${cy}" r="${innerR}" ` +
|
|
821
|
+
`fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
822
|
+
)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Hexagon: 6-point polygon with flat top/bottom and angled sides */
|
|
826
|
+
function renderHexagon(
|
|
827
|
+
x: number,
|
|
828
|
+
y: number,
|
|
829
|
+
w: number,
|
|
830
|
+
h: number,
|
|
831
|
+
fill: string,
|
|
832
|
+
stroke: string,
|
|
833
|
+
sw: string,
|
|
834
|
+
): string {
|
|
835
|
+
const inset = h / 4 // horizontal inset for the angled sides
|
|
836
|
+
const points = [
|
|
837
|
+
`${x + inset},${y}`, // top-left
|
|
838
|
+
`${x + w - inset},${y}`, // top-right
|
|
839
|
+
`${x + w},${y + h / 2}`, // mid-right
|
|
840
|
+
`${x + w - inset},${y + h}`, // bottom-right
|
|
841
|
+
`${x + inset},${y + h}`, // bottom-left
|
|
842
|
+
`${x},${y + h / 2}`, // mid-left
|
|
843
|
+
].join(' ')
|
|
844
|
+
|
|
845
|
+
return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// --- Batch 2 shapes ---
|
|
849
|
+
|
|
850
|
+
/** Cylinder / database: top ellipse cap + body rect + bottom ellipse */
|
|
851
|
+
function renderCylinder(
|
|
852
|
+
x: number,
|
|
853
|
+
y: number,
|
|
854
|
+
w: number,
|
|
855
|
+
h: number,
|
|
856
|
+
fill: string,
|
|
857
|
+
stroke: string,
|
|
858
|
+
sw: string,
|
|
859
|
+
): string {
|
|
860
|
+
const ry = 7 // ellipse vertical radius for the cap
|
|
861
|
+
const cx = x + w / 2
|
|
862
|
+
const bodyTop = y + ry
|
|
863
|
+
const bodyH = h - 2 * ry
|
|
864
|
+
|
|
865
|
+
return (
|
|
866
|
+
// Body rectangle (no top border — covered by top ellipse)
|
|
867
|
+
`<rect x="${x}" y="${bodyTop}" width="${w}" height="${bodyH}" ` +
|
|
868
|
+
`fill="${fill}" stroke="none" />` +
|
|
869
|
+
// Left and right body borders
|
|
870
|
+
`\n<line x1="${x}" y1="${bodyTop}" x2="${x}" y2="${bodyTop + bodyH}" stroke="${stroke}" stroke-width="${sw}" />` +
|
|
871
|
+
`\n<line x1="${x + w}" y1="${bodyTop}" x2="${x + w}" y2="${bodyTop + bodyH}" stroke="${stroke}" stroke-width="${sw}" />` +
|
|
872
|
+
// Bottom ellipse (half visible)
|
|
873
|
+
`\n<ellipse cx="${cx}" cy="${y + h - ry}" rx="${w / 2}" ry="${ry}" ` +
|
|
874
|
+
`fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />` +
|
|
875
|
+
// Top ellipse (full, on top)
|
|
876
|
+
`\n<ellipse cx="${cx}" cy="${bodyTop}" rx="${w / 2}" ry="${ry}" ` +
|
|
877
|
+
`fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
878
|
+
)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Asymmetric / flag: rectangle with a pointed left edge */
|
|
882
|
+
function renderAsymmetric(
|
|
883
|
+
x: number,
|
|
884
|
+
y: number,
|
|
885
|
+
w: number,
|
|
886
|
+
h: number,
|
|
887
|
+
fill: string,
|
|
888
|
+
stroke: string,
|
|
889
|
+
sw: string,
|
|
890
|
+
): string {
|
|
891
|
+
const indent = 12 // how far the point indents
|
|
892
|
+
const points = [
|
|
893
|
+
`${x + indent},${y}`, // top-left (indented)
|
|
894
|
+
`${x + w},${y}`, // top-right
|
|
895
|
+
`${x + w},${y + h}`, // bottom-right
|
|
896
|
+
`${x + indent},${y + h}`, // bottom-left (indented)
|
|
897
|
+
`${x},${y + h / 2}`, // left point
|
|
898
|
+
].join(' ')
|
|
899
|
+
|
|
900
|
+
return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Trapezoid [/text\]: wider bottom, narrower top */
|
|
904
|
+
function renderTrapezoid(
|
|
905
|
+
x: number,
|
|
906
|
+
y: number,
|
|
907
|
+
w: number,
|
|
908
|
+
h: number,
|
|
909
|
+
fill: string,
|
|
910
|
+
stroke: string,
|
|
911
|
+
sw: string,
|
|
912
|
+
): string {
|
|
913
|
+
const inset = w * 0.15 // top edge is narrower by this amount on each side
|
|
914
|
+
const points = [
|
|
915
|
+
`${x + inset},${y}`, // top-left (indented)
|
|
916
|
+
`${x + w - inset},${y}`, // top-right (indented)
|
|
917
|
+
`${x + w},${y + h}`, // bottom-right (full width)
|
|
918
|
+
`${x},${y + h}`, // bottom-left (full width)
|
|
919
|
+
].join(' ')
|
|
920
|
+
|
|
921
|
+
return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Trapezoid-alt [\text/]: wider top, narrower bottom */
|
|
925
|
+
function renderTrapezoidAlt(
|
|
926
|
+
x: number,
|
|
927
|
+
y: number,
|
|
928
|
+
w: number,
|
|
929
|
+
h: number,
|
|
930
|
+
fill: string,
|
|
931
|
+
stroke: string,
|
|
932
|
+
sw: string,
|
|
933
|
+
): string {
|
|
934
|
+
const inset = w * 0.15 // bottom edge is narrower
|
|
935
|
+
const points = [
|
|
936
|
+
`${x},${y}`, // top-left (full width)
|
|
937
|
+
`${x + w},${y}`, // top-right (full width)
|
|
938
|
+
`${x + w - inset},${y + h}`, // bottom-right (indented)
|
|
939
|
+
`${x + inset},${y + h}`, // bottom-left (indented)
|
|
940
|
+
].join(' ')
|
|
941
|
+
|
|
942
|
+
return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Parallelogram [/text/]: leans right.
|
|
947
|
+
*
|
|
948
|
+
* Unlike the trapezoids, both sloped sides run the same direction — the top
|
|
949
|
+
* edge shifts right by `inset` and the bottom edge shifts left by the same
|
|
950
|
+
* amount, so opposite sides stay parallel.
|
|
951
|
+
*/
|
|
952
|
+
function renderParallelogram(
|
|
953
|
+
x: number,
|
|
954
|
+
y: number,
|
|
955
|
+
w: number,
|
|
956
|
+
h: number,
|
|
957
|
+
fill: string,
|
|
958
|
+
stroke: string,
|
|
959
|
+
sw: string,
|
|
960
|
+
): string {
|
|
961
|
+
const inset = w * 0.15
|
|
962
|
+
const points = [
|
|
963
|
+
`${x + inset},${y}`, // top-left (shifted right)
|
|
964
|
+
`${x + w},${y}`, // top-right
|
|
965
|
+
`${x + w - inset},${y + h}`, // bottom-right (shifted left)
|
|
966
|
+
`${x},${y + h}`, // bottom-left
|
|
967
|
+
].join(' ')
|
|
968
|
+
|
|
969
|
+
return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Parallelogram-alt [\text\]: leans left — the mirror of renderParallelogram. */
|
|
973
|
+
function renderParallelogramAlt(
|
|
974
|
+
x: number,
|
|
975
|
+
y: number,
|
|
976
|
+
w: number,
|
|
977
|
+
h: number,
|
|
978
|
+
fill: string,
|
|
979
|
+
stroke: string,
|
|
980
|
+
sw: string,
|
|
981
|
+
): string {
|
|
982
|
+
const inset = w * 0.15
|
|
983
|
+
const points = [
|
|
984
|
+
`${x},${y}`, // top-left
|
|
985
|
+
`${x + w - inset},${y}`, // top-right (shifted left)
|
|
986
|
+
`${x + w},${y + h}`, // bottom-right
|
|
987
|
+
`${x + inset},${y + h}`, // bottom-left (shifted right)
|
|
988
|
+
].join(' ')
|
|
989
|
+
|
|
990
|
+
return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// --- Expanded-syntax shapes (`A@{ shape: ... }`) ---
|
|
994
|
+
//
|
|
995
|
+
// Each of these is reachable only through the expanded metadata syntax; the
|
|
996
|
+
// classic bracket forms have no spelling for them. See
|
|
997
|
+
// packages/mermaid-parser/src/expanded-shapes.ts for the semantic-name →
|
|
998
|
+
// geometry alias table.
|
|
999
|
+
|
|
1000
|
+
/** Shared attribute string for a filled, stroked path. */
|
|
1001
|
+
function shapeAttrs(fill: string, stroke: string, sw: string): string {
|
|
1002
|
+
return `fill="${fill}" stroke="${stroke}" stroke-width="${sw}"`
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Document: rectangle with a wavy bottom edge.
|
|
1007
|
+
*
|
|
1008
|
+
* The wave is two cubic segments — down then up — so the edge returns to its
|
|
1009
|
+
* starting height at the right corner and the shape tiles cleanly when
|
|
1010
|
+
* stacked (see renderStacked).
|
|
1011
|
+
*/
|
|
1012
|
+
function renderDocument(
|
|
1013
|
+
x: number,
|
|
1014
|
+
y: number,
|
|
1015
|
+
w: number,
|
|
1016
|
+
h: number,
|
|
1017
|
+
fill: string,
|
|
1018
|
+
stroke: string,
|
|
1019
|
+
sw: string,
|
|
1020
|
+
): string {
|
|
1021
|
+
const waveH = Math.min(10, h * 0.16)
|
|
1022
|
+
const base = y + h - waveH
|
|
1023
|
+
// Both ends land on `base`, so the left and right sides are the same
|
|
1024
|
+
// height. Ending the curve anywhere else makes every document node
|
|
1025
|
+
// visibly lopsided.
|
|
1026
|
+
const d =
|
|
1027
|
+
`M ${x} ${y} L ${x + w} ${y} L ${x + w} ${base} ` +
|
|
1028
|
+
`C ${x + w * 0.75} ${base + waveH * 1.8} ${x + w * 0.25} ${base - waveH * 0.8} ${x} ${base} Z`
|
|
1029
|
+
return `<path d="${d}" ${shapeAttrs(fill, stroke, sw)} />`
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** Stacked document / process: offset copies behind the front shape. */
|
|
1033
|
+
function renderStacked(
|
|
1034
|
+
x: number,
|
|
1035
|
+
y: number,
|
|
1036
|
+
w: number,
|
|
1037
|
+
h: number,
|
|
1038
|
+
fill: string,
|
|
1039
|
+
stroke: string,
|
|
1040
|
+
sw: string,
|
|
1041
|
+
isDocument: boolean,
|
|
1042
|
+
): string {
|
|
1043
|
+
const offset = 5
|
|
1044
|
+
const frontW = w - offset * 2
|
|
1045
|
+
const frontH = h - offset * 2
|
|
1046
|
+
const parts: string[] = []
|
|
1047
|
+
|
|
1048
|
+
// Two offset copies behind, back to front.
|
|
1049
|
+
for (let i = 2; i >= 1; i--) {
|
|
1050
|
+
parts.push(
|
|
1051
|
+
`<rect x="${x + offset * i}" y="${y + offset * (2 - i)}" width="${frontW}" height="${frontH}" ` +
|
|
1052
|
+
`${shapeAttrs(fill, stroke, sw)} />`,
|
|
1053
|
+
)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
parts.push(
|
|
1057
|
+
isDocument
|
|
1058
|
+
? renderDocument(x, y + offset * 2, frontW, frontH, fill, stroke, sw)
|
|
1059
|
+
: `<rect x="${x}" y="${y + offset * 2}" width="${frontW}" height="${frontH}" ${shapeAttrs(fill, stroke, sw)} />`,
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
return parts.join('\n')
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** Card / notched rectangle: top-left corner clipped. */
|
|
1066
|
+
function renderCard(
|
|
1067
|
+
x: number,
|
|
1068
|
+
y: number,
|
|
1069
|
+
w: number,
|
|
1070
|
+
h: number,
|
|
1071
|
+
fill: string,
|
|
1072
|
+
stroke: string,
|
|
1073
|
+
sw: string,
|
|
1074
|
+
): string {
|
|
1075
|
+
const notch = Math.min(14, w * 0.12, h * 0.3)
|
|
1076
|
+
const points = [
|
|
1077
|
+
`${x + notch},${y}`,
|
|
1078
|
+
`${x + w},${y}`,
|
|
1079
|
+
`${x + w},${y + h}`,
|
|
1080
|
+
`${x},${y + h}`,
|
|
1081
|
+
`${x},${y + notch}`,
|
|
1082
|
+
].join(' ')
|
|
1083
|
+
return `<polygon points="${points}" ${shapeAttrs(fill, stroke, sw)} />`
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Lined process: rectangle with a vertical rule inset from the left edge. */
|
|
1087
|
+
function renderLinedProcess(
|
|
1088
|
+
x: number,
|
|
1089
|
+
y: number,
|
|
1090
|
+
w: number,
|
|
1091
|
+
h: number,
|
|
1092
|
+
fill: string,
|
|
1093
|
+
stroke: string,
|
|
1094
|
+
sw: string,
|
|
1095
|
+
): string {
|
|
1096
|
+
const inset = Math.min(12, w * 0.12)
|
|
1097
|
+
return (
|
|
1098
|
+
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ${shapeAttrs(fill, stroke, sw)} />\n` +
|
|
1099
|
+
`<line x1="${x + inset}" y1="${y}" x2="${x + inset}" y2="${y + h}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
1100
|
+
)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Divided process: rectangle split by a horizontal rule near the top. */
|
|
1104
|
+
function renderDividedProcess(
|
|
1105
|
+
x: number,
|
|
1106
|
+
y: number,
|
|
1107
|
+
w: number,
|
|
1108
|
+
h: number,
|
|
1109
|
+
fill: string,
|
|
1110
|
+
stroke: string,
|
|
1111
|
+
sw: string,
|
|
1112
|
+
): string {
|
|
1113
|
+
const split = y + Math.min(16, h * 0.3)
|
|
1114
|
+
return (
|
|
1115
|
+
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ${shapeAttrs(fill, stroke, sw)} />\n` +
|
|
1116
|
+
`<line x1="${x}" y1="${split}" x2="${x + w}" y2="${split}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
1117
|
+
)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** Window pane / internal storage: rectangle quartered by a cross. */
|
|
1121
|
+
function renderWindowPane(
|
|
1122
|
+
x: number,
|
|
1123
|
+
y: number,
|
|
1124
|
+
w: number,
|
|
1125
|
+
h: number,
|
|
1126
|
+
fill: string,
|
|
1127
|
+
stroke: string,
|
|
1128
|
+
sw: string,
|
|
1129
|
+
): string {
|
|
1130
|
+
const vx = x + Math.min(16, w * 0.16)
|
|
1131
|
+
const hy = y + Math.min(14, h * 0.28)
|
|
1132
|
+
return (
|
|
1133
|
+
`<rect x="${x}" y="${y}" width="${w}" height="${h}" ${shapeAttrs(fill, stroke, sw)} />\n` +
|
|
1134
|
+
`<line x1="${vx}" y1="${y}" x2="${vx}" y2="${y + h}" stroke="${stroke}" stroke-width="${sw}" />\n` +
|
|
1135
|
+
`<line x1="${x}" y1="${hy}" x2="${x + w}" y2="${hy}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
1136
|
+
)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** Triangle, apex up (extract) or apex down (manual file). */
|
|
1140
|
+
function renderTriangle(
|
|
1141
|
+
x: number,
|
|
1142
|
+
y: number,
|
|
1143
|
+
w: number,
|
|
1144
|
+
h: number,
|
|
1145
|
+
fill: string,
|
|
1146
|
+
stroke: string,
|
|
1147
|
+
sw: string,
|
|
1148
|
+
flipped: boolean,
|
|
1149
|
+
): string {
|
|
1150
|
+
const points = flipped
|
|
1151
|
+
? [`${x},${y}`, `${x + w},${y}`, `${x + w / 2},${y + h}`]
|
|
1152
|
+
: [`${x + w / 2},${y}`, `${x + w},${y + h}`, `${x},${y + h}`]
|
|
1153
|
+
return `<polygon points="${points.join(' ')}" ${shapeAttrs(fill, stroke, sw)} />`
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** Filled circle (junction): a solid dot, so it takes the stroke color as fill. */
|
|
1157
|
+
function renderFilledCircle(
|
|
1158
|
+
x: number,
|
|
1159
|
+
y: number,
|
|
1160
|
+
w: number,
|
|
1161
|
+
h: number,
|
|
1162
|
+
stroke: string,
|
|
1163
|
+
): string {
|
|
1164
|
+
const r = Math.min(w, h) / 2 - 2
|
|
1165
|
+
return `<circle cx="${x + w / 2}" cy="${y + h / 2}" r="${r}" fill="${stroke}" stroke="${stroke}" />`
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/** Crossed circle (summary): circle with an X through it. */
|
|
1169
|
+
function renderCrossedCircle(
|
|
1170
|
+
x: number,
|
|
1171
|
+
y: number,
|
|
1172
|
+
w: number,
|
|
1173
|
+
h: number,
|
|
1174
|
+
fill: string,
|
|
1175
|
+
stroke: string,
|
|
1176
|
+
sw: string,
|
|
1177
|
+
): string {
|
|
1178
|
+
const cx = x + w / 2
|
|
1179
|
+
const cy = y + h / 2
|
|
1180
|
+
const r = Math.min(w, h) / 2 - 2
|
|
1181
|
+
// Cross arms meet the circumference at 45°, so offset by r/√2.
|
|
1182
|
+
const d = r / Math.SQRT2
|
|
1183
|
+
return (
|
|
1184
|
+
`<circle cx="${cx}" cy="${cy}" r="${r}" ${shapeAttrs(fill, stroke, sw)} />\n` +
|
|
1185
|
+
`<line x1="${cx - d}" y1="${cy - d}" x2="${cx + d}" y2="${cy + d}" stroke="${stroke}" stroke-width="${sw}" />\n` +
|
|
1186
|
+
`<line x1="${cx + d}" y1="${cy - d}" x2="${cx - d}" y2="${cy + d}" stroke="${stroke}" stroke-width="${sw}" />`
|
|
1187
|
+
)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Fork/join: a solid bar, drawn in the stroke color. */
|
|
1191
|
+
function renderForkJoin(
|
|
1192
|
+
x: number,
|
|
1193
|
+
y: number,
|
|
1194
|
+
w: number,
|
|
1195
|
+
h: number,
|
|
1196
|
+
stroke: string,
|
|
1197
|
+
): string {
|
|
1198
|
+
const barH = Math.min(8, h)
|
|
1199
|
+
return `<rect x="${x}" y="${y + (h - barH) / 2}" width="${w}" height="${barH}" fill="${stroke}" stroke="${stroke}" />`
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** Notched pentagon (loop limit): both top corners clipped. */
|
|
1203
|
+
function renderNotchedPentagon(
|
|
1204
|
+
x: number,
|
|
1205
|
+
y: number,
|
|
1206
|
+
w: number,
|
|
1207
|
+
h: number,
|
|
1208
|
+
fill: string,
|
|
1209
|
+
stroke: string,
|
|
1210
|
+
sw: string,
|
|
1211
|
+
): string {
|
|
1212
|
+
const notch = Math.min(14, w * 0.12, h * 0.3)
|
|
1213
|
+
const points = [
|
|
1214
|
+
`${x + notch},${y}`,
|
|
1215
|
+
`${x + w - notch},${y}`,
|
|
1216
|
+
`${x + w},${y + notch}`,
|
|
1217
|
+
`${x + w},${y + h}`,
|
|
1218
|
+
`${x},${y + h}`,
|
|
1219
|
+
`${x},${y + notch}`,
|
|
1220
|
+
].join(' ')
|
|
1221
|
+
return `<polygon points="${points}" ${shapeAttrs(fill, stroke, sw)} />`
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** Sloped rectangle (manual input): top edge slopes up to the right. */
|
|
1225
|
+
function renderSlopedRectangle(
|
|
1226
|
+
x: number,
|
|
1227
|
+
y: number,
|
|
1228
|
+
w: number,
|
|
1229
|
+
h: number,
|
|
1230
|
+
fill: string,
|
|
1231
|
+
stroke: string,
|
|
1232
|
+
sw: string,
|
|
1233
|
+
): string {
|
|
1234
|
+
const slope = Math.min(12, h * 0.3)
|
|
1235
|
+
const points = [
|
|
1236
|
+
`${x},${y + slope}`,
|
|
1237
|
+
`${x + w},${y}`,
|
|
1238
|
+
`${x + w},${y + h}`,
|
|
1239
|
+
`${x},${y + h}`,
|
|
1240
|
+
].join(' ')
|
|
1241
|
+
return `<polygon points="${points}" ${shapeAttrs(fill, stroke, sw)} />`
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** Flag / paper tape: wavy top and bottom edges. */
|
|
1245
|
+
function renderFlag(
|
|
1246
|
+
x: number,
|
|
1247
|
+
y: number,
|
|
1248
|
+
w: number,
|
|
1249
|
+
h: number,
|
|
1250
|
+
fill: string,
|
|
1251
|
+
stroke: string,
|
|
1252
|
+
sw: string,
|
|
1253
|
+
): string {
|
|
1254
|
+
const waveH = Math.min(8, h * 0.14)
|
|
1255
|
+
const d =
|
|
1256
|
+
`M ${x} ${y + waveH} ` +
|
|
1257
|
+
`C ${x + w * 0.25} ${y - waveH} ${x + w * 0.75} ${y + waveH * 2} ${x + w} ${y + waveH} ` +
|
|
1258
|
+
`L ${x + w} ${y + h - waveH} ` +
|
|
1259
|
+
`C ${x + w * 0.75} ${y + h + waveH} ${x + w * 0.25} ${y + h - waveH * 2} ${x} ${y + h - waveH} Z`
|
|
1260
|
+
return `<path d="${d}" ${shapeAttrs(fill, stroke, sw)} />`
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** Bow-tie rectangle (stored data): left and right edges curve inward. */
|
|
1264
|
+
function renderBowTie(
|
|
1265
|
+
x: number,
|
|
1266
|
+
y: number,
|
|
1267
|
+
w: number,
|
|
1268
|
+
h: number,
|
|
1269
|
+
fill: string,
|
|
1270
|
+
stroke: string,
|
|
1271
|
+
sw: string,
|
|
1272
|
+
): string {
|
|
1273
|
+
const bow = Math.min(14, w * 0.12)
|
|
1274
|
+
const d =
|
|
1275
|
+
`M ${x + bow} ${y} L ${x + w} ${y} ` +
|
|
1276
|
+
`Q ${x + w - bow * 1.4} ${y + h / 2} ${x + w} ${y + h} ` +
|
|
1277
|
+
`L ${x + bow} ${y + h} ` +
|
|
1278
|
+
`Q ${x + bow * 1.4} ${y + h / 2} ${x + bow} ${y} Z`
|
|
1279
|
+
return `<path d="${d}" ${shapeAttrs(fill, stroke, sw)} />`
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** Delay / half-rounded rectangle: the right end is a semicircle. */
|
|
1283
|
+
function renderHalfRounded(
|
|
1284
|
+
x: number,
|
|
1285
|
+
y: number,
|
|
1286
|
+
w: number,
|
|
1287
|
+
h: number,
|
|
1288
|
+
fill: string,
|
|
1289
|
+
stroke: string,
|
|
1290
|
+
sw: string,
|
|
1291
|
+
): string {
|
|
1292
|
+
const r = h / 2
|
|
1293
|
+
const straight = Math.max(0, w - r)
|
|
1294
|
+
const d =
|
|
1295
|
+
`M ${x} ${y} L ${x + straight} ${y} ` +
|
|
1296
|
+
`A ${r} ${r} 0 0 1 ${x + straight} ${y + h} ` +
|
|
1297
|
+
`L ${x} ${y + h} Z`
|
|
1298
|
+
return `<path d="${d}" ${shapeAttrs(fill, stroke, sw)} />`
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Brace shapes: a rectangle body flanked by curly braces.
|
|
1303
|
+
*
|
|
1304
|
+
* Mermaid's `brace`/`comment` is a left brace, `brace-r` a right brace, and
|
|
1305
|
+
* `braces` both. The body itself is unfilled so the braces read as annotation
|
|
1306
|
+
* marks rather than as a container.
|
|
1307
|
+
*/
|
|
1308
|
+
function renderBraces(
|
|
1309
|
+
x: number,
|
|
1310
|
+
y: number,
|
|
1311
|
+
w: number,
|
|
1312
|
+
h: number,
|
|
1313
|
+
_fill: string,
|
|
1314
|
+
stroke: string,
|
|
1315
|
+
sw: string,
|
|
1316
|
+
side: 'left' | 'right' | 'both',
|
|
1317
|
+
): string {
|
|
1318
|
+
const armW = Math.min(10, w * 0.12)
|
|
1319
|
+
// Unfilled: a filled body reads as a container, which is the opposite of
|
|
1320
|
+
// what a brace annotation means. The rect stays only to reserve the
|
|
1321
|
+
// label area; `_fill` is deliberately unused.
|
|
1322
|
+
const parts = [
|
|
1323
|
+
`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="none" />`,
|
|
1324
|
+
]
|
|
1325
|
+
|
|
1326
|
+
const leftBrace =
|
|
1327
|
+
`M ${x + armW} ${y} Q ${x} ${y} ${x} ${y + h * 0.25} ` +
|
|
1328
|
+
`Q ${x} ${y + h / 2} ${x - armW * 0.4} ${y + h / 2} ` +
|
|
1329
|
+
`Q ${x} ${y + h / 2} ${x} ${y + h * 0.75} ` +
|
|
1330
|
+
`Q ${x} ${y + h} ${x + armW} ${y + h}`
|
|
1331
|
+
const rightBrace =
|
|
1332
|
+
`M ${x + w - armW} ${y} Q ${x + w} ${y} ${x + w} ${y + h * 0.25} ` +
|
|
1333
|
+
`Q ${x + w} ${y + h / 2} ${x + w + armW * 0.4} ${y + h / 2} ` +
|
|
1334
|
+
`Q ${x + w} ${y + h / 2} ${x + w} ${y + h * 0.75} ` +
|
|
1335
|
+
`Q ${x + w} ${y + h} ${x + w - armW} ${y + h}`
|
|
1336
|
+
|
|
1337
|
+
if (side === 'left' || side === 'both') {
|
|
1338
|
+
parts.push(
|
|
1339
|
+
`<path d="${leftBrace}" fill="none" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
1340
|
+
)
|
|
1341
|
+
}
|
|
1342
|
+
if (side === 'right' || side === 'both') {
|
|
1343
|
+
parts.push(
|
|
1344
|
+
`<path d="${rightBrace}" fill="none" stroke="${stroke}" stroke-width="${sw}" />`,
|
|
1345
|
+
)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return parts.join('\n')
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** Lightning bolt (communication link). */
|
|
1352
|
+
function renderBolt(
|
|
1353
|
+
x: number,
|
|
1354
|
+
y: number,
|
|
1355
|
+
w: number,
|
|
1356
|
+
h: number,
|
|
1357
|
+
fill: string,
|
|
1358
|
+
stroke: string,
|
|
1359
|
+
sw: string,
|
|
1360
|
+
): string {
|
|
1361
|
+
const points = [
|
|
1362
|
+
`${x + w * 0.42},${y}`,
|
|
1363
|
+
`${x + w},${y}`,
|
|
1364
|
+
`${x + w * 0.62},${y + h * 0.42}`,
|
|
1365
|
+
`${x + w},${y + h * 0.42}`,
|
|
1366
|
+
`${x + w * 0.3},${y + h}`,
|
|
1367
|
+
`${x + w * 0.5},${y + h * 0.55}`,
|
|
1368
|
+
`${x},${y + h * 0.55}`,
|
|
1369
|
+
].join(' ')
|
|
1370
|
+
return `<polygon points="${points}" ${shapeAttrs(fill, stroke, sw)} />`
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// --- Batch 3: State diagram pseudostates ---
|
|
1374
|
+
|
|
1375
|
+
/** State start: small filled circle using primary text color */
|
|
1376
|
+
function renderStateStart(x: number, y: number, w: number, h: number): string {
|
|
1377
|
+
const cx = x + w / 2
|
|
1378
|
+
const cy = y + h / 2
|
|
1379
|
+
const r = Math.min(w, h) / 2 - 2
|
|
1380
|
+
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="var(--_text)" stroke="none" />`
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** State end: bullseye — outer ring + inner filled circle using primary text color */
|
|
1384
|
+
function renderStateEnd(x: number, y: number, w: number, h: number): string {
|
|
1385
|
+
const cx = x + w / 2
|
|
1386
|
+
const cy = y + h / 2
|
|
1387
|
+
const outerR = Math.min(w, h) / 2 - 2
|
|
1388
|
+
const innerR = outerR - 4
|
|
1389
|
+
return (
|
|
1390
|
+
`<circle cx="${cx}" cy="${cy}" r="${outerR}" ` +
|
|
1391
|
+
`fill="none" stroke="var(--_text)" stroke-width="${STROKE_WIDTHS.innerBox * 2}" />` +
|
|
1392
|
+
`\n<circle cx="${cx}" cy="${cy}" r="${innerR}" fill="var(--_text)" stroke="none" />`
|
|
1393
|
+
)
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// ============================================================================
|
|
1397
|
+
// Node label rendering
|
|
1398
|
+
// ============================================================================
|
|
1399
|
+
|
|
1400
|
+
// `_font` isn't read here but is kept to match the `(entity, font)` signature
|
|
1401
|
+
// threaded through the rest of the render* functions in this file.
|
|
1402
|
+
function renderNodeLabel(
|
|
1403
|
+
node: PositionedNode,
|
|
1404
|
+
_font: string,
|
|
1405
|
+
fontSizes: FontSizes,
|
|
1406
|
+
): string {
|
|
1407
|
+
// State pseudostates have no label
|
|
1408
|
+
if (node.shape === 'state-start' || node.shape === 'state-end') {
|
|
1409
|
+
if (!node.label) return ''
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const cx = node.x + node.width / 2
|
|
1413
|
+
const cy = node.y + node.height / 2
|
|
1414
|
+
|
|
1415
|
+
// Resolve text color — inline styles can override the CSS variable default.
|
|
1416
|
+
// When there's no explicit `color` but there IS a concrete, resolvable
|
|
1417
|
+
// `fill` (e.g. from classDef/style), compute a readable black/white text
|
|
1418
|
+
// color from the fill's luminance instead of defaulting to the ambient
|
|
1419
|
+
// theme foreground, which can be unreadable against a custom fill (e.g.
|
|
1420
|
+
// white theme text on a light pastel fill in dark mode). See issue #55.
|
|
1421
|
+
const textColor = escapeAttr(
|
|
1422
|
+
node.inlineStyle?.color ??
|
|
1423
|
+
getReadableTextColor(node.inlineStyle?.fill, 'var(--_text)'),
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
let attrs = `text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="${textColor}"`
|
|
1427
|
+
|
|
1428
|
+
// Per-node font-family override (from `style A font-family:...` or
|
|
1429
|
+
// classDef/class). Emitted as an inline `style` attribute rather than a
|
|
1430
|
+
// `font-family` presentation attribute: the global font is applied via a
|
|
1431
|
+
// `text { font-family: ... }` rule in the embedded <style> block (see
|
|
1432
|
+
// theme.ts buildStyleBlock), and a presentation attribute always loses to
|
|
1433
|
+
// any stylesheet rule regardless of selector specificity. An inline `style`
|
|
1434
|
+
// attribute has the highest priority in the cascade, so it reliably
|
|
1435
|
+
// overrides the global rule for just this node while every other node
|
|
1436
|
+
// keeps falling back to the global font stack. See issue #57.
|
|
1437
|
+
const fontFamily = node.inlineStyle?.['font-family']
|
|
1438
|
+
if (fontFamily) {
|
|
1439
|
+
attrs += ` style="font-family: ${escapeAttr(fontFamily)};"`
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
return renderMultilineText(node.label, cx, cy, fontSizes.nodeLabel, attrs)
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// ============================================================================
|
|
1446
|
+
// Utilities
|
|
1447
|
+
// ============================================================================
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Escape a string for embedding as a *multiline-safe* XML attribute value —
|
|
1451
|
+
* `escapeAttr()` plus tab/LF/CR as numeric character references.
|
|
1452
|
+
*
|
|
1453
|
+
* A strict XML parser applies attribute-value normalization on the way in:
|
|
1454
|
+
* any literal tab, LF, or CR in the value is collapsed to a single space.
|
|
1455
|
+
* (This is why a standalone .svg file opened directly, or any output run
|
|
1456
|
+
* through `DOMParser` with an XML/`image/svg+xml` mimetype, would silently
|
|
1457
|
+
* flatten a multiline `data-src` back to one line.) Character references
|
|
1458
|
+
* are exempt from that normalization — ` ` round-trips as an actual
|
|
1459
|
+
* newline — so diagram source, which is virtually always multiline, needs
|
|
1460
|
+
* this rather than `escapeAttr()` alone to survive the trip intact.
|
|
1461
|
+
*/
|
|
1462
|
+
function escapeMultilineAttr(value: string): string {
|
|
1463
|
+
return escapeAttr(value)
|
|
1464
|
+
.replace(/\r/g, ' ')
|
|
1465
|
+
.replace(/\n/g, ' ')
|
|
1466
|
+
.replace(/\t/g, '	')
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Splice a `data-src` attribute (the original diagram source, escaped) onto
|
|
1471
|
+
* an already-built root `<svg ...>` opening tag, e.g. from
|
|
1472
|
+
* `embedSource: true`. Applied to the string `svgOpenTag()` returns rather
|
|
1473
|
+
* than the diagram source's own markup, so it always lands on the root
|
|
1474
|
+
* element regardless of diagram type. No-op when `source` is undefined.
|
|
1475
|
+
*/
|
|
1476
|
+
export function withDataSrc(
|
|
1477
|
+
svgTag: string,
|
|
1478
|
+
source: string | undefined,
|
|
1479
|
+
): string {
|
|
1480
|
+
if (source === undefined) return svgTag
|
|
1481
|
+
return svgTag.replace(
|
|
1482
|
+
'<svg ',
|
|
1483
|
+
`<svg data-src="${escapeMultilineAttr(source)}" `,
|
|
1484
|
+
)
|
|
1485
|
+
}
|