@zombie-mermaid/svg-renderer 4.2.2 → 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 (72) 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 +897 -849
  7. package/dist/index.js.map +1 -1
  8. package/package.json +14 -5
  9. package/src/__tests__/c4-label-clearance-1290.test.ts +0 -112
  10. package/src/__tests__/c4-line-ends-1208.test.ts +0 -52
  11. package/src/__tests__/c4-mermaid-reference.test.ts +0 -124
  12. package/src/__tests__/c4-svg.test.ts +0 -337
  13. package/src/__tests__/c4-text-width-1210.test.ts +0 -43
  14. package/src/__tests__/class-component-order-1249.test.ts +0 -40
  15. package/src/__tests__/class-diamond-markers-1232.test.ts +0 -59
  16. package/src/__tests__/class-member-right-padding-1238.test.ts +0 -43
  17. package/src/__tests__/class-namespace-1196.test.ts +0 -165
  18. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +0 -96
  19. package/src/__tests__/class-visibility-markers-1237.test.ts +0 -27
  20. package/src/__tests__/edge-jog-straightening-1388.test.ts +0 -57
  21. package/src/__tests__/elk-adapter-utils.test.ts +0 -166
  22. package/src/__tests__/er-attribute-right-padding-1262.test.ts +0 -47
  23. package/src/__tests__/er-cardinality-markers-1233.test.ts +0 -35
  24. package/src/__tests__/er-default-direction-1250.test.ts +0 -69
  25. package/src/__tests__/er-edge-label-contrast-1244.test.ts +0 -58
  26. package/src/__tests__/fixtures/c4-mermaid-reference.json +0 -626
  27. package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +0 -168
  28. package/src/__tests__/pie-layout.test.ts +0 -215
  29. package/src/__tests__/pie-svg.test.ts +0 -194
  30. package/src/__tests__/render-mermaid-svg.test.ts +0 -64
  31. package/src/__tests__/sequence-label-clearance-1242.test.ts +0 -162
  32. package/src/__tests__/sequence-nested-activation-1241.test.ts +0 -37
  33. package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +0 -32
  34. package/src/__tests__/xychart-smooth-curve.test.ts +0 -42
  35. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +0 -62
  36. package/src/c4/arial-widths.ts +0 -59
  37. package/src/c4/layout.ts +0 -680
  38. package/src/c4/metrics.ts +0 -261
  39. package/src/c4/renderer.ts +0 -366
  40. package/src/class/layout.ts +0 -525
  41. package/src/class/renderer.ts +0 -669
  42. package/src/edge-curves.ts +0 -205
  43. package/src/elk-instance.ts +0 -292
  44. package/src/er/layout.ts +0 -216
  45. package/src/er/renderer.ts +0 -516
  46. package/src/generated/mono-font-subset.ts +0 -32
  47. package/src/index.ts +0 -277
  48. package/src/layout-engine/back-edges.ts +0 -73
  49. package/src/layout-engine/compound-flat.ts +0 -916
  50. package/src/layout-engine/constants.ts +0 -19
  51. package/src/layout-engine/edge-bundling.ts +0 -458
  52. package/src/layout-engine/elk-adapter-utils.ts +0 -81
  53. package/src/layout-engine/elk-graph-builder.ts +0 -245
  54. package/src/layout-engine/from-elk.ts +0 -767
  55. package/src/layout-engine/inner-edges.ts +0 -329
  56. package/src/layout-engine/jog-straightening.ts +0 -52
  57. package/src/layout-engine/layer-alignment.ts +0 -174
  58. package/src/layout-engine/layout-hints.ts +0 -27
  59. package/src/layout-engine/to-elk.ts +0 -855
  60. package/src/layout-engine.ts +0 -115
  61. package/src/pie/layout.ts +0 -234
  62. package/src/pie/renderer.ts +0 -161
  63. package/src/registry.ts +0 -441
  64. package/src/renderer.ts +0 -1510
  65. package/src/resolve-colors.ts +0 -339
  66. package/src/sequence/layout.ts +0 -766
  67. package/src/sequence/renderer.ts +0 -687
  68. package/src/shape-clipping.ts +0 -197
  69. package/src/styles.ts +0 -145
  70. package/src/title-gaps.ts +0 -161
  71. package/src/xychart/layout.ts +0 -727
  72. 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
- }