@zombie-mermaid/svg-renderer 3.0.0 → 3.2.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.
@@ -0,0 +1,235 @@
1
+ import type { C4Element } from '@zombie-mermaid/mermaid-parser'
2
+ import { estimateTextWidth } from '../styles.ts'
3
+
4
+ // ============================================================================
5
+ // C4 SVG geometry, shared by the layout (which sizes and places shapes) and
6
+ // the renderer (which draws text and glyphs inside them), so the two can't
7
+ // drift.
8
+ //
9
+ // The numbers follow Mermaid 11.17's C4 renderer: its default `c4` config for
10
+ // spacing, and sizes fitted to what it draws for each shape (a fixed 216px
11
+ // width, 20px padding, a 14px name, a 0.75em type line and a 0.82em
12
+ // description). Text is measured with this package's own width estimate
13
+ // instead of the browser, so sizes agree with Mermaid's to within a few pixels.
14
+ // ============================================================================
15
+
16
+ export const C4 = {
17
+ // Mermaid's default `c4` config.
18
+ width: 216,
19
+ diagramMarginX: 50,
20
+ diagramMarginY: 10,
21
+ shapeMargin: 50,
22
+ shapePadding: 20,
23
+ shapeInRow: 4,
24
+ boundaryInRow: 2,
25
+ nextLinePaddingX: 0,
26
+ /**
27
+ * Mermaid limits a row to the browser's `screen.availWidth`, which a server
28
+ * cannot know; a common desktop width keeps the output stable.
29
+ */
30
+ screenWidth: 1440,
31
+ // Text.
32
+ nameSize: 14,
33
+ typeSize: 10.5,
34
+ descrSize: 11.5,
35
+ messageSize: 12,
36
+ boundarySize: 14,
37
+ // Line heights fitted to Mermaid: one more name line adds 15.35px, and a
38
+ // name plus its type line make 31.07px; each description line adds 17.1px.
39
+ nameLine: 15.35,
40
+ typeLine: 15.72,
41
+ descrLine: 17.1,
42
+ /** Each wrapped description line after the first (one `1.1em` row). */
43
+ descrExtraLine: 12.48,
44
+ /**
45
+ * Mermaid's text is about 8% narrower than this package's width estimate
46
+ * (its font is Open Sans, this package's is Inter); fitted on a measured
47
+ * name.
48
+ */
49
+ textScale: 0.922,
50
+ // Heights Mermaid measures for a boundary's heading text (fitted): the
51
+ // title at 16px bold, the `[type]` at 14px, a description at 12px.
52
+ boundaryLabelHeight: 18,
53
+ boundaryTypeHeight: 16.1,
54
+ boundaryDescrHeight: 14.4,
55
+ /** Height Mermaid reserves for the unnamed root boundary's heading. */
56
+ rootHeadingHeight: 47,
57
+ /** Extra height added to a diagram with a title (Mermaid's `extraVertForTitle`). */
58
+ titleExtra: 60,
59
+ // Shapes.
60
+ cornerRadius: 12,
61
+ strokeWidth: 2,
62
+ /**
63
+ * Person proportions at the standard width (Mermaid scales the pill's
64
+ * corners and head with the width, and the head's rise a little faster).
65
+ */
66
+ personRx: 38.232,
67
+ personHeadRadius: 49.68,
68
+ /** Distance from the top of a person to the top of its pill (under the head). */
69
+ personPillTop: 85.946,
70
+ personPillTopPerExtraWidth: 0.4,
71
+ /** Cylinder height lost where its text block meets the lower ellipse. */
72
+ dbTrim: 3.91,
73
+ /** Queue height over its text block. */
74
+ queueExtra: 10,
75
+ } as const
76
+
77
+ /**
78
+ * Width the text of an element wraps to. Fitted to Mermaid: a 173px name
79
+ * stays on one line and a 231px one wraps, and a 188px name stays on one.
80
+ */
81
+ export const C4_TEXT_WIDTH = C4.width - C4.shapePadding
82
+
83
+ /** Greedy word wrap by estimated pixel width. */
84
+ export function wrapToWidth(
85
+ text: string,
86
+ maxWidth: number,
87
+ fontSize: number,
88
+ weight: number,
89
+ ): string[] {
90
+ const lines: string[] = []
91
+ for (const paragraph of text.split('\n')) {
92
+ let cur = ''
93
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
94
+ const next = cur ? `${cur} ${word}` : word
95
+ if (
96
+ cur &&
97
+ estimateTextWidth(next, fontSize, weight) * C4.textScale > maxWidth
98
+ ) {
99
+ lines.push(cur)
100
+ cur = word
101
+ } else {
102
+ cur = next
103
+ }
104
+ }
105
+ lines.push(cur)
106
+ }
107
+ return lines
108
+ }
109
+
110
+ /** A person's pill corner, head radius and pill top for a box of this width. */
111
+ export function c4PersonGeometry(width: number): {
112
+ rx: number
113
+ headRadius: number
114
+ pillTop: number
115
+ } {
116
+ const scale = width / C4.width
117
+ return {
118
+ rx: C4.personRx * scale,
119
+ headRadius: C4.personHeadRadius * scale,
120
+ pillTop:
121
+ C4.personPillTop + C4.personPillTopPerExtraWidth * (width - C4.width),
122
+ }
123
+ }
124
+
125
+ export interface C4ShapeSize {
126
+ width: number
127
+ height: number
128
+ /** Height of the text block (name, type, description lines). */
129
+ textHeight: number
130
+ }
131
+
132
+ /** Height of an element's text block for its wrapped lines. */
133
+ export function c4TextBlockHeight(
134
+ nameLines: number,
135
+ descriptionLines: number,
136
+ ): number {
137
+ return (
138
+ nameLines * C4.nameLine +
139
+ C4.typeLine +
140
+ c4DescriptionHeight(descriptionLines)
141
+ )
142
+ }
143
+
144
+ /** Height of a description wrapped to this many lines. */
145
+ export function c4DescriptionHeight(lines: number): number {
146
+ return lines === 0 ? 0 : C4.descrLine + (lines - 1) * C4.descrExtraLine
147
+ }
148
+
149
+ /** Cylinder cap radii for a box of this width: `rx` across, `ry` down. */
150
+ export function c4CylinderCap(width: number): { rx: number; ry: number } {
151
+ const rx = width / 2
152
+ return { rx, ry: rx / (2.5 + width / 50) }
153
+ }
154
+
155
+ /** Queue cap radii for a pipe of this height: `rx` across, `ry` down. */
156
+ export function c4QueueCap(height: number): { rx: number; ry: number } {
157
+ const ry = height / 2
158
+ return { rx: ry / (2.5 + height / 50), ry }
159
+ }
160
+
161
+ /** Estimated width of a text line as Mermaid would measure it. */
162
+ export function c4TextWidth(
163
+ text: string,
164
+ fontSize: number,
165
+ weight: number,
166
+ ): number {
167
+ return estimateTextWidth(text, fontSize, weight) * C4.textScale
168
+ }
169
+
170
+ /**
171
+ * The size Mermaid gives an element: the standard width unless its text is
172
+ * wider (then the text plus padding both sides), and a height that follows
173
+ * its wrapped text lines.
174
+ */
175
+ export function c4ShapeSize(
176
+ el: Pick<C4Element, 'kind' | 'shape'>,
177
+ nameLines: number,
178
+ descriptionLines: number,
179
+ textWidth: number,
180
+ ): C4ShapeSize {
181
+ const textHeight = c4TextBlockHeight(nameLines, descriptionLines)
182
+ const boxHeight = textHeight + 2 * C4.shapePadding
183
+ const width = Math.max(C4.width, textWidth + 2 * C4.shapePadding)
184
+ if (el.kind === 'person') {
185
+ return {
186
+ width,
187
+ // The head rises above the pill, which is as tall as the text needs.
188
+ height: c4PersonGeometry(width).pillTop + boxHeight,
189
+ textHeight,
190
+ }
191
+ }
192
+ if (el.shape === 'db') {
193
+ const { ry } = c4CylinderCap(width)
194
+ return { width, height: boxHeight + 2 * ry - C4.dbTrim, textHeight }
195
+ }
196
+ if (el.shape === 'queue') {
197
+ const height = textHeight + C4.queueExtra
198
+ const { rx } = c4QueueCap(height)
199
+ return { width: width + 3 * rx, height, textHeight }
200
+ }
201
+ return { width, height: boxHeight, textHeight }
202
+ }
203
+
204
+ /** Mermaid's C4 colours by kind and externality. */
205
+ export interface C4Palette {
206
+ fill: string
207
+ stroke: string
208
+ text: string
209
+ }
210
+
211
+ export function c4Palette(el: Pick<C4Element, 'kind' | 'external'>): C4Palette {
212
+ const text = '#ffffff'
213
+ if (el.external) {
214
+ switch (el.kind) {
215
+ case 'person':
216
+ return { fill: '#686868', stroke: '#8a8a8a', text }
217
+ case 'system':
218
+ return { fill: '#999999', stroke: '#8a8a8a', text }
219
+ case 'container':
220
+ return { fill: '#b3b3b3', stroke: '#a6a6a6', text }
221
+ case 'component':
222
+ return { fill: '#cccccc', stroke: '#bfbfbf', text }
223
+ }
224
+ }
225
+ switch (el.kind) {
226
+ case 'person':
227
+ return { fill: '#08427b', stroke: '#073b6f', text }
228
+ case 'system':
229
+ return { fill: '#1168bd', stroke: '#3c7fc0', text }
230
+ case 'container':
231
+ return { fill: '#438dd5', stroke: '#3c7fc0', text }
232
+ case 'component':
233
+ return { fill: '#85bbf0', stroke: '#78a8d8', text }
234
+ }
235
+ }
@@ -0,0 +1,366 @@
1
+ import type {
2
+ PositionedC4Boundary,
3
+ PositionedC4Diagram,
4
+ PositionedC4Element,
5
+ PositionedC4Relationship,
6
+ } from '@zombie-mermaid/mermaid-parser'
7
+ import {
8
+ c4BoundaryTypeLine,
9
+ c4RelLabelLines,
10
+ c4TypeLine,
11
+ } from '@zombie-mermaid/mermaid-parser'
12
+ import type { DiagramColors, Point, SvgEmitOptions } from '@zombie-mermaid/core'
13
+ import {
14
+ svgOpenTag,
15
+ buildStyleBlock,
16
+ escapeXml,
17
+ escapeAttr,
18
+ f,
19
+ } from '@zombie-mermaid/core'
20
+ import { withDataSrc } from '../renderer.ts'
21
+ import { FONT_SIZES, TEXT_BASELINE_SHIFT } from '../styles.ts'
22
+ import type { FontSizes } from '../styles.ts'
23
+ import {
24
+ C4,
25
+ c4CylinderCap,
26
+ c4DescriptionHeight,
27
+ c4Palette,
28
+ c4PersonGeometry,
29
+ c4QueueCap,
30
+ c4TextBlockHeight,
31
+ } from './metrics.ts'
32
+ import type { C4Palette } from './metrics.ts'
33
+
34
+ // ============================================================================
35
+ // C4 diagram SVG renderer, drawn the way Mermaid draws C4.
36
+ //
37
+ // Render order (as in Mermaid): boundaries, elements, then relationships,
38
+ // each with its label on top. Elements use Mermaid's C4 colours and stay
39
+ // fixed across themes, as C4 colours carry meaning; boundaries, lines and
40
+ // labels follow the theme's CSS variables.
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Render a positioned C4 diagram as an SVG string. Parameters mirror
45
+ * `renderErSvg`; see there for the `embedSource` / `title` / `decorative` /
46
+ * `emit` contract.
47
+ */
48
+ export function renderC4Svg(
49
+ diagram: PositionedC4Diagram,
50
+ colors: DiagramColors,
51
+ font: string = 'Inter',
52
+ transparent: boolean = false,
53
+ _fontSizes: FontSizes = FONT_SIZES,
54
+ embedSource?: string,
55
+ title?: string,
56
+ decorative?: boolean,
57
+ emit: SvgEmitOptions = {},
58
+ ): string {
59
+ const parts: string[] = []
60
+ parts.push(
61
+ withDataSrc(
62
+ svgOpenTag(
63
+ diagram.width,
64
+ diagram.height,
65
+ colors,
66
+ transparent,
67
+ title ?? diagram.title,
68
+ decorative,
69
+ undefined,
70
+ emit.styleAttribute,
71
+ ),
72
+ embedSource,
73
+ ),
74
+ )
75
+ parts.push(buildStyleBlock(font, false, emit.nonce))
76
+
77
+ if (diagram.title && diagram.titlePosition) {
78
+ // Mermaid sets the title as plain text at its start point.
79
+ parts.push(
80
+ f`<text class="c4-title" x="${diagram.titlePosition.x}" y="${diagram.titlePosition.y}" dy="${TEXT_BASELINE_SHIFT}" ` +
81
+ f`font-size="16" fill="var(--_text)">${escapeXml(diagram.title)}</text>`,
82
+ )
83
+ }
84
+
85
+ for (const b of diagram.boundaries) parts.push(renderBoundary(b))
86
+ for (const el of diagram.elements) parts.push(renderElement(el))
87
+ for (const r of diagram.relationships) {
88
+ parts.push(renderRelationshipLine(r))
89
+ parts.push(renderRelationshipLabel(r))
90
+ }
91
+
92
+ parts.push('</svg>')
93
+ return parts.filter(Boolean).join('\n')
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Boundaries
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /** One centred line of text. */
101
+ function centred(
102
+ text: string,
103
+ x: number,
104
+ y: number,
105
+ size: number,
106
+ extra: string,
107
+ ): string {
108
+ return (
109
+ f` <text x="${x}" y="${y}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" font-size="${size}" ` +
110
+ f`${extra}>${escapeXml(text)}</text>`
111
+ )
112
+ }
113
+
114
+ function renderBoundary(b: PositionedC4Boundary): string {
115
+ const parts: string[] = []
116
+ parts.push(
117
+ f`<g class="c4-boundary" data-id="${escapeAttr(b.alias)}" data-label="${escapeAttr(b.label)}" data-depth="${b.depth}">`,
118
+ )
119
+ // A dashed square-cornered frame with no fill, as Mermaid draws it.
120
+ parts.push(
121
+ f` <rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="2.5" ry="2.5" ` +
122
+ `fill="none" stroke="var(--_text)" stroke-width="1" stroke-dasharray="7,7" />`,
123
+ )
124
+ const cx = b.x + b.width / 2
125
+ parts.push(
126
+ centred(
127
+ b.label,
128
+ cx,
129
+ b.y + (b.labelY ?? 8),
130
+ C4.boundarySize + 2,
131
+ 'font-weight="700" fill="var(--_text)"',
132
+ ),
133
+ )
134
+ const typeLine = c4BoundaryTypeLine({
135
+ alias: b.alias,
136
+ label: b.label,
137
+ ...(b.type ? { type: b.type } : {}),
138
+ elementAliases: [],
139
+ children: [],
140
+ })
141
+ if (typeLine && b.typeY !== undefined) {
142
+ parts.push(
143
+ centred(
144
+ typeLine,
145
+ cx,
146
+ b.y + b.typeY,
147
+ C4.boundarySize,
148
+ 'fill="var(--_text)"',
149
+ ),
150
+ )
151
+ }
152
+ if (b.description && b.descrY !== undefined) {
153
+ parts.push(
154
+ centred(
155
+ b.description,
156
+ cx,
157
+ b.y + b.descrY,
158
+ C4.boundarySize - 2,
159
+ 'fill="var(--_text)"',
160
+ ),
161
+ )
162
+ }
163
+ parts.push('</g>')
164
+ return parts.join('\n')
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Elements
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function renderElement(el: PositionedC4Element): string {
172
+ const pal = c4Palette(el)
173
+ const { x, y, width: w, height: h } = el
174
+ const cx = x + w / 2
175
+ const textH = c4TextBlockHeight(
176
+ el.nameLines.length,
177
+ el.descriptionLines.length,
178
+ )
179
+ const parts: string[] = []
180
+ parts.push(
181
+ f`<g class="c4-element" data-id="${escapeAttr(el.alias)}" data-kind="${el.kind}" data-shape="${el.shape}" ` +
182
+ f`data-external="${el.external}" data-label="${escapeAttr(el.label)}">`,
183
+ )
184
+
185
+ // Where the text block starts, and the x it is centred on.
186
+ let top: number
187
+ let textCx = cx
188
+ if (el.kind === 'person') {
189
+ parts.push(...personShape(el, pal))
190
+ const g = c4PersonGeometry(w)
191
+ top = y + g.pillTop + C4.shapePadding
192
+ } else if (el.shape === 'db') {
193
+ parts.push(...cylinderShape(el, pal))
194
+ const { ry } = c4CylinderCap(w)
195
+ // Centred in the body below the upper ellipse.
196
+ top = y + (2 * ry + h) / 2 - textH / 2
197
+ } else if (el.shape === 'queue') {
198
+ parts.push(...queueShape(el, pal))
199
+ const { rx } = c4QueueCap(h)
200
+ // Mermaid centres the text one cap radius left of the pipe's centre.
201
+ textCx = cx - rx
202
+ top = y + h / 2 - textH / 2
203
+ } else {
204
+ parts.push(
205
+ f` <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${C4.cornerRadius}" ry="${C4.cornerRadius}" ` +
206
+ f`fill="${pal.fill}" stroke="${pal.stroke}" stroke-width="${C4.strokeWidth}" />`,
207
+ )
208
+ top = y + C4.shapePadding
209
+ }
210
+
211
+ let cursor = top
212
+ for (const line of el.nameLines) {
213
+ parts.push(
214
+ centred(
215
+ line,
216
+ textCx,
217
+ cursor + C4.nameLine / 2,
218
+ C4.nameSize,
219
+ f`font-weight="700" fill="${pal.text}"`,
220
+ ),
221
+ )
222
+ cursor += C4.nameLine
223
+ }
224
+ parts.push(
225
+ centred(
226
+ c4TypeLine(el),
227
+ textCx,
228
+ cursor + C4.typeLine / 2,
229
+ C4.typeSize,
230
+ f`fill="${pal.text}"`,
231
+ ),
232
+ )
233
+ cursor += C4.typeLine
234
+ el.descriptionLines.forEach((line, i) => {
235
+ // The first line is as tall as Mermaid measures a one-line text; each
236
+ // wrapped line after it is one `1.1em` row.
237
+ parts.push(
238
+ centred(
239
+ line,
240
+ textCx,
241
+ cursor + C4.descrLine / 2 + i * C4.descrExtraLine,
242
+ C4.descrSize,
243
+ f`fill="${pal.text}"`,
244
+ ),
245
+ )
246
+ })
247
+ cursor += c4DescriptionHeight(el.descriptionLines.length)
248
+ parts.push('</g>')
249
+ return parts.join('\n')
250
+ }
251
+
252
+ /** A rounded pill with a round head rising out of its top, as Mermaid draws a person. */
253
+ function personShape(el: PositionedC4Element, pal: C4Palette): string[] {
254
+ const { x, width: w } = el
255
+ const { rx, headRadius, pillTop: rise } = c4PersonGeometry(w)
256
+ const y = el.y
257
+ const cx = x + w / 2
258
+ const pillTop = y + rise
259
+ const pillHeight = el.y + el.height - pillTop
260
+ const style = f`fill="${pal.fill}" stroke="${pal.stroke}" stroke-width="${C4.strokeWidth}"`
261
+ return [
262
+ f` <rect x="${x}" y="${pillTop}" width="${w}" height="${pillHeight}" rx="${rx}" ry="${rx}" ${style} />`,
263
+ f` <circle cx="${cx}" cy="${y + headRadius}" r="${headRadius}" ${style} />`,
264
+ ]
265
+ }
266
+
267
+ function cylinderShape(el: PositionedC4Element, pal: C4Palette): string[] {
268
+ const { x, y, width: w, height: h } = el
269
+ const { rx, ry } = c4CylinderCap(w)
270
+ const style = f`fill="${pal.fill}" stroke="${pal.stroke}" stroke-width="${C4.strokeWidth}"`
271
+ return [
272
+ f` <path d="M ${x} ${y + ry} L ${x} ${y + h - ry} A ${rx} ${ry} 0 0 0 ${x + w} ${y + h - ry} L ${x + w} ${y + ry} Z" ${style} />`,
273
+ f` <ellipse cx="${x + rx}" cy="${y + ry}" rx="${rx}" ry="${ry}" ${style} />`,
274
+ ]
275
+ }
276
+
277
+ /**
278
+ * A pipe on its side, as Mermaid draws it: rounded at the left, with the
279
+ * ellipse face showing at the right end.
280
+ */
281
+ function queueShape(el: PositionedC4Element, pal: C4Palette): string[] {
282
+ const { x, y, width: w, height: h } = el
283
+ const { rx, ry } = c4QueueCap(h)
284
+ const style = f`fill="${pal.fill}" stroke="${pal.stroke}" stroke-width="${C4.strokeWidth}"`
285
+ return [
286
+ f` <path d="M ${x + w - rx} ${y} H ${x + rx} A ${rx} ${ry} 0 0 0 ${x + rx} ${y + h} H ${x + w - rx} Z" ${style} />`,
287
+ f` <ellipse cx="${x + w - rx}" cy="${y + ry}" rx="${rx}" ry="${ry}" ${style} />`,
288
+ ]
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Relationships
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /** Mermaid's arrowhead: a 10 by 10 triangle whose tip sits on the line's end. */
296
+ function arrowHead(tip: Point, from: Point): string {
297
+ const dx = tip.x - from.x
298
+ const dy = tip.y - from.y
299
+ const len = Math.hypot(dx, dy)
300
+ if (len === 0) return ''
301
+ const ux = dx / len
302
+ const uy = dy / len
303
+ const L = 10
304
+ const W = 5
305
+ const bx = tip.x - ux * L
306
+ const by = tip.y - uy * L
307
+ return (
308
+ f`<polygon class="c4-arrowhead" points="${tip.x},${tip.y} ${bx - uy * W},${by + ux * W} ${bx + uy * W},${by - ux * W}" ` +
309
+ `fill="var(--_text)" />`
310
+ )
311
+ }
312
+
313
+ function renderRelationshipLine(rel: PositionedC4Relationship): string {
314
+ const [start, end] = [rel.points[0], rel.points[rel.points.length - 1]]
315
+ if (!start || !end || start === end) return ''
316
+ const attrs = [
317
+ 'class="c4-relationship"',
318
+ f`data-from="${escapeAttr(rel.from)}"`,
319
+ f`data-to="${escapeAttr(rel.to)}"`,
320
+ ]
321
+ if (rel.label) attrs.push(f`data-label="${escapeAttr(rel.label)}"`)
322
+ if (rel.technology) {
323
+ attrs.push(f`data-technology="${escapeAttr(rel.technology)}"`)
324
+ }
325
+ const d = rel.curve
326
+ ? f`M ${start.x},${start.y} Q ${rel.curve.x},${rel.curve.y} ${end.x},${end.y}`
327
+ : f`M ${start.x},${start.y} L ${end.x},${end.y}`
328
+ const line = f`<path ${attrs.join(' ')} d="${d}" fill="none" stroke="var(--_text)" stroke-width="1" />`
329
+ // A curve leaves and arrives along its control point.
330
+ const beforeEnd = rel.curve ?? start
331
+ const afterStart = rel.curve ?? end
332
+ const endHead = arrowHead(end, beforeEnd)
333
+ const startHead = arrowHead(start, afterStart)
334
+ const heads = rel.bidirectional
335
+ ? [endHead, startHead]
336
+ : [rel.reversed ? startHead : endHead]
337
+ return [line, ...heads].filter(Boolean).join('\n')
338
+ }
339
+
340
+ /** Plain text at the middle of the chord: the label, then `[technology]` below. */
341
+ function renderRelationshipLabel(rel: PositionedC4Relationship): string {
342
+ const lines = c4RelLabelLines(rel)
343
+ if (lines.length === 0 || !rel.labelPosition) return ''
344
+ const { x, y } = rel.labelPosition
345
+ const parts = [
346
+ f`<g class="c4-relationship-label" data-from="${escapeAttr(rel.from)}" data-to="${escapeAttr(rel.to)}">`,
347
+ ]
348
+ lines.forEach((line, i) => {
349
+ const isTech = rel.technology !== undefined && i === lines.length - 1
350
+ // Mermaid puts the technology one font size plus 5px below the label.
351
+ const cy = y + (i === 0 ? 0 : C4.messageSize + 5)
352
+ parts.push(
353
+ centred(
354
+ line,
355
+ isTech && rel.technologyX !== undefined ? rel.technologyX : x,
356
+ cy,
357
+ C4.messageSize,
358
+ isTech
359
+ ? 'font-style="italic" fill="var(--_text)"'
360
+ : 'fill="var(--_text)"',
361
+ ),
362
+ )
363
+ })
364
+ parts.push('</g>')
365
+ return parts.join('\n')
366
+ }