@zombie-mermaid/svg-renderer 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +524 -0
- package/dist/index.d.ts +524 -0
- package/dist/index.js +2994 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/elk-adapter-utils.test.ts +166 -0
- package/src/class/layout.ts +360 -0
- package/src/class/renderer.ts +636 -0
- package/src/edge-curves.ts +204 -0
- package/src/elk-instance.ts +292 -0
- package/src/er/layout.ts +200 -0
- package/src/er/renderer.ts +493 -0
- package/src/index.ts +58 -0
- package/src/layout-engine/constants.ts +19 -0
- package/src/layout-engine/edge-bundling.ts +379 -0
- package/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/src/layout-engine/elk-graph-builder.ts +240 -0
- package/src/layout-engine/from-elk.ts +685 -0
- package/src/layout-engine/layer-alignment.ts +174 -0
- package/src/layout-engine/to-elk.ts +695 -0
- package/src/layout-engine.ts +74 -0
- package/src/layout.ts +8 -0
- package/src/renderer.ts +1485 -0
- package/src/resolve-colors.ts +339 -0
- package/src/sequence/layout.ts +698 -0
- package/src/sequence/renderer.ts +546 -0
- package/src/shape-clipping.ts +197 -0
- package/src/styles.ts +118 -0
- package/src/xychart/layout.ts +682 -0
- package/src/xychart/renderer.ts +684 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PositionedSequenceDiagram,
|
|
3
|
+
PositionedActor,
|
|
4
|
+
Lifeline,
|
|
5
|
+
PositionedMessage,
|
|
6
|
+
Activation,
|
|
7
|
+
PositionedBlock,
|
|
8
|
+
PositionedNote,
|
|
9
|
+
PositionedParticipantBox,
|
|
10
|
+
} from '@zombie-mermaid/mermaid-parser'
|
|
11
|
+
import { boxLabelHeight } from './layout.ts'
|
|
12
|
+
import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
|
|
13
|
+
import {
|
|
14
|
+
svgOpenTag,
|
|
15
|
+
buildStyleBlock,
|
|
16
|
+
renderMultilineText,
|
|
17
|
+
escapeAttr,
|
|
18
|
+
} from '@zombie-mermaid/core'
|
|
19
|
+
import { withDataSrc } from '../renderer.ts'
|
|
20
|
+
import {
|
|
21
|
+
FONT_SIZES,
|
|
22
|
+
FONT_WEIGHTS,
|
|
23
|
+
STROKE_WIDTHS,
|
|
24
|
+
ARROW_HEAD,
|
|
25
|
+
estimateTextWidth,
|
|
26
|
+
} from '../styles.ts'
|
|
27
|
+
import type { FontSizes } from '../styles.ts'
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Sequence diagram SVG renderer
|
|
31
|
+
//
|
|
32
|
+
// Renders a positioned sequence diagram to SVG string.
|
|
33
|
+
// All colors use CSS custom properties (var(--_xxx)) from the theme system.
|
|
34
|
+
//
|
|
35
|
+
// Render order (back to front):
|
|
36
|
+
// 0. Participant-group backgrounds (box … end)
|
|
37
|
+
// 1. Block backgrounds (loop/alt/opt)
|
|
38
|
+
// 2. Lifelines (dashed vertical lines)
|
|
39
|
+
// 3. Activation boxes
|
|
40
|
+
// 4. Messages (arrows with labels)
|
|
41
|
+
// 5. Notes
|
|
42
|
+
// 6. Actor boxes (at top)
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Render a positioned sequence diagram as an SVG string.
|
|
47
|
+
*
|
|
48
|
+
* @param colors - DiagramColors with bg/fg and optional enrichment variables.
|
|
49
|
+
* @param transparent - If true, renders with transparent background.
|
|
50
|
+
* @param embedSource - Original diagram source to stamp onto the root `<svg>`
|
|
51
|
+
* as `data-src` (from `options.embedSource`). Omitted
|
|
52
|
+
* when the option is off.
|
|
53
|
+
* @param title - Accessible name (from `options.title`). See svgOpenTag() in
|
|
54
|
+
* packages/core/src/theme.ts.
|
|
55
|
+
* @param decorative - Marks the SVG decorative (from `options.decorative`).
|
|
56
|
+
* @param emit - Strict-CSP controls (from `options.nonce` /
|
|
57
|
+
* `options.styleAttribute`, see #216). Default: no nonce,
|
|
58
|
+
* root `style` attribute on.
|
|
59
|
+
*/
|
|
60
|
+
export function renderSequenceSvg(
|
|
61
|
+
diagram: PositionedSequenceDiagram,
|
|
62
|
+
colors: DiagramColors,
|
|
63
|
+
font: string = 'Inter',
|
|
64
|
+
transparent: boolean = false,
|
|
65
|
+
fontSizes: FontSizes = FONT_SIZES,
|
|
66
|
+
embedSource?: string,
|
|
67
|
+
title?: string,
|
|
68
|
+
decorative?: boolean,
|
|
69
|
+
emit: SvgEmitOptions = {},
|
|
70
|
+
): string {
|
|
71
|
+
const parts: string[] = []
|
|
72
|
+
|
|
73
|
+
// SVG root with CSS variables + style block + defs
|
|
74
|
+
parts.push(
|
|
75
|
+
withDataSrc(
|
|
76
|
+
svgOpenTag(
|
|
77
|
+
diagram.width,
|
|
78
|
+
diagram.height,
|
|
79
|
+
colors,
|
|
80
|
+
transparent,
|
|
81
|
+
title,
|
|
82
|
+
decorative,
|
|
83
|
+
undefined,
|
|
84
|
+
emit.styleAttribute,
|
|
85
|
+
),
|
|
86
|
+
embedSource,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
parts.push(buildStyleBlock(font, false, emit.nonce))
|
|
90
|
+
parts.push('<defs>')
|
|
91
|
+
|
|
92
|
+
// Arrow marker definitions
|
|
93
|
+
parts.push(arrowMarkerDefs())
|
|
94
|
+
parts.push('</defs>')
|
|
95
|
+
|
|
96
|
+
// 0. Participant-group backgrounds (box … end), behind everything
|
|
97
|
+
for (const box of diagram.boxes) {
|
|
98
|
+
parts.push(renderParticipantBox(box, fontSizes))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 1. Block backgrounds (loop/alt/opt rectangles)
|
|
102
|
+
for (const block of diagram.blocks) {
|
|
103
|
+
parts.push(renderBlock(block, fontSizes))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 2. Lifelines (dashed vertical lines from actor to bottom)
|
|
107
|
+
for (const lifeline of diagram.lifelines) {
|
|
108
|
+
parts.push(renderLifeline(lifeline))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 3. Activation boxes
|
|
112
|
+
for (const activation of diagram.activations) {
|
|
113
|
+
parts.push(renderActivation(activation))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. Messages (horizontal arrows with labels)
|
|
117
|
+
for (const message of diagram.messages) {
|
|
118
|
+
parts.push(renderMessage(message, fontSizes))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 5. Notes
|
|
122
|
+
for (const note of diagram.notes) {
|
|
123
|
+
parts.push(renderNote(note, fontSizes))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 6. Actor boxes at top (rendered last so they're on top)
|
|
127
|
+
for (const actor of diagram.actors) {
|
|
128
|
+
parts.push(renderActor(actor, fontSizes))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
parts.push('</svg>')
|
|
132
|
+
return parts.join('\n')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ============================================================================
|
|
136
|
+
// Arrow marker definitions
|
|
137
|
+
// ============================================================================
|
|
138
|
+
|
|
139
|
+
function arrowMarkerDefs(): string {
|
|
140
|
+
const w = ARROW_HEAD.width
|
|
141
|
+
const h = ARROW_HEAD.height
|
|
142
|
+
return (
|
|
143
|
+
` <marker id="seq-arrow" markerWidth="${w}" markerHeight="${h}" refX="${w}" refY="${h / 2}" orient="auto-start-reverse">` +
|
|
144
|
+
`\n <polygon points="0 0, ${w} ${h / 2}, 0 ${h}" fill="var(--_arrow)" />` +
|
|
145
|
+
`\n </marker>` +
|
|
146
|
+
// Open arrow head (just lines, no fill)
|
|
147
|
+
`\n <marker id="seq-arrow-open" markerWidth="${w}" markerHeight="${h}" refX="${w}" refY="${h / 2}" orient="auto-start-reverse">` +
|
|
148
|
+
`\n <polyline points="0 0, ${w} ${h / 2}, 0 ${h}" fill="none" stroke="var(--_arrow)" stroke-width="1" />` +
|
|
149
|
+
`\n </marker>`
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Component renderers
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render an actor box (participant = rectangle, actor = stick figure).
|
|
159
|
+
* Wrapped in <g class="actor"> with semantic data attributes.
|
|
160
|
+
*/
|
|
161
|
+
function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
|
|
162
|
+
const { id, x, y, width, height, label, type } = actor
|
|
163
|
+
const parts: string[] = []
|
|
164
|
+
|
|
165
|
+
// Semantic wrapper with actor metadata
|
|
166
|
+
parts.push(
|
|
167
|
+
`<g class="actor" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}" data-type="${type}">`,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if (type === 'actor') {
|
|
171
|
+
// Circle-person icon: outer circle + head circle + shoulders arc.
|
|
172
|
+
// Defined in a 24×24 coordinate space, scaled to 90% of the actor box height
|
|
173
|
+
// and centered both horizontally and vertically within the box.
|
|
174
|
+
// Stroke width is inverse-scaled so the visual thickness matches STROKE_WIDTHS.outerBox.
|
|
175
|
+
const s = (height / 24) * 0.9
|
|
176
|
+
const tx = x - 12 * s // center icon horizontally on actor.x
|
|
177
|
+
const ty = y + (height - 24 * s) / 2 // center icon vertically in actor box
|
|
178
|
+
const sw = STROKE_WIDTHS.outerBox / s // compensate for scale transform
|
|
179
|
+
const iconStroke = 'var(--_line)' // use line color for actor icon strokes
|
|
180
|
+
|
|
181
|
+
parts.push(
|
|
182
|
+
` <g transform="translate(${tx},${ty}) scale(${s})">` +
|
|
183
|
+
// Outer circle
|
|
184
|
+
`\n <path d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
|
|
185
|
+
// Head
|
|
186
|
+
`\n <path d="M15 10C15 11.6569 13.6569 13 12 13C10.3431 13 9 11.6569 9 10C9 8.34315 10.3431 7 12 7C13.6569 7 15 8.34315 15 10Z" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
|
|
187
|
+
// Shoulders
|
|
188
|
+
`\n <path d="M5.62842 18.3563C7.08963 17.0398 9.39997 16 12 16C14.6 16 16.9104 17.0398 18.3716 18.3563" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
|
|
189
|
+
`\n </g>`,
|
|
190
|
+
)
|
|
191
|
+
// Label below the icon (supports multi-line)
|
|
192
|
+
parts.push(
|
|
193
|
+
' ' +
|
|
194
|
+
renderMultilineText(
|
|
195
|
+
label,
|
|
196
|
+
x,
|
|
197
|
+
y + height + 14,
|
|
198
|
+
fontSizes.nodeLabel,
|
|
199
|
+
`font-size="${fontSizes.nodeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="var(--_text)"`,
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
} else {
|
|
203
|
+
// Participant: rectangle box with label (supports multi-line)
|
|
204
|
+
const boxX = x - width / 2
|
|
205
|
+
parts.push(
|
|
206
|
+
` <rect x="${boxX}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" ` +
|
|
207
|
+
`fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
208
|
+
)
|
|
209
|
+
parts.push(
|
|
210
|
+
' ' +
|
|
211
|
+
renderMultilineText(
|
|
212
|
+
label,
|
|
213
|
+
x,
|
|
214
|
+
y + height / 2,
|
|
215
|
+
fontSizes.nodeLabel,
|
|
216
|
+
`font-size="${fontSizes.nodeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="var(--_text)"`,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
parts.push('</g>')
|
|
222
|
+
return parts.join('\n')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Render a lifeline (dashed vertical line from actor to bottom).
|
|
227
|
+
* Includes data-actor to link to its actor.
|
|
228
|
+
*/
|
|
229
|
+
function renderLifeline(lifeline: Lifeline): string {
|
|
230
|
+
const line =
|
|
231
|
+
`<line class="lifeline" data-actor="${escapeAttr(lifeline.actorId)}" ` +
|
|
232
|
+
`x1="${lifeline.x}" y1="${lifeline.topY}" x2="${lifeline.x}" y2="${lifeline.bottomY}" ` +
|
|
233
|
+
`stroke="var(--_line)" stroke-width="0.75" stroke-dasharray="6 4" />`
|
|
234
|
+
if (!lifeline.destroyed) return line
|
|
235
|
+
// `destroy X`: the lifeline ends at the destroying message's row, marked
|
|
236
|
+
// with a cross centred on it — the same glyph Mermaid uses. Drawn in the
|
|
237
|
+
// lifeline pass (before messages), so the destroying arrow lands on top.
|
|
238
|
+
const { x, bottomY: y } = lifeline
|
|
239
|
+
const r = DESTROY_CROSS_HALF
|
|
240
|
+
return (
|
|
241
|
+
line +
|
|
242
|
+
`\n<path class="destroy" data-actor="${escapeAttr(lifeline.actorId)}" ` +
|
|
243
|
+
`d="M${x - r} ${y - r} L${x + r} ${y + r} M${x + r} ${y - r} L${x - r} ${y + r}" ` +
|
|
244
|
+
`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.outerBox}" stroke-linecap="round" />`
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Half-size of the `destroy` cross at the end of a lifeline, in px. */
|
|
249
|
+
const DESTROY_CROSS_HALF = 8
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Render an activation box (narrow filled rectangle on lifeline).
|
|
253
|
+
* Includes data-actor to link to its actor.
|
|
254
|
+
*/
|
|
255
|
+
function renderActivation(activation: Activation): string {
|
|
256
|
+
return (
|
|
257
|
+
`<rect class="activation" data-actor="${escapeAttr(activation.actorId)}" ` +
|
|
258
|
+
`x="${activation.x}" y="${activation.topY}" width="${activation.width}" height="${activation.bottomY - activation.topY}" ` +
|
|
259
|
+
`fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Render a message arrow with label.
|
|
265
|
+
* Wrapped in <g class="message"> with semantic data attributes.
|
|
266
|
+
*/
|
|
267
|
+
function renderMessage(msg: PositionedMessage, fontSizes: FontSizes): string {
|
|
268
|
+
const parts: string[] = []
|
|
269
|
+
const dashArray = msg.lineStyle === 'dashed' ? ' stroke-dasharray="6 4"' : ''
|
|
270
|
+
const markerId = msg.arrowHead === 'filled' ? 'seq-arrow' : 'seq-arrow-open'
|
|
271
|
+
// Bidirectional arrows (`<<->>` / `<<-->>`) reuse the same marker on both
|
|
272
|
+
// ends — the marker defs use orient="auto-start-reverse", which SVG
|
|
273
|
+
// automatically flips 180° for marker-start so it points outward at the
|
|
274
|
+
// line's start instead of reusing the marker-end orientation.
|
|
275
|
+
const markerStart = msg.bidirectional
|
|
276
|
+
? ` marker-start="url(#${markerId})"`
|
|
277
|
+
: ''
|
|
278
|
+
|
|
279
|
+
// Semantic wrapper with message metadata
|
|
280
|
+
parts.push(
|
|
281
|
+
`<g class="message" data-from="${escapeAttr(msg.from)}" data-to="${escapeAttr(msg.to)}" ` +
|
|
282
|
+
`data-label="${escapeAttr(msg.label)}" data-line-style="${msg.lineStyle}" ` +
|
|
283
|
+
`data-arrow-head="${msg.arrowHead}" data-self="${msg.isSelf}" data-bidirectional="${msg.bidirectional}">`,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if (msg.isSelf) {
|
|
287
|
+
// Self-message: curved loop going right and back
|
|
288
|
+
// Loop dimensions - loopH is fixed, loopW provides minimum clearance
|
|
289
|
+
const loopW = 30
|
|
290
|
+
const loopH = 20
|
|
291
|
+
const labelPadding = 8 // Space between loop and label
|
|
292
|
+
parts.push(
|
|
293
|
+
` <polyline points="${msg.x1},${msg.y} ${msg.x1 + loopW},${msg.y} ${msg.x1 + loopW},${msg.y + loopH} ${msg.x2},${msg.y + loopH}" ` +
|
|
294
|
+
`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}"${dashArray} marker-end="url(#${markerId})"${markerStart} />`,
|
|
295
|
+
)
|
|
296
|
+
// Label to the right of the loop (supports multi-line)
|
|
297
|
+
parts.push(
|
|
298
|
+
' ' +
|
|
299
|
+
renderMultilineText(
|
|
300
|
+
msg.label,
|
|
301
|
+
msg.x1 + loopW + labelPadding,
|
|
302
|
+
msg.y + loopH / 2,
|
|
303
|
+
fontSizes.edgeLabel,
|
|
304
|
+
`font-size="${fontSizes.edgeLabel}" text-anchor="start" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
} else {
|
|
308
|
+
// Normal message: horizontal arrow
|
|
309
|
+
parts.push(
|
|
310
|
+
` <line x1="${msg.x1}" y1="${msg.y}" x2="${msg.x2}" y2="${msg.y}" ` +
|
|
311
|
+
`stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}"${dashArray} marker-end="url(#${markerId})"${markerStart} />`,
|
|
312
|
+
)
|
|
313
|
+
// Label above the arrow, centered (supports multi-line)
|
|
314
|
+
const midX = (msg.x1 + msg.x2) / 2
|
|
315
|
+
parts.push(
|
|
316
|
+
' ' +
|
|
317
|
+
renderMultilineText(
|
|
318
|
+
msg.label,
|
|
319
|
+
midX,
|
|
320
|
+
msg.y - 10,
|
|
321
|
+
fontSizes.edgeLabel,
|
|
322
|
+
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (msg.seqNumber !== undefined) {
|
|
328
|
+
parts.push(' ' + renderSeqNumberBadge(msg, fontSizes))
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
parts.push('</g>')
|
|
332
|
+
return parts.join('\n')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Render the small `autonumber` badge Mermaid draws near the start of a
|
|
337
|
+
* message arrow: a circle with the sequence number centered inside it,
|
|
338
|
+
* layered on top of the arrow rather than folded into the label text.
|
|
339
|
+
*/
|
|
340
|
+
function renderSeqNumberBadge(
|
|
341
|
+
msg: PositionedMessage,
|
|
342
|
+
fontSizes: FontSizes,
|
|
343
|
+
): string {
|
|
344
|
+
const radius = 8
|
|
345
|
+
const fontSize = Math.max(fontSizes.edgeLabel - 2, 8)
|
|
346
|
+
// Bidirectional messages also draw an arrowhead at x1 (the departure
|
|
347
|
+
// end) — badge-at-x1 would sit on top of it. Shift the badge into the
|
|
348
|
+
// arrow span, clear of the marker, in that case. One-way messages have
|
|
349
|
+
// no marker at x1, so they keep the badge exactly at the departure
|
|
350
|
+
// point as before.
|
|
351
|
+
const cx = msg.bidirectional
|
|
352
|
+
? msg.x1 + Math.sign(msg.x2 - msg.x1) * (radius + ARROW_HEAD.width)
|
|
353
|
+
: msg.x1
|
|
354
|
+
return (
|
|
355
|
+
`<g class="seq-number">` +
|
|
356
|
+
`<circle cx="${cx}" cy="${msg.y}" r="${radius}" fill="var(--bg)" stroke="var(--_arrow)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
357
|
+
renderMultilineText(
|
|
358
|
+
String(msg.seqNumber),
|
|
359
|
+
cx,
|
|
360
|
+
msg.y,
|
|
361
|
+
fontSize,
|
|
362
|
+
`font-size="${fontSize}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_arrow)"`,
|
|
363
|
+
) +
|
|
364
|
+
`</g>`
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* How much of a `box` colour shows through: the colour is mixed into the
|
|
370
|
+
* theme background at this percentage rather than painted as-is, so
|
|
371
|
+
* `box Purple` reads as a purple tint on a light theme and a deep purple on
|
|
372
|
+
* a dark one instead of the same opaque swatch on both (Mermaid paints the
|
|
373
|
+
* literal colour). The stroke and label use theme variables, so a colourless
|
|
374
|
+
* box still shows its grouping in every theme.
|
|
375
|
+
*/
|
|
376
|
+
const BOX_COLOR_MIX_PERCENT = 35
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Render a `box … end` participant-group background: a full-height rect
|
|
380
|
+
* behind the grouped lifelines with the label centred in its top band.
|
|
381
|
+
* Wrapped in <g class="participant-box"> with semantic data attributes.
|
|
382
|
+
*
|
|
383
|
+
* `box.color` is emitted verbatim inside `color-mix()` — safe because the
|
|
384
|
+
* parser only records a value that matched its fixed named-colour list or
|
|
385
|
+
* the hex / rgb() / hsl() patterns (see box-color.ts), never arbitrary
|
|
386
|
+
* diagram text.
|
|
387
|
+
*/
|
|
388
|
+
function renderParticipantBox(
|
|
389
|
+
box: PositionedParticipantBox,
|
|
390
|
+
fontSizes: FontSizes,
|
|
391
|
+
): string {
|
|
392
|
+
const labelAttr = box.label ? ` data-label="${escapeAttr(box.label)}"` : ''
|
|
393
|
+
const colorAttr =
|
|
394
|
+
box.color !== undefined ? ` data-color="${escapeAttr(box.color)}"` : ''
|
|
395
|
+
const fill =
|
|
396
|
+
box.color !== undefined
|
|
397
|
+
? `color-mix(in srgb, ${escapeAttr(box.color)} ${BOX_COLOR_MIX_PERCENT}%, var(--bg))`
|
|
398
|
+
: 'none'
|
|
399
|
+
const parts: string[] = [
|
|
400
|
+
`<g class="participant-box"${labelAttr}${colorAttr}>`,
|
|
401
|
+
` <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="4" ry="4" ` +
|
|
402
|
+
`fill="${fill}" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
403
|
+
]
|
|
404
|
+
if (box.label) {
|
|
405
|
+
// Centred in the label band the layout reserved above the actor boxes.
|
|
406
|
+
parts.push(
|
|
407
|
+
' ' +
|
|
408
|
+
renderMultilineText(
|
|
409
|
+
box.label,
|
|
410
|
+
box.x + box.width / 2,
|
|
411
|
+
box.y + boxLabelHeight(fontSizes.edgeLabel) / 2,
|
|
412
|
+
fontSizes.edgeLabel,
|
|
413
|
+
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)"`,
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
parts.push('</g>')
|
|
418
|
+
return parts.join('\n')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Render a block background (loop/alt/opt).
|
|
423
|
+
* Wrapped in <g class="block"> with semantic data attributes.
|
|
424
|
+
*/
|
|
425
|
+
function renderBlock(block: PositionedBlock, fontSizes: FontSizes): string {
|
|
426
|
+
const parts: string[] = []
|
|
427
|
+
|
|
428
|
+
// Semantic wrapper with block metadata
|
|
429
|
+
const labelAttr = block.label
|
|
430
|
+
? ` data-label="${escapeAttr(block.label)}"`
|
|
431
|
+
: ''
|
|
432
|
+
parts.push(
|
|
433
|
+
`<g class="block" data-type="${escapeAttr(block.type)}"${labelAttr}>`,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
// Outer rectangle
|
|
437
|
+
parts.push(
|
|
438
|
+
` <rect x="${block.x}" y="${block.y}" width="${block.width}" height="${block.height}" ` +
|
|
439
|
+
`rx="0" ry="0" fill="none" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
// Type label tab (top-left corner)
|
|
443
|
+
// For multi-line block labels, we use the first line for the tab but show full label
|
|
444
|
+
const labelText = `${block.type}${block.label ? ` [${block.label}]` : ''}`
|
|
445
|
+
// Audited for issue #100: `String.prototype.split` always returns an
|
|
446
|
+
// array with at least one element (even splitting `''` yields `['']`),
|
|
447
|
+
// so index 0 is guaranteed to exist regardless of whether `labelText`
|
|
448
|
+
// contains a newline. `noUncheckedIndexedAccess` can't see that
|
|
449
|
+
// language-level guarantee; left as-is, no behavior change.
|
|
450
|
+
const firstLine = labelText.split('\n')[0]!
|
|
451
|
+
const tabWidth =
|
|
452
|
+
estimateTextWidth(
|
|
453
|
+
firstLine,
|
|
454
|
+
fontSizes.edgeLabel,
|
|
455
|
+
FONT_WEIGHTS.groupHeader,
|
|
456
|
+
) + 16
|
|
457
|
+
const tabHeight = 18
|
|
458
|
+
|
|
459
|
+
parts.push(
|
|
460
|
+
` <rect x="${block.x}" y="${block.y}" width="${tabWidth}" height="${tabHeight}" ` +
|
|
461
|
+
`fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
462
|
+
)
|
|
463
|
+
// Block type label (supports multi-line via <br> tags)
|
|
464
|
+
parts.push(
|
|
465
|
+
' ' +
|
|
466
|
+
renderMultilineText(
|
|
467
|
+
labelText,
|
|
468
|
+
block.x + 6,
|
|
469
|
+
block.y + tabHeight / 2,
|
|
470
|
+
fontSizes.edgeLabel,
|
|
471
|
+
`font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)"`,
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
// Divider lines (for alt/else, par/and)
|
|
476
|
+
for (const divider of block.dividers) {
|
|
477
|
+
parts.push(
|
|
478
|
+
` <line x1="${block.x}" y1="${divider.y}" x2="${block.x + block.width}" y2="${divider.y}" ` +
|
|
479
|
+
`stroke="var(--_line)" stroke-width="0.75" stroke-dasharray="6 4" />`,
|
|
480
|
+
)
|
|
481
|
+
if (divider.label) {
|
|
482
|
+
// Divider label supports multi-line
|
|
483
|
+
parts.push(
|
|
484
|
+
' ' +
|
|
485
|
+
renderMultilineText(
|
|
486
|
+
`[${divider.label}]`,
|
|
487
|
+
block.x + 8,
|
|
488
|
+
divider.y + 14,
|
|
489
|
+
fontSizes.edgeLabel,
|
|
490
|
+
`font-size="${fontSizes.edgeLabel}" text-anchor="start" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
parts.push('</g>')
|
|
497
|
+
return parts.join('\n')
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Render a note box.
|
|
502
|
+
* Wrapped in <g class="note"> with semantic data attributes.
|
|
503
|
+
*/
|
|
504
|
+
function renderNote(note: PositionedNote, fontSizes: FontSizes): string {
|
|
505
|
+
// Dog-ear note: polygon with clipped top-right corner + fold triangle
|
|
506
|
+
const foldSize = 6
|
|
507
|
+
const { x, y, width: w, height: h } = note
|
|
508
|
+
|
|
509
|
+
// Build actor reference attribute if present
|
|
510
|
+
const actorsAttr =
|
|
511
|
+
note.actors && note.actors.length > 0
|
|
512
|
+
? ` data-actors="${note.actors.map(escapeAttr).join(',')}"`
|
|
513
|
+
: ''
|
|
514
|
+
const positionAttr = note.position
|
|
515
|
+
? ` data-position="${escapeAttr(note.position)}"`
|
|
516
|
+
: ''
|
|
517
|
+
|
|
518
|
+
// Note body: polygon with top-right corner cut off
|
|
519
|
+
// (x,y) → (x+w-fold,y) → (x+w,y+fold) → (x+w,y+h) → (x,y+h)
|
|
520
|
+
const bodyPoints = [
|
|
521
|
+
`${x},${y}`,
|
|
522
|
+
`${x + w - foldSize},${y}`,
|
|
523
|
+
`${x + w},${y + foldSize}`,
|
|
524
|
+
`${x + w},${y + h}`,
|
|
525
|
+
`${x},${y + h}`,
|
|
526
|
+
].join(' ')
|
|
527
|
+
|
|
528
|
+
return (
|
|
529
|
+
`<g class="note"${positionAttr}${actorsAttr}>` +
|
|
530
|
+
// Note body with bg fill and clipped corner
|
|
531
|
+
`\n <polygon points="${bodyPoints}" ` +
|
|
532
|
+
`fill="var(--bg)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
533
|
+
// Fold triangle (the folded-over corner)
|
|
534
|
+
`\n <polygon points="${x + w - foldSize},${y} ${x + w},${y + foldSize} ${x + w - foldSize},${y + foldSize}" ` +
|
|
535
|
+
`fill="var(--_inner-stroke)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
536
|
+
// Note text (supports multi-line)
|
|
537
|
+
`\n ${renderMultilineText(
|
|
538
|
+
note.text,
|
|
539
|
+
x + w / 2,
|
|
540
|
+
y + h / 2,
|
|
541
|
+
fontSizes.edgeLabel,
|
|
542
|
+
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
543
|
+
)}` +
|
|
544
|
+
`\n</g>`
|
|
545
|
+
)
|
|
546
|
+
}
|