@zombie-mermaid/svg-renderer 3.0.0 → 3.1.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 +35 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +837 -831
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/class/renderer.ts +65 -62
- package/src/edge-curves.ts +17 -16
- package/src/er/renderer.ts +50 -49
- package/src/renderer.ts +190 -189
- package/src/sequence/renderer.ts +66 -65
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zombie-mermaid/svg-renderer",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "SVG rendering primitives and the ELK-backed layout engine behind zombie-mermaid's SVG output.",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"elkjs": "^0.11.0",
|
|
39
39
|
"entities": "^7.0.1",
|
|
40
|
-
"@zombie-mermaid/core": "3.
|
|
41
|
-
"@zombie-mermaid/mermaid-parser": "3.
|
|
40
|
+
"@zombie-mermaid/core": "3.1.0",
|
|
41
|
+
"@zombie-mermaid/mermaid-parser": "3.1.0"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/class/renderer.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
escapeXml as escapeXmlUtil,
|
|
17
17
|
escapeAttr,
|
|
18
18
|
safeHref,
|
|
19
|
+
f,
|
|
19
20
|
} from '@zombie-mermaid/core'
|
|
20
21
|
import { withDataSrc } from '../renderer.ts'
|
|
21
22
|
import {
|
|
@@ -230,20 +231,20 @@ function renderClassBox(
|
|
|
230
231
|
// data-label: class name
|
|
231
232
|
// data-annotation: stereotype (interface, abstract, etc.)
|
|
232
233
|
const annotationAttr = cls.annotation
|
|
233
|
-
? ` data-annotation="${escapeAttr(cls.annotation)}"`
|
|
234
|
+
? f` data-annotation="${escapeAttr(cls.annotation)}"`
|
|
234
235
|
: ''
|
|
235
236
|
// A style class from `cssClass`/`class A name`/`:::name` is emitted onto
|
|
236
237
|
// the group so external CSS can target it, after the same allowlist the
|
|
237
238
|
// flowchart renderer applies.
|
|
238
239
|
const safeClassName = sanitizeClassName(cls.className)
|
|
239
240
|
const groupAttrs = [
|
|
240
|
-
`class="${safeClassName ? `class-node ${safeClassName}` : 'class-node'}"`,
|
|
241
|
-
`data-id="${escapeAttr(cls.id)}"`,
|
|
242
|
-
`data-label="${escapeAttr(cls.label)}"`,
|
|
241
|
+
f`class="${safeClassName ? f`class-node ${safeClassName}` : 'class-node'}"`,
|
|
242
|
+
f`data-id="${escapeAttr(cls.id)}"`,
|
|
243
|
+
f`data-label="${escapeAttr(cls.label)}"`,
|
|
243
244
|
]
|
|
244
245
|
// `click ClassName call fn()` is parsed, never invoked, and never written
|
|
245
246
|
// into the markup — see renderNode() in packages/svg-renderer/src/renderer.ts for the rationale.
|
|
246
|
-
parts.push(`<g ${groupAttrs.join(' ')}${annotationAttr}>`)
|
|
247
|
+
parts.push(f`<g ${groupAttrs.join(' ')}${annotationAttr}>`)
|
|
247
248
|
|
|
248
249
|
// An href becomes a real SVG link, which needs no script to work.
|
|
249
250
|
// `interactivity: 'none'` strips it — a link is meaningless in
|
|
@@ -251,28 +252,28 @@ function renderClassBox(
|
|
|
251
252
|
const href = linksEnabled ? safeHref(interaction?.href) : undefined
|
|
252
253
|
if (href) {
|
|
253
254
|
const targetAttr = interaction?.target
|
|
254
|
-
? ` target="${escapeAttr(interaction.target)}"`
|
|
255
|
+
? f` target="${escapeAttr(interaction.target)}"`
|
|
255
256
|
: ''
|
|
256
|
-
parts.push(` <a href="${escapeAttr(href)}"${targetAttr}>`)
|
|
257
|
+
parts.push(f` <a href="${escapeAttr(href)}"${targetAttr}>`)
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
if (linksEnabled && interaction?.tooltip) {
|
|
260
261
|
// <title> is SVG's native tooltip — no script, no CSS. Gated by
|
|
261
262
|
// linksEnabled alongside href above: both come from the same `click`
|
|
262
263
|
// statement, and 'none' strips both.
|
|
263
|
-
parts.push(` <title>${escapeXml(interaction.tooltip)}</title>`)
|
|
264
|
+
parts.push(f` <title>${escapeXml(interaction.tooltip)}</title>`)
|
|
264
265
|
}
|
|
265
266
|
|
|
266
267
|
// Outer rectangle (full box)
|
|
267
268
|
parts.push(
|
|
268
|
-
` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
|
|
269
|
-
`rx="0" ry="0" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
|
|
269
|
+
f` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
|
|
270
|
+
f`rx="0" ry="0" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
|
|
270
271
|
)
|
|
271
272
|
|
|
272
273
|
// Header background
|
|
273
274
|
parts.push(
|
|
274
|
-
` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
|
|
275
|
-
`rx="0" ry="0" fill="${headerFill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
|
|
275
|
+
f` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
|
|
276
|
+
f`rx="0" ry="0" fill="${headerFill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`,
|
|
276
277
|
)
|
|
277
278
|
|
|
278
279
|
// Annotation (<<interface>>, <<abstract>>, etc.)
|
|
@@ -280,9 +281,9 @@ function renderClassBox(
|
|
|
280
281
|
if (cls.annotation) {
|
|
281
282
|
const annotY = y + 12
|
|
282
283
|
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>`,
|
|
284
|
+
f` <text x="${x + width / 2}" y="${annotY}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
285
|
+
f`font-size="${CLS_FONT.annotationSize}" font-weight="${CLS_FONT.annotationWeight}" ` +
|
|
286
|
+
f`font-style="italic" fill="${textColor ?? 'var(--_text-muted)'}"><<${escapeXml(cls.annotation)}>></text>`,
|
|
286
287
|
)
|
|
287
288
|
nameY = y + headerHeight / 2 + 6
|
|
288
289
|
}
|
|
@@ -295,15 +296,15 @@ function renderClassBox(
|
|
|
295
296
|
x + width / 2,
|
|
296
297
|
nameY,
|
|
297
298
|
fontSizes.nodeLabel,
|
|
298
|
-
`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="${textColor ?? 'var(--_text)'}"`,
|
|
299
|
+
f`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="${textColor ?? 'var(--_text)'}"`,
|
|
299
300
|
),
|
|
300
301
|
)
|
|
301
302
|
|
|
302
303
|
// Divider line between header and attributes
|
|
303
304
|
const attrTop = y + headerHeight
|
|
304
305
|
parts.push(
|
|
305
|
-
` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
|
|
306
|
-
`stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
306
|
+
f` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
|
|
307
|
+
f`stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
307
308
|
)
|
|
308
309
|
|
|
309
310
|
// Attributes
|
|
@@ -317,8 +318,8 @@ function renderClassBox(
|
|
|
317
318
|
// Divider line between attributes and methods
|
|
318
319
|
const methodTop = attrTop + attrHeight
|
|
319
320
|
parts.push(
|
|
320
|
-
` <line x1="${x}" y1="${methodTop}" x2="${x + width}" y2="${methodTop}" ` +
|
|
321
|
-
`stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
321
|
+
f` <line x1="${x}" y1="${methodTop}" x2="${x + width}" y2="${methodTop}" ` +
|
|
322
|
+
f`stroke="${stroke}" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
322
323
|
)
|
|
323
324
|
|
|
324
325
|
// Methods
|
|
@@ -366,27 +367,27 @@ function renderMember(
|
|
|
366
367
|
|
|
367
368
|
if (member.visibility) {
|
|
368
369
|
spans.push(
|
|
369
|
-
`<tspan fill="${faint}">${escapeXml(member.visibility)} </tspan>`,
|
|
370
|
+
f`<tspan fill="${faint}">${escapeXml(member.visibility)} </tspan>`,
|
|
370
371
|
)
|
|
371
372
|
}
|
|
372
373
|
|
|
373
374
|
// Add parentheses for methods to distinguish from attributes, including parameters if present
|
|
374
375
|
const displayName = member.isMethod
|
|
375
|
-
? `${member.name}(${member.params || ''})`
|
|
376
|
+
? f`${member.name}(${member.params || ''})`
|
|
376
377
|
: member.name
|
|
377
378
|
// False positive: displayName is passed through escapeXml() (see packages/core/src/multiline-utils.ts),
|
|
378
379
|
// 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
|
+
spans.push(f`<tspan fill="${secondary}">${escapeXml(displayName)}</tspan>`) // nosemgrep: javascript.express.security.injection.raw-html-format.raw-html-format
|
|
380
381
|
|
|
381
382
|
if (member.type) {
|
|
382
|
-
spans.push(`<tspan fill="${faint}">: </tspan>`)
|
|
383
|
-
spans.push(`<tspan fill="${muted}">${escapeXml(member.type)}</tspan>`)
|
|
383
|
+
spans.push(f`<tspan fill="${faint}">: </tspan>`)
|
|
384
|
+
spans.push(f`<tspan fill="${muted}">${escapeXml(member.type)}</tspan>`)
|
|
384
385
|
}
|
|
385
386
|
|
|
386
387
|
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>`
|
|
388
|
+
f`<text x="${x}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
389
|
+
f`font-size="${CLS_FONT.memberSize}" font-weight="${CLS_FONT.memberWeight}"${fontStyle}${decoration}>` +
|
|
390
|
+
f`${spans.join('')}</text>`
|
|
390
391
|
)
|
|
391
392
|
}
|
|
392
393
|
|
|
@@ -408,33 +409,33 @@ function renderNote(note: PositionedClassNote, fontSizes: FontSizes): string {
|
|
|
408
409
|
const { x, y, width: w, height: h } = note
|
|
409
410
|
const forAttr =
|
|
410
411
|
note.forClass !== undefined
|
|
411
|
-
? ` data-for="${escapeAttr(note.forClass)}"`
|
|
412
|
+
? f` data-for="${escapeAttr(note.forClass)}"`
|
|
412
413
|
: ''
|
|
413
414
|
|
|
414
415
|
// Note body: (x,y) → (x+w-fold,y) → (x+w,y+fold) → (x+w,y+h) → (x,y+h)
|
|
415
416
|
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}`,
|
|
417
|
+
f`${x},${y}`,
|
|
418
|
+
f`${x + w - NOTE_FOLD},${y}`,
|
|
419
|
+
f`${x + w},${y + NOTE_FOLD}`,
|
|
420
|
+
f`${x + w},${y + h}`,
|
|
421
|
+
f`${x},${y + h}`,
|
|
421
422
|
].join(' ')
|
|
422
423
|
const foldPoints =
|
|
423
|
-
`${x + w - NOTE_FOLD},${y} ${x + w},${y + NOTE_FOLD} ` +
|
|
424
|
-
`${x + w - NOTE_FOLD},${y + NOTE_FOLD}`
|
|
424
|
+
f`${x + w - NOTE_FOLD},${y} ${x + w},${y + NOTE_FOLD} ` +
|
|
425
|
+
f`${x + w - NOTE_FOLD},${y + NOTE_FOLD}`
|
|
425
426
|
|
|
426
427
|
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(
|
|
428
|
+
f`<g class="class-note"${forAttr}>` +
|
|
429
|
+
f`\n <polygon points="${bodyPoints}" ` +
|
|
430
|
+
f`fill="var(--bg)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
431
|
+
f`\n <polygon points="${foldPoints}" ` +
|
|
432
|
+
f`fill="var(--_inner-stroke)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
433
|
+
f`\n ${renderMultilineText(
|
|
433
434
|
note.text,
|
|
434
435
|
x + w / 2,
|
|
435
436
|
y + h / 2,
|
|
436
437
|
fontSizes.edgeLabel,
|
|
437
|
-
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
438
|
+
f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
438
439
|
)}` +
|
|
439
440
|
`\n</g>`
|
|
440
441
|
)
|
|
@@ -446,14 +447,14 @@ function renderNote(note: PositionedClassNote, fontSizes: FontSizes): string {
|
|
|
446
447
|
*/
|
|
447
448
|
function renderNoteLink(note: PositionedClassNote): string {
|
|
448
449
|
if (!note.linkPoints || note.linkPoints.length < 2) return ''
|
|
449
|
-
const pathData = note.linkPoints.map((p) => `${p.x},${p.y}`).join(' ')
|
|
450
|
+
const pathData = note.linkPoints.map((p) => f`${p.x},${p.y}`).join(' ')
|
|
450
451
|
const forAttr =
|
|
451
452
|
note.forClass !== undefined
|
|
452
|
-
? ` data-for="${escapeAttr(note.forClass)}"`
|
|
453
|
+
? f` data-for="${escapeAttr(note.forClass)}"`
|
|
453
454
|
: ''
|
|
454
455
|
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" />`
|
|
456
|
+
f`<polyline class="class-note-link"${forAttr} points="${pathData}" ` +
|
|
457
|
+
f`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}" stroke-dasharray="2 3" />`
|
|
457
458
|
)
|
|
458
459
|
}
|
|
459
460
|
|
|
@@ -468,7 +469,7 @@ function renderNoteLink(note: PositionedClassNote): string {
|
|
|
468
469
|
function renderRelationship(rel: PositionedClassRelationship): string {
|
|
469
470
|
if (rel.points.length < 2) return ''
|
|
470
471
|
|
|
471
|
-
const pathData = rel.points.map((p) => `${p.x},${p.y}`).join(' ')
|
|
472
|
+
const pathData = rel.points.map((p) => f`${p.x},${p.y}`).join(' ')
|
|
472
473
|
const isDashed = rel.type === 'dependency' || rel.type === 'realization'
|
|
473
474
|
const dashArray = isDashed ? ' stroke-dasharray="6 4"' : ''
|
|
474
475
|
|
|
@@ -484,24 +485,26 @@ function renderRelationship(rel: PositionedClassRelationship): string {
|
|
|
484
485
|
// - data-label: relationship label if present
|
|
485
486
|
const dataAttrs = [
|
|
486
487
|
'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}"`,
|
|
488
|
+
f`data-from="${escapeAttr(rel.from)}"`,
|
|
489
|
+
f`data-to="${escapeAttr(rel.to)}"`,
|
|
490
|
+
f`data-type="${rel.type}"`,
|
|
491
|
+
f`data-marker-at="${rel.markerAt}"`,
|
|
491
492
|
]
|
|
492
493
|
if (rel.label) {
|
|
493
|
-
dataAttrs.push(`data-label="${escapeAttr(rel.label)}"`)
|
|
494
|
+
dataAttrs.push(f`data-label="${escapeAttr(rel.label)}"`)
|
|
494
495
|
}
|
|
495
496
|
if (rel.fromCardinality) {
|
|
496
|
-
dataAttrs.push(
|
|
497
|
+
dataAttrs.push(
|
|
498
|
+
f`data-from-cardinality="${escapeAttr(rel.fromCardinality)}"`,
|
|
499
|
+
)
|
|
497
500
|
}
|
|
498
501
|
if (rel.toCardinality) {
|
|
499
|
-
dataAttrs.push(`data-to-cardinality="${escapeAttr(rel.toCardinality)}"`)
|
|
502
|
+
dataAttrs.push(f`data-to-cardinality="${escapeAttr(rel.toCardinality)}"`)
|
|
500
503
|
}
|
|
501
504
|
|
|
502
505
|
return (
|
|
503
|
-
`<polyline ${dataAttrs.join(' ')} points="${pathData}" fill="none" stroke="var(--_line)" ` +
|
|
504
|
-
`stroke-width="${STROKE_WIDTHS.connector}"${dashArray}${markers} />`
|
|
506
|
+
f`<polyline ${dataAttrs.join(' ')} points="${pathData}" fill="none" stroke="var(--_line)" ` +
|
|
507
|
+
f`stroke-width="${STROKE_WIDTHS.connector}"${dashArray}${markers} />`
|
|
505
508
|
)
|
|
506
509
|
}
|
|
507
510
|
|
|
@@ -519,9 +522,9 @@ function getRelationshipMarkers(
|
|
|
519
522
|
if (!markerId) return ''
|
|
520
523
|
|
|
521
524
|
if (markerAt === 'from') {
|
|
522
|
-
return ` marker-start="url(#${markerId})"`
|
|
525
|
+
return f` marker-start="url(#${markerId})"`
|
|
523
526
|
} else {
|
|
524
|
-
return ` marker-end="url(#${markerId})"`
|
|
527
|
+
return f` marker-end="url(#${markerId})"`
|
|
525
528
|
}
|
|
526
529
|
}
|
|
527
530
|
|
|
@@ -562,7 +565,7 @@ function renderRelationshipLabels(
|
|
|
562
565
|
pos.x,
|
|
563
566
|
pos.y - 8,
|
|
564
567
|
fontSizes.edgeLabel,
|
|
565
|
-
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
568
|
+
f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
566
569
|
),
|
|
567
570
|
)
|
|
568
571
|
}
|
|
@@ -578,7 +581,7 @@ function renderRelationshipLabels(
|
|
|
578
581
|
p.x + offset.x,
|
|
579
582
|
p.y + offset.y,
|
|
580
583
|
fontSizes.edgeLabel,
|
|
581
|
-
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
584
|
+
f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
582
585
|
),
|
|
583
586
|
)
|
|
584
587
|
}
|
|
@@ -594,7 +597,7 @@ function renderRelationshipLabels(
|
|
|
594
597
|
p.x + offset.x,
|
|
595
598
|
p.y + offset.y,
|
|
596
599
|
fontSizes.edgeLabel,
|
|
597
|
-
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
600
|
+
f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
598
601
|
),
|
|
599
602
|
)
|
|
600
603
|
}
|
package/src/edge-curves.ts
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
// ============================================================================
|
|
11
11
|
|
|
12
12
|
import type { Point, CurveStyle } from '@zombie-mermaid/core'
|
|
13
|
+
import { f } from '@zombie-mermaid/core'
|
|
13
14
|
|
|
14
15
|
/** `M x y` for the first point. */
|
|
15
16
|
function moveTo(p: Point): string {
|
|
16
|
-
return `M ${p.x} ${p.y}`
|
|
17
|
+
return f`M ${p.x} ${p.y}`
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/** Straight segments through every point — the historical default. */
|
|
@@ -22,7 +23,7 @@ function linearPath(points: Point[]): string {
|
|
|
22
23
|
moveTo(points[0]!) +
|
|
23
24
|
points
|
|
24
25
|
.slice(1)
|
|
25
|
-
.map((p) => ` L ${p.x} ${p.y}`)
|
|
26
|
+
.map((p) => f` L ${p.x} ${p.y}`)
|
|
26
27
|
.join('')
|
|
27
28
|
)
|
|
28
29
|
}
|
|
@@ -51,14 +52,14 @@ function basisPath(points: Point[]): string {
|
|
|
51
52
|
|
|
52
53
|
/** One B-spline segment across three successive control points. */
|
|
53
54
|
const segment = (p0: Point, p1: Point, p2: Point): string =>
|
|
54
|
-
` C ${(2 * p0.x + p1.x) / 3} ${(2 * p0.y + p1.y) / 3},` +
|
|
55
|
-
` ${(p0.x + 2 * p1.x) / 3} ${(p0.y + 2 * p1.y) / 3},` +
|
|
56
|
-
` ${(p0.x + 4 * p1.x + p2.x) / 6} ${(p0.y + 4 * p1.y + p2.y) / 6}`
|
|
55
|
+
f` C ${(2 * p0.x + p1.x) / 3} ${(2 * p0.y + p1.y) / 3},` +
|
|
56
|
+
f` ${(p0.x + 2 * p1.x) / 3} ${(p0.y + 2 * p1.y) / 3},` +
|
|
57
|
+
f` ${(p0.x + 4 * p1.x + p2.x) / 6} ${(p0.y + 4 * p1.y + p2.y) / 6}`
|
|
57
58
|
|
|
58
59
|
// Lead-in to the first knot, one sixth of the way along the opening leg.
|
|
59
60
|
parts.push(
|
|
60
|
-
` L ${(5 * points[0]!.x + points[1]!.x) / 6}` +
|
|
61
|
-
` ${(5 * points[0]!.y + points[1]!.y) / 6}`,
|
|
61
|
+
f` L ${(5 * points[0]!.x + points[1]!.x) / 6}` +
|
|
62
|
+
f` ${(5 * points[0]!.y + points[1]!.y) / 6}`,
|
|
62
63
|
)
|
|
63
64
|
|
|
64
65
|
for (let i = 2; i < points.length; i++) {
|
|
@@ -67,7 +68,7 @@ function basisPath(points: Point[]): string {
|
|
|
67
68
|
|
|
68
69
|
// d3 closes by feeding the last point twice, then drawing to it.
|
|
69
70
|
parts.push(segment(points[points.length - 2]!, last, last))
|
|
70
|
-
parts.push(` L ${last.x} ${last.y}`)
|
|
71
|
+
parts.push(f` L ${last.x} ${last.y}`)
|
|
71
72
|
|
|
72
73
|
return parts.join('')
|
|
73
74
|
}
|
|
@@ -109,7 +110,7 @@ function naturalPath(points: Point[]): string {
|
|
|
109
110
|
// A zero-length leg means duplicated routed points; there is no corner
|
|
110
111
|
// to round, so pass straight through.
|
|
111
112
|
if (inLen === 0 || outLen === 0) {
|
|
112
|
-
parts.push(` L ${corner.x} ${corner.y}`)
|
|
113
|
+
parts.push(f` L ${corner.x} ${corner.y}`)
|
|
113
114
|
continue
|
|
114
115
|
}
|
|
115
116
|
|
|
@@ -123,12 +124,12 @@ function naturalPath(points: Point[]): string {
|
|
|
123
124
|
const endY = corner.y + ((next.y - corner.y) / outLen) * radius
|
|
124
125
|
|
|
125
126
|
// Straight up to the fillet, then a quadratic through the corner.
|
|
126
|
-
parts.push(` L ${startX} ${startY}`)
|
|
127
|
-
parts.push(` Q ${corner.x} ${corner.y}, ${endX} ${endY}`)
|
|
127
|
+
parts.push(f` L ${startX} ${startY}`)
|
|
128
|
+
parts.push(f` Q ${corner.x} ${corner.y}, ${endX} ${endY}`)
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
const last = points[points.length - 1]!
|
|
131
|
-
parts.push(` L ${last.x} ${last.y}`)
|
|
132
|
+
parts.push(f` L ${last.x} ${last.y}`)
|
|
132
133
|
|
|
133
134
|
return parts.join('')
|
|
134
135
|
}
|
|
@@ -155,17 +156,17 @@ function stepPath(points: Point[], where: 'mid' | 'before' | 'after'): string {
|
|
|
155
156
|
const b = points[i + 1]!
|
|
156
157
|
|
|
157
158
|
if (i === lastIndex - 1) {
|
|
158
|
-
parts.push(` L ${b.x} ${b.y}`)
|
|
159
|
+
parts.push(f` L ${b.x} ${b.y}`)
|
|
159
160
|
break
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
if (where === 'before') {
|
|
163
|
-
parts.push(` L ${a.x} ${b.y} L ${b.x} ${b.y}`)
|
|
164
|
+
parts.push(f` L ${a.x} ${b.y} L ${b.x} ${b.y}`)
|
|
164
165
|
} else if (where === 'after') {
|
|
165
|
-
parts.push(` L ${b.x} ${a.y} L ${b.x} ${b.y}`)
|
|
166
|
+
parts.push(f` L ${b.x} ${a.y} L ${b.x} ${b.y}`)
|
|
166
167
|
} else {
|
|
167
168
|
const midY = (a.y + b.y) / 2
|
|
168
|
-
parts.push(` L ${a.x} ${midY} L ${b.x} ${midY} L ${b.x} ${b.y}`)
|
|
169
|
+
parts.push(f` L ${a.x} ${midY} L ${b.x} ${midY} L ${b.x} ${b.y}`)
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
|
package/src/er/renderer.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
escapeXml as escapeXmlUtil,
|
|
14
14
|
escapeAttr,
|
|
15
15
|
measureMultilineText,
|
|
16
|
+
f,
|
|
16
17
|
} from '@zombie-mermaid/core'
|
|
17
18
|
import { withDataSrc } from '../renderer.ts'
|
|
18
19
|
import {
|
|
@@ -144,19 +145,19 @@ function renderEntityBox(
|
|
|
144
145
|
|
|
145
146
|
// Semantic wrapper with entity metadata
|
|
146
147
|
parts.push(
|
|
147
|
-
`<g class="entity" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}">`,
|
|
148
|
+
f`<g class="entity" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}">`,
|
|
148
149
|
)
|
|
149
150
|
|
|
150
151
|
// Outer rectangle
|
|
151
152
|
parts.push(
|
|
152
|
-
` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
|
|
153
|
-
`rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
153
|
+
f` <rect x="${x}" y="${y}" width="${width}" height="${height}" ` +
|
|
154
|
+
f`rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
154
155
|
)
|
|
155
156
|
|
|
156
157
|
// Header background
|
|
157
158
|
parts.push(
|
|
158
|
-
` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
|
|
159
|
-
`rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
159
|
+
f` <rect x="${x}" y="${y}" width="${width}" height="${headerHeight}" ` +
|
|
160
|
+
f`rx="0" ry="0" fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
160
161
|
)
|
|
161
162
|
|
|
162
163
|
// Entity name (supports multi-line via <br> tags)
|
|
@@ -167,15 +168,15 @@ function renderEntityBox(
|
|
|
167
168
|
x + width / 2,
|
|
168
169
|
y + headerHeight / 2,
|
|
169
170
|
fontSizes.nodeLabel,
|
|
170
|
-
`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="var(--_text)"`,
|
|
171
|
+
f`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="var(--_text)"`,
|
|
171
172
|
),
|
|
172
173
|
)
|
|
173
174
|
|
|
174
175
|
// Divider
|
|
175
176
|
const attrTop = y + headerHeight
|
|
176
177
|
parts.push(
|
|
177
|
-
` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
|
|
178
|
-
`stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
178
|
+
f` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
|
|
179
|
+
f`stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
179
180
|
)
|
|
180
181
|
|
|
181
182
|
// Attribute rows
|
|
@@ -190,8 +191,8 @@ function renderEntityBox(
|
|
|
190
191
|
// Empty row placeholder when no attributes
|
|
191
192
|
if (attributes.length === 0) {
|
|
192
193
|
parts.push(
|
|
193
|
-
` <text x="${x + width / 2}" y="${attrTop + rowHeight / 2}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
194
|
-
`font-size="${ER_FONT.attrSize}" fill="var(--_text-faint)" font-style="italic">(no attributes)</text>`,
|
|
194
|
+
f` <text x="${x + width / 2}" y="${attrTop + rowHeight / 2}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
195
|
+
f`font-size="${ER_FONT.attrSize}" fill="var(--_text-faint)" font-style="italic">(no attributes)</text>`,
|
|
195
196
|
)
|
|
196
197
|
}
|
|
197
198
|
|
|
@@ -220,7 +221,7 @@ function renderAttribute(
|
|
|
220
221
|
if (attr.comment && attr.comment.length > 0) {
|
|
221
222
|
// Replace <br> with newlines for tooltip display
|
|
222
223
|
const tooltipText = attr.comment.replace(/<br\s*\/?>/gi, '\n')
|
|
223
|
-
parts.push(`<g><title>${escapeXml(tooltipText)}</title>`)
|
|
224
|
+
parts.push(f`<g><title>${escapeXml(tooltipText)}</title>`)
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
// Key badges on the left (keep proportional font — they're visual tags, not code)
|
|
@@ -230,29 +231,29 @@ function renderAttribute(
|
|
|
230
231
|
keyWidth =
|
|
231
232
|
estimateTextWidth(keyText, ER_FONT.keySize, ER_FONT.keyWeight) + 8
|
|
232
233
|
parts.push(
|
|
233
|
-
`<rect x="${boxX + 6}" y="${y - 7}" width="${keyWidth}" height="14" rx="2" ry="2" ` +
|
|
234
|
+
f`<rect x="${boxX + 6}" y="${y - 7}" width="${keyWidth}" height="14" rx="2" ry="2" ` +
|
|
234
235
|
`fill="var(--_key-badge)" />`,
|
|
235
236
|
)
|
|
236
237
|
parts.push(
|
|
237
|
-
`<text x="${boxX + 6 + keyWidth / 2}" y="${y}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
238
|
-
`font-size="${ER_FONT.keySize}" font-weight="${ER_FONT.keyWeight}" fill="var(--_text-sec)">${attr.keys.join(',')}</text>`,
|
|
238
|
+
f`<text x="${boxX + 6 + keyWidth / 2}" y="${y}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
239
|
+
f`font-size="${ER_FONT.keySize}" font-weight="${ER_FONT.keyWeight}" fill="var(--_text-sec)">${attr.keys.join(',')}</text>`,
|
|
239
240
|
)
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
// Type (left-aligned after keys, monospace with syntax highlighting)
|
|
243
244
|
const typeX = boxX + 8 + (keyWidth > 0 ? keyWidth + 6 : 0)
|
|
244
245
|
parts.push(
|
|
245
|
-
`<text x="${typeX}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
246
|
-
`font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
|
|
247
|
-
`<tspan fill="var(--_text-muted)">${escapeXml(attr.type)}</tspan></text>`,
|
|
246
|
+
f`<text x="${typeX}" y="${y}" class="mono" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
247
|
+
f`font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
|
|
248
|
+
f`<tspan fill="var(--_text-muted)">${escapeXml(attr.type)}</tspan></text>`,
|
|
248
249
|
)
|
|
249
250
|
|
|
250
251
|
// Name (right-aligned, monospace with syntax highlighting)
|
|
251
252
|
const nameX = boxX + boxWidth - 8
|
|
252
253
|
parts.push(
|
|
253
|
-
`<text x="${nameX}" y="${y}" class="mono" text-anchor="end" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
254
|
-
`font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
|
|
255
|
-
`<tspan fill="var(--_text-sec)">${escapeXml(attr.name)}</tspan></text>`,
|
|
254
|
+
f`<text x="${nameX}" y="${y}" class="mono" text-anchor="end" dy="${TEXT_BASELINE_SHIFT}" ` +
|
|
255
|
+
f`font-size="${ER_FONT.attrSize}" font-weight="${ER_FONT.attrWeight}">` +
|
|
256
|
+
f`<tspan fill="var(--_text-sec)">${escapeXml(attr.name)}</tspan></text>`,
|
|
256
257
|
)
|
|
257
258
|
|
|
258
259
|
// Close the group if we opened one
|
|
@@ -273,23 +274,23 @@ function renderAttribute(
|
|
|
273
274
|
function renderRelationshipLine(rel: PositionedErRelationship): string {
|
|
274
275
|
if (rel.points.length < 2) return ''
|
|
275
276
|
|
|
276
|
-
const pathData = rel.points.map((p) => `${p.x},${p.y}`).join(' ')
|
|
277
|
+
const pathData = rel.points.map((p) => f`${p.x},${p.y}`).join(' ')
|
|
277
278
|
const dashArray = !rel.identifying ? ' stroke-dasharray="6 4"' : ''
|
|
278
279
|
|
|
279
280
|
// Semantic data attributes for relationship inspection
|
|
280
|
-
const labelAttr = rel.label ? ` data-label="${escapeAttr(rel.label)}"` : ''
|
|
281
|
+
const labelAttr = rel.label ? f` data-label="${escapeAttr(rel.label)}"` : ''
|
|
281
282
|
const dataAttrs = [
|
|
282
283
|
'class="er-relationship"',
|
|
283
|
-
`data-entity1="${escapeAttr(rel.entity1)}"`,
|
|
284
|
-
`data-entity2="${escapeAttr(rel.entity2)}"`,
|
|
285
|
-
`data-cardinality1="${rel.cardinality1}"`,
|
|
286
|
-
`data-cardinality2="${rel.cardinality2}"`,
|
|
287
|
-
`data-identifying="${rel.identifying}"`,
|
|
284
|
+
f`data-entity1="${escapeAttr(rel.entity1)}"`,
|
|
285
|
+
f`data-entity2="${escapeAttr(rel.entity2)}"`,
|
|
286
|
+
f`data-cardinality1="${rel.cardinality1}"`,
|
|
287
|
+
f`data-cardinality2="${rel.cardinality2}"`,
|
|
288
|
+
f`data-identifying="${rel.identifying}"`,
|
|
288
289
|
]
|
|
289
290
|
|
|
290
291
|
return (
|
|
291
|
-
`<polyline ${dataAttrs.join(' ')}${labelAttr} points="${pathData}" fill="none" stroke="var(--_line)" ` +
|
|
292
|
-
`stroke-width="${STROKE_WIDTHS.connector}"${dashArray} />`
|
|
292
|
+
f`<polyline ${dataAttrs.join(' ')}${labelAttr} points="${pathData}" fill="none" stroke="var(--_line)" ` +
|
|
293
|
+
f`stroke-width="${STROKE_WIDTHS.connector}"${dashArray} />`
|
|
293
294
|
)
|
|
294
295
|
}
|
|
295
296
|
|
|
@@ -312,14 +313,14 @@ function renderRelationshipLabel(
|
|
|
312
313
|
const bgH = metrics.height + 6
|
|
313
314
|
|
|
314
315
|
return (
|
|
315
|
-
`<rect x="${mid.x - bgW / 2}" y="${mid.y - bgH / 2}" width="${bgW}" height="${bgH}" rx="2" ry="2" ` +
|
|
316
|
+
f`<rect x="${mid.x - bgW / 2}" y="${mid.y - bgH / 2}" width="${bgW}" height="${bgH}" rx="2" ry="2" ` +
|
|
316
317
|
`fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="0.5" />` +
|
|
317
|
-
`\n${renderMultilineText(
|
|
318
|
+
f`\n${renderMultilineText(
|
|
318
319
|
rel.label,
|
|
319
320
|
mid.x,
|
|
320
321
|
mid.y,
|
|
321
322
|
fontSizes.edgeLabel,
|
|
322
|
-
`text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
323
|
+
f`text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
323
324
|
)}`
|
|
324
325
|
)
|
|
325
326
|
}
|
|
@@ -389,17 +390,17 @@ function renderCrowsFoot(
|
|
|
389
390
|
if (hasOneLine) {
|
|
390
391
|
const halfW = 6
|
|
391
392
|
parts.push(
|
|
392
|
-
`<line x1="${tipX + px * halfW}" y1="${tipY + py * halfW}" ` +
|
|
393
|
-
`x2="${tipX - px * halfW}" y2="${tipY - py * halfW}" ` +
|
|
394
|
-
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
393
|
+
f`<line x1="${tipX + px * halfW}" y1="${tipY + py * halfW}" ` +
|
|
394
|
+
f`x2="${tipX - px * halfW}" y2="${tipY - py * halfW}" ` +
|
|
395
|
+
f`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
395
396
|
)
|
|
396
397
|
// Second line slightly back for "exactly one" emphasis
|
|
397
398
|
const line2X = tipX - ux * 4
|
|
398
399
|
const line2Y = tipY - uy * 4
|
|
399
400
|
parts.push(
|
|
400
|
-
`<line x1="${line2X + px * halfW}" y1="${line2Y + py * halfW}" ` +
|
|
401
|
-
`x2="${line2X - px * halfW}" y2="${line2Y - py * halfW}" ` +
|
|
402
|
-
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
401
|
+
f`<line x1="${line2X + px * halfW}" y1="${line2Y + py * halfW}" ` +
|
|
402
|
+
f`x2="${line2X - px * halfW}" y2="${line2Y - py * halfW}" ` +
|
|
403
|
+
f`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
403
404
|
)
|
|
404
405
|
}
|
|
405
406
|
|
|
@@ -412,21 +413,21 @@ function renderCrowsFoot(
|
|
|
412
413
|
// Three lines from tip to back, fanning out
|
|
413
414
|
parts.push(
|
|
414
415
|
// Top fan line
|
|
415
|
-
`<line x1="${cfTipX + px * fanW}" y1="${cfTipY + py * fanW}" ` +
|
|
416
|
-
`x2="${backX}" y2="${backY}" ` +
|
|
417
|
-
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
416
|
+
f`<line x1="${cfTipX + px * fanW}" y1="${cfTipY + py * fanW}" ` +
|
|
417
|
+
f`x2="${backX}" y2="${backY}" ` +
|
|
418
|
+
f`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
418
419
|
)
|
|
419
420
|
parts.push(
|
|
420
421
|
// Center line
|
|
421
|
-
`<line x1="${cfTipX}" y1="${cfTipY}" ` +
|
|
422
|
-
`x2="${backX}" y2="${backY}" ` +
|
|
423
|
-
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
422
|
+
f`<line x1="${cfTipX}" y1="${cfTipY}" ` +
|
|
423
|
+
f`x2="${backX}" y2="${backY}" ` +
|
|
424
|
+
f`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
424
425
|
)
|
|
425
426
|
parts.push(
|
|
426
427
|
// Bottom fan line
|
|
427
|
-
`<line x1="${cfTipX - px * fanW}" y1="${cfTipY - py * fanW}" ` +
|
|
428
|
-
`x2="${backX}" y2="${backY}" ` +
|
|
429
|
-
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
428
|
+
f`<line x1="${cfTipX - px * fanW}" y1="${cfTipY - py * fanW}" ` +
|
|
429
|
+
f`x2="${backX}" y2="${backY}" ` +
|
|
430
|
+
f`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
430
431
|
)
|
|
431
432
|
}
|
|
432
433
|
|
|
@@ -436,8 +437,8 @@ function renderCrowsFoot(
|
|
|
436
437
|
const circleX = point.x - ux * circleOffset
|
|
437
438
|
const circleY = point.y - uy * circleOffset
|
|
438
439
|
parts.push(
|
|
439
|
-
`<circle cx="${circleX}" cy="${circleY}" r="4" ` +
|
|
440
|
-
`fill="var(--bg)" stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
440
|
+
f`<circle cx="${circleX}" cy="${circleY}" r="4" ` +
|
|
441
|
+
f`fill="var(--bg)" stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
441
442
|
)
|
|
442
443
|
}
|
|
443
444
|
|