@zombie-mermaid/svg-renderer 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +524 -0
- package/dist/index.d.ts +524 -0
- package/dist/index.js +2994 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/elk-adapter-utils.test.ts +166 -0
- package/src/class/layout.ts +360 -0
- package/src/class/renderer.ts +636 -0
- package/src/edge-curves.ts +204 -0
- package/src/elk-instance.ts +292 -0
- package/src/er/layout.ts +200 -0
- package/src/er/renderer.ts +493 -0
- package/src/index.ts +58 -0
- package/src/layout-engine/constants.ts +19 -0
- package/src/layout-engine/edge-bundling.ts +379 -0
- package/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/src/layout-engine/elk-graph-builder.ts +240 -0
- package/src/layout-engine/from-elk.ts +685 -0
- package/src/layout-engine/layer-alignment.ts +174 -0
- package/src/layout-engine/to-elk.ts +695 -0
- package/src/layout-engine.ts +74 -0
- package/src/layout.ts +8 -0
- package/src/renderer.ts +1485 -0
- package/src/resolve-colors.ts +339 -0
- package/src/sequence/layout.ts +698 -0
- package/src/sequence/renderer.ts +546 -0
- package/src/shape-clipping.ts +197 -0
- package/src/styles.ts +118 -0
- package/src/xychart/layout.ts +682 -0
- package/src/xychart/renderer.ts +684 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PositionedClassDiagram,
|
|
3
|
+
PositionedClassNode,
|
|
4
|
+
PositionedClassNote,
|
|
5
|
+
PositionedClassRelationship,
|
|
6
|
+
ClassMember,
|
|
7
|
+
RelationshipType,
|
|
8
|
+
} from '@zombie-mermaid/mermaid-parser'
|
|
9
|
+
import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
|
|
10
|
+
import {
|
|
11
|
+
svgOpenTag,
|
|
12
|
+
buildStyleBlock,
|
|
13
|
+
getReadableTextColor,
|
|
14
|
+
sanitizeClassName,
|
|
15
|
+
renderMultilineText,
|
|
16
|
+
escapeXml as escapeXmlUtil,
|
|
17
|
+
escapeAttr,
|
|
18
|
+
safeHref,
|
|
19
|
+
} from '@zombie-mermaid/core'
|
|
20
|
+
import { withDataSrc } from '../renderer.ts'
|
|
21
|
+
import {
|
|
22
|
+
FONT_SIZES,
|
|
23
|
+
FONT_WEIGHTS,
|
|
24
|
+
STROKE_WIDTHS,
|
|
25
|
+
TEXT_BASELINE_SHIFT,
|
|
26
|
+
} from '../styles.ts'
|
|
27
|
+
import type { FontSizes } from '../styles.ts'
|
|
28
|
+
import { CLS } from './layout.ts'
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Class diagram SVG renderer
|
|
32
|
+
//
|
|
33
|
+
// Renders positioned class diagrams to SVG.
|
|
34
|
+
// All colors use CSS custom properties (var(--_xxx)) from the theme system.
|
|
35
|
+
//
|
|
36
|
+
// Render order:
|
|
37
|
+
// 1. Relationship lines and note links (behind boxes)
|
|
38
|
+
// 2. Class boxes (header + attributes + methods compartments)
|
|
39
|
+
// 3. Notes (dog-eared boxes)
|
|
40
|
+
// 4. Relationship labels and cardinality
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
/** Font sizes specific to class diagrams */
|
|
44
|
+
const CLS_FONT = {
|
|
45
|
+
memberSize: 11,
|
|
46
|
+
memberWeight: 400,
|
|
47
|
+
annotationSize: 10,
|
|
48
|
+
annotationWeight: 500,
|
|
49
|
+
} as const
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render a positioned class diagram as an SVG string.
|
|
53
|
+
*
|
|
54
|
+
* @param colors - DiagramColors with bg/fg and optional enrichment variables.
|
|
55
|
+
* @param transparent - If true, renders with transparent background.
|
|
56
|
+
* @param embedSource - Original diagram source to stamp onto the root `<svg>`
|
|
57
|
+
* as `data-src` (from `options.embedSource`). Omitted
|
|
58
|
+
* when the option is off.
|
|
59
|
+
* @param title - Accessible name (from `options.title`). See svgOpenTag() in
|
|
60
|
+
* packages/core/src/theme.ts.
|
|
61
|
+
* @param decorative - Marks the SVG decorative (from `options.decorative`).
|
|
62
|
+
* @param linksEnabled - Whether `click`-based `<a href>` links and `<title>`
|
|
63
|
+
* tooltips render (from `options.interactivity !==
|
|
64
|
+
* 'none'`, see `resolveLinksEnabled` in
|
|
65
|
+
* src/diagram-registry.ts).
|
|
66
|
+
* Default true — matches the flowchart/state renderer.
|
|
67
|
+
* @param emit - Strict-CSP controls (from `options.nonce` /
|
|
68
|
+
* `options.styleAttribute`, see #216). Default: no nonce,
|
|
69
|
+
* root `style` attribute on.
|
|
70
|
+
*/
|
|
71
|
+
export function renderClassSvg(
|
|
72
|
+
diagram: PositionedClassDiagram,
|
|
73
|
+
colors: DiagramColors,
|
|
74
|
+
font: string = 'Inter',
|
|
75
|
+
transparent: boolean = false,
|
|
76
|
+
fontSizes: FontSizes = FONT_SIZES,
|
|
77
|
+
embedSource?: string,
|
|
78
|
+
title?: string,
|
|
79
|
+
decorative?: boolean,
|
|
80
|
+
linksEnabled: boolean = true,
|
|
81
|
+
emit: SvgEmitOptions = {},
|
|
82
|
+
): string {
|
|
83
|
+
const parts: string[] = []
|
|
84
|
+
|
|
85
|
+
// See #239 / packages/svg-renderer/src/renderer.ts's renderSvg: a click-based link renders as a
|
|
86
|
+
// focusable <a href> inside the SVG, which role="img"/aria-hidden would
|
|
87
|
+
// hide from assistive tech while leaving it Tab-reachable — svgOpenTag
|
|
88
|
+
// forces no root role in that case. Gated by linksEnabled too, since
|
|
89
|
+
// `interactivity: 'none'` strips the <a> below.
|
|
90
|
+
const hasInteractiveLinks =
|
|
91
|
+
linksEnabled &&
|
|
92
|
+
diagram.classes.some((c) => Boolean(safeHref(c.interaction?.href)))
|
|
93
|
+
|
|
94
|
+
// SVG root with CSS variables + style block (with mono font) + defs
|
|
95
|
+
parts.push(
|
|
96
|
+
withDataSrc(
|
|
97
|
+
svgOpenTag(
|
|
98
|
+
diagram.width,
|
|
99
|
+
diagram.height,
|
|
100
|
+
colors,
|
|
101
|
+
transparent,
|
|
102
|
+
title,
|
|
103
|
+
decorative,
|
|
104
|
+
hasInteractiveLinks,
|
|
105
|
+
emit.styleAttribute,
|
|
106
|
+
),
|
|
107
|
+
embedSource,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
parts.push(buildStyleBlock(font, true, emit.nonce))
|
|
111
|
+
parts.push('<defs>')
|
|
112
|
+
parts.push(relationshipMarkerDefs())
|
|
113
|
+
parts.push('</defs>')
|
|
114
|
+
|
|
115
|
+
// 1. Relationship lines and note links (rendered behind boxes)
|
|
116
|
+
for (const rel of diagram.relationships) {
|
|
117
|
+
parts.push(renderRelationship(rel))
|
|
118
|
+
}
|
|
119
|
+
for (const note of diagram.notes) {
|
|
120
|
+
parts.push(renderNoteLink(note))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 2. Class boxes
|
|
124
|
+
for (const cls of diagram.classes) {
|
|
125
|
+
parts.push(renderClassBox(cls, fontSizes, linksEnabled))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 3. Notes
|
|
129
|
+
for (const note of diagram.notes) {
|
|
130
|
+
parts.push(renderNote(note, fontSizes))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 4. Relationship labels and cardinality
|
|
134
|
+
for (const rel of diagram.relationships) {
|
|
135
|
+
parts.push(renderRelationshipLabels(rel, fontSizes))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
parts.push('</svg>')
|
|
139
|
+
return parts.join('\n')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// Marker definitions
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Marker definitions for class relationship endpoints.
|
|
148
|
+
* Each relationship type has a distinct marker:
|
|
149
|
+
* - inheritance: hollow triangle
|
|
150
|
+
* - composition: filled diamond
|
|
151
|
+
* - aggregation: hollow diamond
|
|
152
|
+
* - association: open arrow (simple >)
|
|
153
|
+
* - dependency: open arrow (simple >)
|
|
154
|
+
* - realization: hollow triangle (same as inheritance)
|
|
155
|
+
*
|
|
156
|
+
* Uses var(--_arrow) for fill/stroke and var(--bg) for hollow marker fills.
|
|
157
|
+
*/
|
|
158
|
+
function relationshipMarkerDefs(): string {
|
|
159
|
+
return (
|
|
160
|
+
// Hollow triangle (inheritance, realization) — points at target
|
|
161
|
+
` <marker id="cls-inherit" markerWidth="12" markerHeight="10" refX="12" refY="5" orient="auto-start-reverse">` +
|
|
162
|
+
`\n <polygon points="0 0, 12 5, 0 10" fill="var(--bg)" stroke="var(--_arrow)" stroke-width="1.5" />` +
|
|
163
|
+
`\n </marker>` +
|
|
164
|
+
// Filled diamond (composition) — points at source
|
|
165
|
+
`\n <marker id="cls-composition" markerWidth="12" markerHeight="10" refX="0" refY="5" orient="auto-start-reverse">` +
|
|
166
|
+
`\n <polygon points="6 0, 12 5, 6 10, 0 5" fill="var(--_arrow)" stroke="var(--_arrow)" stroke-width="1" />` +
|
|
167
|
+
`\n </marker>` +
|
|
168
|
+
// Hollow diamond (aggregation) — points at source
|
|
169
|
+
`\n <marker id="cls-aggregation" markerWidth="12" markerHeight="10" refX="0" refY="5" orient="auto-start-reverse">` +
|
|
170
|
+
`\n <polygon points="6 0, 12 5, 6 10, 0 5" fill="var(--bg)" stroke="var(--_arrow)" stroke-width="1.5" />` +
|
|
171
|
+
`\n </marker>` +
|
|
172
|
+
// Open arrow (association, dependency)
|
|
173
|
+
`\n <marker id="cls-arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto-start-reverse">` +
|
|
174
|
+
`\n <polyline points="0 0, 8 3, 0 6" fill="none" stroke="var(--_arrow)" stroke-width="1.5" />` +
|
|
175
|
+
`\n </marker>`
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// Class box rendering
|
|
181
|
+
// ============================================================================
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Render a class box with 3 compartments: header, attributes, methods.
|
|
185
|
+
* Wrapped in <g class="class-node"> with semantic data attributes.
|
|
186
|
+
*
|
|
187
|
+
* @param linksEnabled - Whether a `click`-based `<a href>` link and `<title>`
|
|
188
|
+
* tooltip render (from `options.interactivity !==
|
|
189
|
+
* 'none'`). Default true. Mirrors renderNode() in
|
|
190
|
+
* packages/svg-renderer/src/renderer.ts — see that function's docstring and
|
|
191
|
+
* docs/decisions/no-script-interactivity.md for why a
|
|
192
|
+
* `call`/callback binding is recorded as a data
|
|
193
|
+
* attribute rather than ever invoked.
|
|
194
|
+
*/
|
|
195
|
+
function renderClassBox(
|
|
196
|
+
cls: PositionedClassNode,
|
|
197
|
+
fontSizes: FontSizes,
|
|
198
|
+
linksEnabled: boolean = true,
|
|
199
|
+
): string {
|
|
200
|
+
const { x, y, width, height, headerHeight, attrHeight, inlineStyle } = cls
|
|
201
|
+
const parts: string[] = []
|
|
202
|
+
|
|
203
|
+
const interaction = cls.interaction
|
|
204
|
+
|
|
205
|
+
// Resolve box colors — inline styles (from `classDef`/`cssClass`/`style`)
|
|
206
|
+
// override the CSS-variable defaults the same way renderNodeShape() in
|
|
207
|
+
// packages/svg-renderer/src/renderer.ts does for flowchart nodes. With no inline style the
|
|
208
|
+
// variables keep deriving from the theme via color-mix(), so dark mode is
|
|
209
|
+
// untouched; a concrete `fill` is used verbatim, exactly as Mermaid does.
|
|
210
|
+
const fill = escapeAttr(inlineStyle?.fill ?? 'var(--_node-fill)')
|
|
211
|
+
const stroke = escapeAttr(inlineStyle?.stroke ?? 'var(--_node-stroke)')
|
|
212
|
+
const strokeWidth = escapeAttr(
|
|
213
|
+
inlineStyle?.['stroke-width'] ?? String(STROKE_WIDTHS.outerBox),
|
|
214
|
+
)
|
|
215
|
+
// Mermaid paints the whole class box one color, so a custom fill replaces
|
|
216
|
+
// the header band too rather than leaving the theme's band on top of it.
|
|
217
|
+
const headerFill = inlineStyle?.fill ? fill : 'var(--_group-hdr)'
|
|
218
|
+
// An explicit `color:` wins; otherwise a concrete custom fill gets a
|
|
219
|
+
// black/white text color chosen for contrast (see getReadableTextColor,
|
|
220
|
+
// issue #55). Left undefined when neither applies so the syntax-colored
|
|
221
|
+
// member tspans keep their theme defaults.
|
|
222
|
+
const textColor = inlineStyle?.color
|
|
223
|
+
? escapeAttr(inlineStyle.color)
|
|
224
|
+
: inlineStyle?.fill
|
|
225
|
+
? escapeAttr(getReadableTextColor(inlineStyle.fill, 'var(--_text)'))
|
|
226
|
+
: undefined
|
|
227
|
+
|
|
228
|
+
// Semantic wrapper with class metadata
|
|
229
|
+
// data-id: class identifier
|
|
230
|
+
// data-label: class name
|
|
231
|
+
// data-annotation: stereotype (interface, abstract, etc.)
|
|
232
|
+
const annotationAttr = cls.annotation
|
|
233
|
+
? ` data-annotation="${escapeAttr(cls.annotation)}"`
|
|
234
|
+
: ''
|
|
235
|
+
// A style class from `cssClass`/`class A name`/`:::name` is emitted onto
|
|
236
|
+
// the group so external CSS can target it, after the same allowlist the
|
|
237
|
+
// flowchart renderer applies.
|
|
238
|
+
const safeClassName = sanitizeClassName(cls.className)
|
|
239
|
+
const groupAttrs = [
|
|
240
|
+
`class="${safeClassName ? `class-node ${safeClassName}` : 'class-node'}"`,
|
|
241
|
+
`data-id="${escapeAttr(cls.id)}"`,
|
|
242
|
+
`data-label="${escapeAttr(cls.label)}"`,
|
|
243
|
+
]
|
|
244
|
+
// `click ClassName call fn()` is parsed, never invoked, and never written
|
|
245
|
+
// into the markup — see renderNode() in packages/svg-renderer/src/renderer.ts for the rationale.
|
|
246
|
+
parts.push(`<g ${groupAttrs.join(' ')}${annotationAttr}>`)
|
|
247
|
+
|
|
248
|
+
// An href becomes a real SVG link, which needs no script to work.
|
|
249
|
+
// `interactivity: 'none'` strips it — a link is meaningless in
|
|
250
|
+
// print/rasterized output, the target that level is meant for.
|
|
251
|
+
const href = linksEnabled ? safeHref(interaction?.href) : undefined
|
|
252
|
+
if (href) {
|
|
253
|
+
const targetAttr = interaction?.target
|
|
254
|
+
? ` target="${escapeAttr(interaction.target)}"`
|
|
255
|
+
: ''
|
|
256
|
+
parts.push(` <a href="${escapeAttr(href)}"${targetAttr}>`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (linksEnabled && interaction?.tooltip) {
|
|
260
|
+
// <title> is SVG's native tooltip — no script, no CSS. Gated by
|
|
261
|
+
// linksEnabled alongside href above: both come from the same `click`
|
|
262
|
+
// statement, and 'none' strips both.
|
|
263
|
+
parts.push(` <title>${escapeXml(interaction.tooltip)}</title>`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Outer rectangle (full box)
|
|
267
|
+
parts.push(
|
|
268
|
+
` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
|
|
269
|
+
`rx="0" ry="0" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
// Header background
|
|
273
|
+
parts.push(
|
|
274
|
+
` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
|
|
275
|
+
`rx="0" ry="0" fill="${headerFill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
// Annotation (<<interface>>, <<abstract>>, etc.)
|
|
279
|
+
let nameY = y + headerHeight / 2
|
|
280
|
+
if (cls.annotation) {
|
|
281
|
+
const annotY = y + 12
|
|
282
|
+
parts.push(
|
|
283
|
+
` <text x="${x + width / 2}" y="${annotY}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
284
|
+
`font-size="${CLS_FONT.annotationSize}" font-weight="${CLS_FONT.annotationWeight}" ` +
|
|
285
|
+
`font-style="italic" fill="${textColor ?? 'var(--_text-muted)'}"><<${escapeXml(cls.annotation)}>></text>`,
|
|
286
|
+
)
|
|
287
|
+
nameY = y + headerHeight / 2 + 6
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Class name (supports multi-line via <br> tags)
|
|
291
|
+
parts.push(
|
|
292
|
+
' ' +
|
|
293
|
+
renderMultilineText(
|
|
294
|
+
cls.label,
|
|
295
|
+
x + width / 2,
|
|
296
|
+
nameY,
|
|
297
|
+
fontSizes.nodeLabel,
|
|
298
|
+
`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="${textColor ?? 'var(--_text)'}"`,
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// Divider line between header and attributes
|
|
303
|
+
const attrTop = y + headerHeight
|
|
304
|
+
parts.push(
|
|
305
|
+
` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
|
|
306
|
+
`stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
// Attributes
|
|
310
|
+
const memberRowH = 20
|
|
311
|
+
for (let i = 0; i < cls.attributes.length; i++) {
|
|
312
|
+
const member = cls.attributes[i]!
|
|
313
|
+
const memberY = attrTop + 4 + i * memberRowH + memberRowH / 2
|
|
314
|
+
parts.push(' ' + renderMember(member, x + CLS.boxPadX, memberY, textColor))
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Divider line between attributes and methods
|
|
318
|
+
const methodTop = attrTop + attrHeight
|
|
319
|
+
parts.push(
|
|
320
|
+
` <line x1="${x}" y1="${methodTop}" x2="${x + width}" y2="${methodTop}" ` +
|
|
321
|
+
`stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
// Methods
|
|
325
|
+
for (let i = 0; i < cls.methods.length; i++) {
|
|
326
|
+
const member = cls.methods[i]!
|
|
327
|
+
const memberY = methodTop + 4 + i * memberRowH + memberRowH / 2
|
|
328
|
+
parts.push(' ' + renderMember(member, x + CLS.boxPadX, memberY, textColor))
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (href) parts.push(' </a>')
|
|
332
|
+
parts.push('</g>')
|
|
333
|
+
|
|
334
|
+
return parts.join('\n')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Render a single class member with syntax highlighting.
|
|
339
|
+
* Uses <tspan> elements to color each part of the member differently:
|
|
340
|
+
* - visibility symbol (+/-/#/~) → textFaint
|
|
341
|
+
* - member name (incl. parens for methods) → textSecondary
|
|
342
|
+
* - colon separator → textFaint
|
|
343
|
+
* - type annotation → textMuted
|
|
344
|
+
*
|
|
345
|
+
* @param textColor - A resolved custom text color (from `style ... color:` or
|
|
346
|
+
* derived from a custom `fill`). When given, every tspan
|
|
347
|
+
* uses it: the per-part theme tints are tuned against the
|
|
348
|
+
* theme's own node fill and can't be assumed readable on
|
|
349
|
+
* an arbitrary user-chosen background.
|
|
350
|
+
*/
|
|
351
|
+
function renderMember(
|
|
352
|
+
member: ClassMember,
|
|
353
|
+
x: number,
|
|
354
|
+
y: number,
|
|
355
|
+
textColor?: string,
|
|
356
|
+
): string {
|
|
357
|
+
const fontStyle = member.isAbstract ? ' font-style="italic"' : ''
|
|
358
|
+
const decoration = member.isStatic ? ' text-decoration="underline"' : ''
|
|
359
|
+
|
|
360
|
+
const faint = textColor ?? 'var(--_text-faint)'
|
|
361
|
+
const secondary = textColor ?? 'var(--_text-sec)'
|
|
362
|
+
const muted = textColor ?? 'var(--_text-muted)'
|
|
363
|
+
|
|
364
|
+
// Build tspan parts for syntax-highlighted member text
|
|
365
|
+
const spans: string[] = []
|
|
366
|
+
|
|
367
|
+
if (member.visibility) {
|
|
368
|
+
spans.push(
|
|
369
|
+
`<tspan fill="${faint}">${escapeXml(member.visibility)} </tspan>`,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Add parentheses for methods to distinguish from attributes, including parameters if present
|
|
374
|
+
const displayName = member.isMethod
|
|
375
|
+
? `${member.name}(${member.params || ''})`
|
|
376
|
+
: member.name
|
|
377
|
+
// False positive: displayName is passed through escapeXml() (see packages/core/src/multiline-utils.ts),
|
|
378
|
+
// which escapes &, <, >, ", ' before interpolation, so this is not raw/unescaped HTML.
|
|
379
|
+
spans.push(`<tspan fill="${secondary}">${escapeXml(displayName)}</tspan>`) // nosemgrep: javascript.express.security.injection.raw-html-format.raw-html-format
|
|
380
|
+
|
|
381
|
+
if (member.type) {
|
|
382
|
+
spans.push(`<tspan fill="${faint}">: </tspan>`)
|
|
383
|
+
spans.push(`<tspan fill="${muted}">${escapeXml(member.type)}</tspan>`)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
`<text x="${x}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
388
|
+
`font-size="${CLS_FONT.memberSize}" font-weight="${CLS_FONT.memberWeight}"${fontStyle}${decoration}>` +
|
|
389
|
+
`${spans.join('')}</text>`
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ============================================================================
|
|
394
|
+
// Note rendering
|
|
395
|
+
// ============================================================================
|
|
396
|
+
|
|
397
|
+
/** Size of the folded corner on a note box, in px */
|
|
398
|
+
const NOTE_FOLD = 6
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Render a note as a dog-eared box: a polygon with its top-right corner
|
|
402
|
+
* clipped plus a small fold triangle — the same shape the sequence renderer
|
|
403
|
+
* draws for its notes (../sequence/renderer.ts renderNote), so notes look
|
|
404
|
+
* alike across diagram types. Wrapped in <g class="class-note"> with
|
|
405
|
+
* `data-for` naming the class it's attached to, if any.
|
|
406
|
+
*/
|
|
407
|
+
function renderNote(note: PositionedClassNote, fontSizes: FontSizes): string {
|
|
408
|
+
const { x, y, width: w, height: h } = note
|
|
409
|
+
const forAttr =
|
|
410
|
+
note.forClass !== undefined
|
|
411
|
+
? ` data-for="${escapeAttr(note.forClass)}"`
|
|
412
|
+
: ''
|
|
413
|
+
|
|
414
|
+
// Note body: (x,y) → (x+w-fold,y) → (x+w,y+fold) → (x+w,y+h) → (x,y+h)
|
|
415
|
+
const bodyPoints = [
|
|
416
|
+
`${x},${y}`,
|
|
417
|
+
`${x + w - NOTE_FOLD},${y}`,
|
|
418
|
+
`${x + w},${y + NOTE_FOLD}`,
|
|
419
|
+
`${x + w},${y + h}`,
|
|
420
|
+
`${x},${y + h}`,
|
|
421
|
+
].join(' ')
|
|
422
|
+
const foldPoints =
|
|
423
|
+
`${x + w - NOTE_FOLD},${y} ${x + w},${y + NOTE_FOLD} ` +
|
|
424
|
+
`${x + w - NOTE_FOLD},${y + NOTE_FOLD}`
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
`<g class="class-note"${forAttr}>` +
|
|
428
|
+
`\n <polygon points="${bodyPoints}" ` +
|
|
429
|
+
`fill="var(--bg)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
430
|
+
`\n <polygon points="${foldPoints}" ` +
|
|
431
|
+
`fill="var(--_inner-stroke)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
432
|
+
`\n ${renderMultilineText(
|
|
433
|
+
note.text,
|
|
434
|
+
x + w / 2,
|
|
435
|
+
y + h / 2,
|
|
436
|
+
fontSizes.edgeLabel,
|
|
437
|
+
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
438
|
+
)}` +
|
|
439
|
+
`\n</g>`
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Render the dotted, arrowless link from a `note for X` note to its class
|
|
445
|
+
* (Mermaid's `pattern: 'dotted'` note edge). Empty for a free note.
|
|
446
|
+
*/
|
|
447
|
+
function renderNoteLink(note: PositionedClassNote): string {
|
|
448
|
+
if (!note.linkPoints || note.linkPoints.length < 2) return ''
|
|
449
|
+
const pathData = note.linkPoints.map((p) => `${p.x},${p.y}`).join(' ')
|
|
450
|
+
const forAttr =
|
|
451
|
+
note.forClass !== undefined
|
|
452
|
+
? ` data-for="${escapeAttr(note.forClass)}"`
|
|
453
|
+
: ''
|
|
454
|
+
return (
|
|
455
|
+
`<polyline class="class-note-link"${forAttr} points="${pathData}" ` +
|
|
456
|
+
`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}" stroke-dasharray="2 3" />`
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ============================================================================
|
|
461
|
+
// Relationship rendering
|
|
462
|
+
// ============================================================================
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Render a relationship line with appropriate markers and semantic attributes.
|
|
466
|
+
* Includes data-* attributes for programmatic inspection.
|
|
467
|
+
*/
|
|
468
|
+
function renderRelationship(rel: PositionedClassRelationship): string {
|
|
469
|
+
if (rel.points.length < 2) return ''
|
|
470
|
+
|
|
471
|
+
const pathData = rel.points.map((p) => `${p.x},${p.y}`).join(' ')
|
|
472
|
+
const isDashed = rel.type === 'dependency' || rel.type === 'realization'
|
|
473
|
+
const dashArray = isDashed ? ' stroke-dasharray="6 4"' : ''
|
|
474
|
+
|
|
475
|
+
// Determine markers based on relationship type and which end has the marker
|
|
476
|
+
const markers = getRelationshipMarkers(rel.type, rel.markerAt)
|
|
477
|
+
|
|
478
|
+
// Build semantic data attributes for relationship inspection:
|
|
479
|
+
// - class="class-relationship": CSS targeting
|
|
480
|
+
// - data-from/data-to: source and target class IDs
|
|
481
|
+
// - data-type: relationship type (inheritance, composition, etc.)
|
|
482
|
+
// - data-marker-at: which end has the marker (from/to)
|
|
483
|
+
// - data-from-cardinality/data-to-cardinality: multiplicity if present
|
|
484
|
+
// - data-label: relationship label if present
|
|
485
|
+
const dataAttrs = [
|
|
486
|
+
'class="class-relationship"',
|
|
487
|
+
`data-from="${escapeAttr(rel.from)}"`,
|
|
488
|
+
`data-to="${escapeAttr(rel.to)}"`,
|
|
489
|
+
`data-type="${rel.type}"`,
|
|
490
|
+
`data-marker-at="${rel.markerAt}"`,
|
|
491
|
+
]
|
|
492
|
+
if (rel.label) {
|
|
493
|
+
dataAttrs.push(`data-label="${escapeAttr(rel.label)}"`)
|
|
494
|
+
}
|
|
495
|
+
if (rel.fromCardinality) {
|
|
496
|
+
dataAttrs.push(`data-from-cardinality="${escapeAttr(rel.fromCardinality)}"`)
|
|
497
|
+
}
|
|
498
|
+
if (rel.toCardinality) {
|
|
499
|
+
dataAttrs.push(`data-to-cardinality="${escapeAttr(rel.toCardinality)}"`)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return (
|
|
503
|
+
`<polyline ${dataAttrs.join(' ')} points="${pathData}" fill="none" stroke="var(--_line)" ` +
|
|
504
|
+
`stroke-width="${STROKE_WIDTHS.connector}"${dashArray}${markers} />`
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Get marker-start/marker-end attributes for a relationship type.
|
|
510
|
+
* Uses `markerAt` from the parser to place the marker on the correct end:
|
|
511
|
+
* - 'from' → marker-start (prefix arrows like `<|--`, `*--`, `o--`)
|
|
512
|
+
* - 'to' → marker-end (suffix arrows like `..|>`, `-->`, `--*`)
|
|
513
|
+
*/
|
|
514
|
+
function getRelationshipMarkers(
|
|
515
|
+
type: RelationshipType,
|
|
516
|
+
markerAt: 'from' | 'to',
|
|
517
|
+
): string {
|
|
518
|
+
const markerId = getMarkerDefId(type)
|
|
519
|
+
if (!markerId) return ''
|
|
520
|
+
|
|
521
|
+
if (markerAt === 'from') {
|
|
522
|
+
return ` marker-start="url(#${markerId})"`
|
|
523
|
+
} else {
|
|
524
|
+
return ` marker-end="url(#${markerId})"`
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Map relationship type to its SVG marker definition ID */
|
|
529
|
+
function getMarkerDefId(type: RelationshipType): string | null {
|
|
530
|
+
switch (type) {
|
|
531
|
+
case 'inheritance':
|
|
532
|
+
case 'realization':
|
|
533
|
+
return 'cls-inherit'
|
|
534
|
+
case 'composition':
|
|
535
|
+
return 'cls-composition'
|
|
536
|
+
case 'aggregation':
|
|
537
|
+
return 'cls-aggregation'
|
|
538
|
+
case 'association':
|
|
539
|
+
case 'dependency':
|
|
540
|
+
return 'cls-arrow'
|
|
541
|
+
default:
|
|
542
|
+
return null
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Render relationship labels and cardinality text (supports multi-line) */
|
|
547
|
+
function renderRelationshipLabels(
|
|
548
|
+
rel: PositionedClassRelationship,
|
|
549
|
+
fontSizes: FontSizes,
|
|
550
|
+
): string {
|
|
551
|
+
if (!rel.label && !rel.fromCardinality && !rel.toCardinality) return ''
|
|
552
|
+
if (rel.points.length < 2) return ''
|
|
553
|
+
|
|
554
|
+
const parts: string[] = []
|
|
555
|
+
|
|
556
|
+
// Label — prefer layout-computed position (collision-aware), fall back to midpoint
|
|
557
|
+
if (rel.label) {
|
|
558
|
+
const pos = rel.labelPosition ?? midpoint(rel.points)
|
|
559
|
+
parts.push(
|
|
560
|
+
renderMultilineText(
|
|
561
|
+
rel.label,
|
|
562
|
+
pos.x,
|
|
563
|
+
pos.y - 8,
|
|
564
|
+
fontSizes.edgeLabel,
|
|
565
|
+
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
566
|
+
),
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// From cardinality (near start)
|
|
571
|
+
if (rel.fromCardinality && rel.points.length >= 2) {
|
|
572
|
+
const p = rel.points[0]!
|
|
573
|
+
const next = rel.points[1]!
|
|
574
|
+
const offset = cardinalityOffset(p, next)
|
|
575
|
+
parts.push(
|
|
576
|
+
renderMultilineText(
|
|
577
|
+
rel.fromCardinality,
|
|
578
|
+
p.x + offset.x,
|
|
579
|
+
p.y + offset.y,
|
|
580
|
+
fontSizes.edgeLabel,
|
|
581
|
+
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
582
|
+
),
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// To cardinality (near end)
|
|
587
|
+
if (rel.toCardinality && rel.points.length >= 2) {
|
|
588
|
+
const p = rel.points[rel.points.length - 1]!
|
|
589
|
+
const prev = rel.points[rel.points.length - 2]!
|
|
590
|
+
const offset = cardinalityOffset(p, prev)
|
|
591
|
+
parts.push(
|
|
592
|
+
renderMultilineText(
|
|
593
|
+
rel.toCardinality,
|
|
594
|
+
p.x + offset.x,
|
|
595
|
+
p.y + offset.y,
|
|
596
|
+
fontSizes.edgeLabel,
|
|
597
|
+
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return parts.join('\n')
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Get the midpoint of a point array */
|
|
606
|
+
function midpoint(points: Array<{ x: number; y: number }>): {
|
|
607
|
+
x: number
|
|
608
|
+
y: number
|
|
609
|
+
} {
|
|
610
|
+
if (points.length === 0) return { x: 0, y: 0 }
|
|
611
|
+
const mid = Math.floor(points.length / 2)
|
|
612
|
+
return points[mid]!
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Calculate offset for cardinality label perpendicular to edge direction */
|
|
616
|
+
function cardinalityOffset(
|
|
617
|
+
from: { x: number; y: number },
|
|
618
|
+
to: { x: number; y: number },
|
|
619
|
+
): { x: number; y: number } {
|
|
620
|
+
const dx = to.x - from.x
|
|
621
|
+
const dy = to.y - from.y
|
|
622
|
+
// Place label perpendicular to the edge, 14px away
|
|
623
|
+
if (Math.abs(dx) > Math.abs(dy)) {
|
|
624
|
+
// Mostly horizontal — offset vertically
|
|
625
|
+
return { x: dx > 0 ? 14 : -14, y: -10 }
|
|
626
|
+
}
|
|
627
|
+
// Mostly vertical — offset horizontally
|
|
628
|
+
return { x: -14, y: dy > 0 ? 14 : -14 }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ============================================================================
|
|
632
|
+
// Utilities
|
|
633
|
+
// ============================================================================
|
|
634
|
+
|
|
635
|
+
// Use shared escapeXml from multiline-utils
|
|
636
|
+
const escapeXml = escapeXmlUtil
|