@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,493 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PositionedErDiagram,
|
|
3
|
+
PositionedErEntity,
|
|
4
|
+
PositionedErRelationship,
|
|
5
|
+
ErAttribute,
|
|
6
|
+
Cardinality,
|
|
7
|
+
} from '@zombie-mermaid/mermaid-parser'
|
|
8
|
+
import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
|
|
9
|
+
import {
|
|
10
|
+
svgOpenTag,
|
|
11
|
+
buildStyleBlock,
|
|
12
|
+
renderMultilineText,
|
|
13
|
+
escapeXml as escapeXmlUtil,
|
|
14
|
+
escapeAttr,
|
|
15
|
+
measureMultilineText,
|
|
16
|
+
} from '@zombie-mermaid/core'
|
|
17
|
+
import { withDataSrc } from '../renderer.ts'
|
|
18
|
+
import {
|
|
19
|
+
FONT_SIZES,
|
|
20
|
+
FONT_WEIGHTS,
|
|
21
|
+
STROKE_WIDTHS,
|
|
22
|
+
estimateTextWidth,
|
|
23
|
+
TEXT_BASELINE_SHIFT,
|
|
24
|
+
} from '../styles.ts'
|
|
25
|
+
import type { FontSizes } from '../styles.ts'
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// ER diagram SVG renderer
|
|
29
|
+
//
|
|
30
|
+
// Renders positioned ER diagrams to SVG.
|
|
31
|
+
// All colors use CSS custom properties (var(--_xxx)) from the theme system.
|
|
32
|
+
//
|
|
33
|
+
// Render order:
|
|
34
|
+
// 1. Relationship lines (behind boxes)
|
|
35
|
+
// 2. Entity boxes (header + attribute rows)
|
|
36
|
+
// 3. Cardinality markers (crow's foot notation)
|
|
37
|
+
// 4. Relationship labels
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/** Font sizes specific to ER diagrams */
|
|
41
|
+
const ER_FONT = {
|
|
42
|
+
attrSize: 11,
|
|
43
|
+
attrWeight: 400,
|
|
44
|
+
keySize: 9,
|
|
45
|
+
keyWeight: 600,
|
|
46
|
+
} as const
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Render a positioned ER diagram as an SVG string.
|
|
50
|
+
*
|
|
51
|
+
* @param colors - DiagramColors with bg/fg and optional enrichment variables.
|
|
52
|
+
* @param transparent - If true, renders with transparent background.
|
|
53
|
+
* @param embedSource - Original diagram source to stamp onto the root `<svg>`
|
|
54
|
+
* as `data-src` (from `options.embedSource`). Omitted
|
|
55
|
+
* when the option is off.
|
|
56
|
+
* @param title - Accessible name (from `options.title`). See svgOpenTag() in
|
|
57
|
+
* packages/core/src/theme.ts.
|
|
58
|
+
* @param decorative - Marks the SVG decorative (from `options.decorative`).
|
|
59
|
+
* @param emit - Strict-CSP controls (from `options.nonce` /
|
|
60
|
+
* `options.styleAttribute`, see #216). Default: no nonce,
|
|
61
|
+
* root `style` attribute on.
|
|
62
|
+
*/
|
|
63
|
+
export function renderErSvg(
|
|
64
|
+
diagram: PositionedErDiagram,
|
|
65
|
+
colors: DiagramColors,
|
|
66
|
+
font: string = 'Inter',
|
|
67
|
+
transparent: boolean = false,
|
|
68
|
+
fontSizes: FontSizes = FONT_SIZES,
|
|
69
|
+
embedSource?: string,
|
|
70
|
+
title?: string,
|
|
71
|
+
decorative?: boolean,
|
|
72
|
+
emit: SvgEmitOptions = {},
|
|
73
|
+
): string {
|
|
74
|
+
const parts: string[] = []
|
|
75
|
+
|
|
76
|
+
// SVG root with CSS variables + style block (with mono font) + defs
|
|
77
|
+
parts.push(
|
|
78
|
+
withDataSrc(
|
|
79
|
+
svgOpenTag(
|
|
80
|
+
diagram.width,
|
|
81
|
+
diagram.height,
|
|
82
|
+
colors,
|
|
83
|
+
transparent,
|
|
84
|
+
title,
|
|
85
|
+
decorative,
|
|
86
|
+
undefined,
|
|
87
|
+
emit.styleAttribute,
|
|
88
|
+
),
|
|
89
|
+
embedSource,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
parts.push(buildStyleBlock(font, true, emit.nonce))
|
|
93
|
+
parts.push('<defs>')
|
|
94
|
+
parts.push('</defs>') // No marker defs — we draw crow's foot inline
|
|
95
|
+
|
|
96
|
+
// 1. Relationship lines
|
|
97
|
+
for (const rel of diagram.relationships) {
|
|
98
|
+
parts.push(renderRelationshipLine(rel))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. Entity boxes
|
|
102
|
+
for (const entity of diagram.entities) {
|
|
103
|
+
parts.push(renderEntityBox(entity, fontSizes))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 3. Cardinality markers at relationship endpoints
|
|
107
|
+
for (const rel of diagram.relationships) {
|
|
108
|
+
parts.push(renderCardinality(rel))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 4. Relationship labels
|
|
112
|
+
for (const rel of diagram.relationships) {
|
|
113
|
+
parts.push(renderRelationshipLabel(rel, fontSizes))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
parts.push('</svg>')
|
|
117
|
+
return parts.join('\n')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// Entity box rendering
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render an entity box with header and attribute rows.
|
|
126
|
+
* Wrapped in <g class="entity"> with semantic data attributes.
|
|
127
|
+
*/
|
|
128
|
+
function renderEntityBox(
|
|
129
|
+
entity: PositionedErEntity,
|
|
130
|
+
fontSizes: FontSizes,
|
|
131
|
+
): string {
|
|
132
|
+
const {
|
|
133
|
+
id,
|
|
134
|
+
x,
|
|
135
|
+
y,
|
|
136
|
+
width,
|
|
137
|
+
height,
|
|
138
|
+
headerHeight,
|
|
139
|
+
rowHeight,
|
|
140
|
+
label,
|
|
141
|
+
attributes,
|
|
142
|
+
} = entity
|
|
143
|
+
const parts: string[] = []
|
|
144
|
+
|
|
145
|
+
// Semantic wrapper with entity metadata
|
|
146
|
+
parts.push(
|
|
147
|
+
`<g class="entity" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}">`,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// Outer rectangle
|
|
151
|
+
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}" />`,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// Header background
|
|
157
|
+
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}" />`,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
// Entity name (supports multi-line via <br> tags)
|
|
163
|
+
parts.push(
|
|
164
|
+
' ' +
|
|
165
|
+
renderMultilineText(
|
|
166
|
+
label,
|
|
167
|
+
x + width / 2,
|
|
168
|
+
y + headerHeight / 2,
|
|
169
|
+
fontSizes.nodeLabel,
|
|
170
|
+
`text-anchor="middle" font-size="${fontSizes.nodeLabel}" font-weight="700" fill="var(--_text)"`,
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// Divider
|
|
175
|
+
const attrTop = y + headerHeight
|
|
176
|
+
parts.push(
|
|
177
|
+
` <line x1="${x}" y1="${attrTop}" x2="${x + width}" y2="${attrTop}" ` +
|
|
178
|
+
`stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// Attribute rows
|
|
182
|
+
for (let i = 0; i < attributes.length; i++) {
|
|
183
|
+
const attr = attributes[i]!
|
|
184
|
+
const rowY = attrTop + i * rowHeight + rowHeight / 2
|
|
185
|
+
parts.push(
|
|
186
|
+
' ' + renderAttribute(attr, x, rowY, width).replace(/\n/g, '\n '),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Empty row placeholder when no attributes
|
|
191
|
+
if (attributes.length === 0) {
|
|
192
|
+
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>`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
parts.push('</g>')
|
|
199
|
+
return parts.join('\n')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Render a single attribute row with monospace syntax highlighting.
|
|
204
|
+
* Layout: [PK badge] type name (left-aligned in mono, name right-aligned)
|
|
205
|
+
* Uses <tspan> elements for per-part coloring, matching the class diagram style.
|
|
206
|
+
*
|
|
207
|
+
* Key badge uses var(--_key-badge) for background tint.
|
|
208
|
+
* Comments are shown as tooltips via SVG <title> element.
|
|
209
|
+
*/
|
|
210
|
+
function renderAttribute(
|
|
211
|
+
attr: ErAttribute,
|
|
212
|
+
boxX: number,
|
|
213
|
+
y: number,
|
|
214
|
+
boxWidth: number,
|
|
215
|
+
): string {
|
|
216
|
+
const parts: string[] = []
|
|
217
|
+
|
|
218
|
+
// Wrap in a group if there's a comment (for tooltip support)
|
|
219
|
+
const hasComment = attr.comment && attr.comment.length > 0
|
|
220
|
+
if (attr.comment && attr.comment.length > 0) {
|
|
221
|
+
// Replace <br> with newlines for tooltip display
|
|
222
|
+
const tooltipText = attr.comment.replace(/<br\s*\/?>/gi, '\n')
|
|
223
|
+
parts.push(`<g><title>${escapeXml(tooltipText)}</title>`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Key badges on the left (keep proportional font — they're visual tags, not code)
|
|
227
|
+
let keyWidth = 0
|
|
228
|
+
if (attr.keys.length > 0) {
|
|
229
|
+
const keyText = attr.keys.join(',')
|
|
230
|
+
keyWidth =
|
|
231
|
+
estimateTextWidth(keyText, ER_FONT.keySize, ER_FONT.keyWeight) + 8
|
|
232
|
+
parts.push(
|
|
233
|
+
`<rect x="${boxX + 6}" y="${y - 7}" width="${keyWidth}" height="14" rx="2" ry="2" ` +
|
|
234
|
+
`fill="var(--_key-badge)" />`,
|
|
235
|
+
)
|
|
236
|
+
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>`,
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Type (left-aligned after keys, monospace with syntax highlighting)
|
|
243
|
+
const typeX = boxX + 8 + (keyWidth > 0 ? keyWidth + 6 : 0)
|
|
244
|
+
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>`,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
// Name (right-aligned, monospace with syntax highlighting)
|
|
251
|
+
const nameX = boxX + boxWidth - 8
|
|
252
|
+
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>`,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
// Close the group if we opened one
|
|
259
|
+
if (hasComment) {
|
|
260
|
+
parts.push('</g>')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return parts.join('\n')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ============================================================================
|
|
267
|
+
// Relationship rendering
|
|
268
|
+
// ============================================================================
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Render a relationship line with semantic data attributes.
|
|
272
|
+
*/
|
|
273
|
+
function renderRelationshipLine(rel: PositionedErRelationship): string {
|
|
274
|
+
if (rel.points.length < 2) return ''
|
|
275
|
+
|
|
276
|
+
const pathData = rel.points.map((p) => `${p.x},${p.y}`).join(' ')
|
|
277
|
+
const dashArray = !rel.identifying ? ' stroke-dasharray="6 4"' : ''
|
|
278
|
+
|
|
279
|
+
// Semantic data attributes for relationship inspection
|
|
280
|
+
const labelAttr = rel.label ? ` data-label="${escapeAttr(rel.label)}"` : ''
|
|
281
|
+
const dataAttrs = [
|
|
282
|
+
'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}"`,
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
`<polyline ${dataAttrs.join(' ')}${labelAttr} points="${pathData}" fill="none" stroke="var(--_line)" ` +
|
|
292
|
+
`stroke-width="${STROKE_WIDTHS.connector}"${dashArray} />`
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Render a relationship label at the midpoint (supports multi-line) */
|
|
297
|
+
function renderRelationshipLabel(
|
|
298
|
+
rel: PositionedErRelationship,
|
|
299
|
+
fontSizes: FontSizes,
|
|
300
|
+
): string {
|
|
301
|
+
if (!rel.label || rel.points.length < 2) return ''
|
|
302
|
+
|
|
303
|
+
const mid = midpoint(rel.points)
|
|
304
|
+
const metrics = measureMultilineText(
|
|
305
|
+
rel.label,
|
|
306
|
+
fontSizes.edgeLabel,
|
|
307
|
+
FONT_WEIGHTS.edgeLabel,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
// Background pill for readability
|
|
311
|
+
const bgW = metrics.width + 8
|
|
312
|
+
const bgH = metrics.height + 6
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
`<rect x="${mid.x - bgW / 2}" y="${mid.y - bgH / 2}" width="${bgW}" height="${bgH}" rx="2" ry="2" ` +
|
|
316
|
+
`fill="var(--bg)" stroke="var(--_inner-stroke)" stroke-width="0.5" />` +
|
|
317
|
+
`\n${renderMultilineText(
|
|
318
|
+
rel.label,
|
|
319
|
+
mid.x,
|
|
320
|
+
mid.y,
|
|
321
|
+
fontSizes.edgeLabel,
|
|
322
|
+
`text-anchor="middle" font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
323
|
+
)}`
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Render crow's foot cardinality markers at both endpoints of a relationship.
|
|
329
|
+
*
|
|
330
|
+
* Crow's foot notation:
|
|
331
|
+
* 'one': ─║─ (single vertical line)
|
|
332
|
+
* 'zero-one': ─o║─ (circle + single line)
|
|
333
|
+
* 'many': ─╢─ (crow's foot + single line)
|
|
334
|
+
* 'zero-many': ─o╣─ (circle + crow's foot)
|
|
335
|
+
*/
|
|
336
|
+
function renderCardinality(rel: PositionedErRelationship): string {
|
|
337
|
+
if (rel.points.length < 2) return ''
|
|
338
|
+
const parts: string[] = []
|
|
339
|
+
|
|
340
|
+
// Entity1 side (first point, direction toward second point)
|
|
341
|
+
const p1 = rel.points[0]!
|
|
342
|
+
const p2 = rel.points[1]!
|
|
343
|
+
parts.push(renderCrowsFoot(p1, p2, rel.cardinality1))
|
|
344
|
+
|
|
345
|
+
// Entity2 side (last point, direction toward second-to-last point)
|
|
346
|
+
const pN = rel.points[rel.points.length - 1]!
|
|
347
|
+
const pN1 = rel.points[rel.points.length - 2]!
|
|
348
|
+
parts.push(renderCrowsFoot(pN, pN1, rel.cardinality2))
|
|
349
|
+
|
|
350
|
+
return parts.join('\n')
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Render a crow's foot marker at a given endpoint.
|
|
355
|
+
* `point` is the endpoint, `toward` gives the direction the line comes from.
|
|
356
|
+
*/
|
|
357
|
+
function renderCrowsFoot(
|
|
358
|
+
point: { x: number; y: number },
|
|
359
|
+
toward: { x: number; y: number },
|
|
360
|
+
cardinality: Cardinality,
|
|
361
|
+
): string {
|
|
362
|
+
const parts: string[] = []
|
|
363
|
+
const sw = STROKE_WIDTHS.connector + 0.25
|
|
364
|
+
|
|
365
|
+
// Calculate direction from toward → point (unit vector)
|
|
366
|
+
const dx = point.x - toward.x
|
|
367
|
+
const dy = point.y - toward.y
|
|
368
|
+
const len = Math.sqrt(dx * dx + dy * dy)
|
|
369
|
+
if (len === 0) return ''
|
|
370
|
+
const ux = dx / len
|
|
371
|
+
const uy = dy / len
|
|
372
|
+
|
|
373
|
+
// Perpendicular direction
|
|
374
|
+
const px = -uy
|
|
375
|
+
const py = ux
|
|
376
|
+
|
|
377
|
+
// Marker sits 4px from the endpoint, extending 12px back along the edge
|
|
378
|
+
const tipX = point.x - ux * 4
|
|
379
|
+
const tipY = point.y - uy * 4
|
|
380
|
+
const backX = point.x - ux * 16
|
|
381
|
+
const backY = point.y - uy * 16
|
|
382
|
+
|
|
383
|
+
// Single line: always present for 'one' and part of others
|
|
384
|
+
const hasOneLine = cardinality === 'one' || cardinality === 'zero-one'
|
|
385
|
+
const hasCrowsFoot = cardinality === 'many' || cardinality === 'zero-many'
|
|
386
|
+
const hasCircle = cardinality === 'zero-one' || cardinality === 'zero-many'
|
|
387
|
+
|
|
388
|
+
// Draw single vertical line (perpendicular to edge) at the tip
|
|
389
|
+
if (hasOneLine) {
|
|
390
|
+
const halfW = 6
|
|
391
|
+
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}" />`,
|
|
395
|
+
)
|
|
396
|
+
// Second line slightly back for "exactly one" emphasis
|
|
397
|
+
const line2X = tipX - ux * 4
|
|
398
|
+
const line2Y = tipY - uy * 4
|
|
399
|
+
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}" />`,
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Crow's foot (three lines fanning out from tip)
|
|
407
|
+
if (hasCrowsFoot) {
|
|
408
|
+
const fanW = 7
|
|
409
|
+
// Center line
|
|
410
|
+
const cfTipX = tipX
|
|
411
|
+
const cfTipY = tipY
|
|
412
|
+
// Three lines from tip to back, fanning out
|
|
413
|
+
parts.push(
|
|
414
|
+
// 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}" />`,
|
|
418
|
+
)
|
|
419
|
+
parts.push(
|
|
420
|
+
// Center line
|
|
421
|
+
`<line x1="${cfTipX}" y1="${cfTipY}" ` +
|
|
422
|
+
`x2="${backX}" y2="${backY}" ` +
|
|
423
|
+
`stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
424
|
+
)
|
|
425
|
+
parts.push(
|
|
426
|
+
// 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}" />`,
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Circle (for zero variants)
|
|
434
|
+
if (hasCircle) {
|
|
435
|
+
const circleOffset = hasCrowsFoot ? 20 : 12
|
|
436
|
+
const circleX = point.x - ux * circleOffset
|
|
437
|
+
const circleY = point.y - uy * circleOffset
|
|
438
|
+
parts.push(
|
|
439
|
+
`<circle cx="${circleX}" cy="${circleY}" r="4" ` +
|
|
440
|
+
`fill="var(--bg)" stroke="var(--_line)" stroke-width="${sw}" />`,
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return parts.join('\n')
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Compute the arc-length midpoint of a polyline path.
|
|
448
|
+
* Walks along each segment, finds the point at exactly 50% of total path length.
|
|
449
|
+
* This ensures the label sits ON the path even for orthogonal routes with bends,
|
|
450
|
+
* unlike the naive first/last geometric center which floats in space for L/Z shapes. */
|
|
451
|
+
function midpoint(points: Array<{ x: number; y: number }>): {
|
|
452
|
+
x: number
|
|
453
|
+
y: number
|
|
454
|
+
} {
|
|
455
|
+
if (points.length === 0) return { x: 0, y: 0 }
|
|
456
|
+
if (points.length === 1) return points[0]!
|
|
457
|
+
|
|
458
|
+
// Compute total path length
|
|
459
|
+
let totalLen = 0
|
|
460
|
+
for (let i = 1; i < points.length; i++) {
|
|
461
|
+
const dx = points[i]!.x - points[i - 1]!.x
|
|
462
|
+
const dy = points[i]!.y - points[i - 1]!.y
|
|
463
|
+
totalLen += Math.sqrt(dx * dx + dy * dy)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (totalLen === 0) return points[0]!
|
|
467
|
+
|
|
468
|
+
// Walk to 50% of total length, interpolating within the segment that crosses the halfway mark
|
|
469
|
+
const halfLen = totalLen / 2
|
|
470
|
+
let walked = 0
|
|
471
|
+
for (let i = 1; i < points.length; i++) {
|
|
472
|
+
const dx = points[i]!.x - points[i - 1]!.x
|
|
473
|
+
const dy = points[i]!.y - points[i - 1]!.y
|
|
474
|
+
const segLen = Math.sqrt(dx * dx + dy * dy)
|
|
475
|
+
if (walked + segLen >= halfLen) {
|
|
476
|
+
const t = segLen > 0 ? (halfLen - walked) / segLen : 0
|
|
477
|
+
return {
|
|
478
|
+
x: points[i - 1]!.x + dx * t,
|
|
479
|
+
y: points[i - 1]!.y + dy * t,
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
walked += segLen
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return points[points.length - 1]!
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ============================================================================
|
|
489
|
+
// Utilities
|
|
490
|
+
// ============================================================================
|
|
491
|
+
|
|
492
|
+
// Use shared escapeXml from multiline-utils
|
|
493
|
+
const escapeXml = escapeXmlUtil
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @zombie-mermaid/svg-renderer — SVG emission and ELK-backed layout
|
|
3
|
+
//
|
|
4
|
+
// Every file re-exported below was grep-verified (zombie-mermaid#625,
|
|
5
|
+
// umbrella #620) as unreachable from `src/ascii/index.ts` except through
|
|
6
|
+
// the SVG-side modules the umbrella still owns — so nothing here can put
|
|
7
|
+
// `elkjs` on the ASCII renderer's dependency path once #623 extracts it.
|
|
8
|
+
//
|
|
9
|
+
// This package depends on `@zombie-mermaid/core` and, since #624, on
|
|
10
|
+
// `@zombie-mermaid/mermaid-parser` too — an ordinary, acyclic dependency
|
|
11
|
+
// (both `svg-renderer` and the future `ascii-renderer` depend on
|
|
12
|
+
// `mermaid-parser` directly, per the scoping doc's finding 2), not a cycle:
|
|
13
|
+
// every per-type `layout.ts`/`renderer.ts` below needs the positioned-
|
|
14
|
+
// diagram types `mermaid-parser`'s `types.ts` halves own, and
|
|
15
|
+
// `class/layout.ts` needs `formatClassMember` from `mermaid-parser`'s
|
|
16
|
+
// `class/format.ts`. It deliberately does NOT re-export the internal
|
|
17
|
+
// `layout-engine/` modules the umbrella never imports
|
|
18
|
+
// (`constants.ts` — whose `DEFAULTS` would collide with `core`'s theme
|
|
19
|
+
// `DEFAULTS` anyway — plus `edge-bundling.ts`, `from-elk.ts`,
|
|
20
|
+
// `layer-alignment.ts` and `to-elk.ts`): they are `layoutGraphSync()`'s
|
|
21
|
+
// implementation, not its API. `elk-graph-builder.ts` is the exception:
|
|
22
|
+
// `class/layout.ts` and `er/layout.ts` (moved into this package under
|
|
23
|
+
// #624) call its primitives directly (zombie-mermaid#616), so it is part
|
|
24
|
+
// of the public API alongside `elk-adapter-utils.ts`.
|
|
25
|
+
//
|
|
26
|
+
// `layout.ts` re-exports `layoutGraphSync` from `layout-engine.ts`, so the
|
|
27
|
+
// two star-exports below resolve to one and the same binding — legal, and
|
|
28
|
+
// not an ambiguous re-export. It stays a module of its own (rather than
|
|
29
|
+
// being folded away here) because #625 is a move, not a redesign.
|
|
30
|
+
//
|
|
31
|
+
// `class/`, `er/`, `sequence/`, `xychart/` hold each diagram type's
|
|
32
|
+
// renderer half (`layout.ts` + `renderer.ts`) — the other half
|
|
33
|
+
// (`parser.ts`/`types.ts`, plus `class/format.ts`, `sequence/box-color.ts`,
|
|
34
|
+
// `sequence/activation-check.ts`, `xychart/colors.ts`) moved to
|
|
35
|
+
// `@zombie-mermaid/mermaid-parser` under the same issue (#624), per the
|
|
36
|
+
// scoping doc's finding 1: each per-type directory used to mix both halves
|
|
37
|
+
// in one place, and the split point already existed at the file level.
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
export * from './edge-curves.ts'
|
|
41
|
+
export * from './elk-instance.ts'
|
|
42
|
+
export * from './layout.ts'
|
|
43
|
+
export * from './layout-engine.ts'
|
|
44
|
+
export * from './layout-engine/elk-adapter-utils.ts'
|
|
45
|
+
export * from './layout-engine/elk-graph-builder.ts'
|
|
46
|
+
export * from './renderer.ts'
|
|
47
|
+
export * from './resolve-colors.ts'
|
|
48
|
+
export * from './shape-clipping.ts'
|
|
49
|
+
export * from './styles.ts'
|
|
50
|
+
|
|
51
|
+
export * from './class/layout.ts'
|
|
52
|
+
export * from './class/renderer.ts'
|
|
53
|
+
export * from './er/layout.ts'
|
|
54
|
+
export * from './er/renderer.ts'
|
|
55
|
+
export * from './sequence/layout.ts'
|
|
56
|
+
export * from './sequence/renderer.ts'
|
|
57
|
+
export * from './xychart/layout.ts'
|
|
58
|
+
export * from './xychart/renderer.ts'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared defaults for the ELK-based layout engine.
|
|
3
|
+
* Split out of layout-engine.ts so both the ELK conversion and the
|
|
4
|
+
* post-processing stages (layer alignment, elk-result conversion) can
|
|
5
|
+
* reference the same values without importing the top-level module.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { FONT_SIZES } from '../styles.ts'
|
|
9
|
+
|
|
10
|
+
/** Default render options (layout-only) */
|
|
11
|
+
export const DEFAULTS = {
|
|
12
|
+
font: 'Inter',
|
|
13
|
+
padding: 40,
|
|
14
|
+
nodeSpacing: 28,
|
|
15
|
+
layerSpacing: 48,
|
|
16
|
+
mergeEdges: true,
|
|
17
|
+
thoroughness: 3,
|
|
18
|
+
fontSizes: FONT_SIZES,
|
|
19
|
+
} as const
|