@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.
- package/dist/index.cjs +49 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +1611 -1118
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/c4-mermaid-reference.test.ts +85 -0
- package/src/__tests__/c4-svg.test.ts +337 -0
- package/src/__tests__/class-diamond-markers-1232.test.ts +59 -0
- package/src/__tests__/er-cardinality-markers-1233.test.ts +35 -0
- package/src/__tests__/fixtures/c4-mermaid-reference.json +626 -0
- package/src/__tests__/xychart-smooth-curve.test.ts +42 -0
- package/src/c4/layout.ts +490 -0
- package/src/c4/metrics.ts +235 -0
- package/src/c4/renderer.ts +366 -0
- package/src/class/renderer.ts +72 -68
- package/src/edge-curves.ts +17 -16
- package/src/er/renderer.ts +64 -50
- package/src/layout-engine/elk-graph-builder.ts +4 -0
- package/src/registry.ts +60 -0
- package/src/renderer.ts +190 -189
- package/src/sequence/renderer.ts +66 -65
- package/src/xychart/layout.ts +27 -11
- package/src/xychart/renderer.ts +33 -42
|
@@ -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
|
+
}
|