@zombie-mermaid/svg-renderer 4.2.3 → 5.0.0

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