@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
package/src/sequence/renderer.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
buildStyleBlock,
|
|
16
16
|
renderMultilineText,
|
|
17
17
|
escapeAttr,
|
|
18
|
+
f,
|
|
18
19
|
} from '@zombie-mermaid/core'
|
|
19
20
|
import { withDataSrc } from '../renderer.ts'
|
|
20
21
|
import {
|
|
@@ -140,12 +141,12 @@ function arrowMarkerDefs(): string {
|
|
|
140
141
|
const w = ARROW_HEAD.width
|
|
141
142
|
const h = ARROW_HEAD.height
|
|
142
143
|
return (
|
|
143
|
-
` <marker id="seq-arrow" markerWidth="${w}" markerHeight="${h}" refX="${w}" refY="${h / 2}" orient="auto-start-reverse">` +
|
|
144
|
-
`\n <polygon points="0 0, ${w} ${h / 2}, 0 ${h}" fill="var(--_arrow)" />` +
|
|
144
|
+
f` <marker id="seq-arrow" markerWidth="${w}" markerHeight="${h}" refX="${w}" refY="${h / 2}" orient="auto-start-reverse">` +
|
|
145
|
+
f`\n <polygon points="0 0, ${w} ${h / 2}, 0 ${h}" fill="var(--_arrow)" />` +
|
|
145
146
|
`\n </marker>` +
|
|
146
147
|
// Open arrow head (just lines, no fill)
|
|
147
|
-
`\n <marker id="seq-arrow-open" markerWidth="${w}" markerHeight="${h}" refX="${w}" refY="${h / 2}" orient="auto-start-reverse">` +
|
|
148
|
-
`\n <polyline points="0 0, ${w} ${h / 2}, 0 ${h}" fill="none" stroke="var(--_arrow)" stroke-width="1" />` +
|
|
148
|
+
f`\n <marker id="seq-arrow-open" markerWidth="${w}" markerHeight="${h}" refX="${w}" refY="${h / 2}" orient="auto-start-reverse">` +
|
|
149
|
+
f`\n <polyline points="0 0, ${w} ${h / 2}, 0 ${h}" fill="none" stroke="var(--_arrow)" stroke-width="1" />` +
|
|
149
150
|
`\n </marker>`
|
|
150
151
|
)
|
|
151
152
|
}
|
|
@@ -164,7 +165,7 @@ function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
|
|
|
164
165
|
|
|
165
166
|
// Semantic wrapper with actor metadata
|
|
166
167
|
parts.push(
|
|
167
|
-
`<g class="actor" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}" data-type="${type}">`,
|
|
168
|
+
f`<g class="actor" data-id="${escapeAttr(id)}" data-label="${escapeAttr(label)}" data-type="${type}">`,
|
|
168
169
|
)
|
|
169
170
|
|
|
170
171
|
if (type === 'actor') {
|
|
@@ -179,13 +180,13 @@ function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
|
|
|
179
180
|
const iconStroke = 'var(--_line)' // use line color for actor icon strokes
|
|
180
181
|
|
|
181
182
|
parts.push(
|
|
182
|
-
` <g transform="translate(${tx},${ty}) scale(${s})">` +
|
|
183
|
+
f` <g transform="translate(${tx},${ty}) scale(${s})">` +
|
|
183
184
|
// Outer circle
|
|
184
|
-
`\n <path d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
|
|
185
|
+
f`\n <path d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
|
|
185
186
|
// Head
|
|
186
|
-
`\n <path d="M15 10C15 11.6569 13.6569 13 12 13C10.3431 13 9 11.6569 9 10C9 8.34315 10.3431 7 12 7C13.6569 7 15 8.34315 15 10Z" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
|
|
187
|
+
f`\n <path d="M15 10C15 11.6569 13.6569 13 12 13C10.3431 13 9 11.6569 9 10C9 8.34315 10.3431 7 12 7C13.6569 7 15 8.34315 15 10Z" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
|
|
187
188
|
// Shoulders
|
|
188
|
-
`\n <path d="M5.62842 18.3563C7.08963 17.0398 9.39997 16 12 16C14.6 16 16.9104 17.0398 18.3716 18.3563" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
|
|
189
|
+
f`\n <path d="M5.62842 18.3563C7.08963 17.0398 9.39997 16 12 16C14.6 16 16.9104 17.0398 18.3716 18.3563" fill="none" stroke="${iconStroke}" stroke-width="${sw}" />` +
|
|
189
190
|
`\n </g>`,
|
|
190
191
|
)
|
|
191
192
|
// Label below the icon (supports multi-line)
|
|
@@ -196,15 +197,15 @@ function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
|
|
|
196
197
|
x,
|
|
197
198
|
y + height + 14,
|
|
198
199
|
fontSizes.nodeLabel,
|
|
199
|
-
`font-size="${fontSizes.nodeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="var(--_text)"`,
|
|
200
|
+
f`font-size="${fontSizes.nodeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="var(--_text)"`,
|
|
200
201
|
),
|
|
201
202
|
)
|
|
202
203
|
} else {
|
|
203
204
|
// Participant: rectangle box with label (supports multi-line)
|
|
204
205
|
const boxX = x - width / 2
|
|
205
206
|
parts.push(
|
|
206
|
-
` <rect x="${boxX}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" ` +
|
|
207
|
-
`fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
207
|
+
f` <rect x="${boxX}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" ` +
|
|
208
|
+
f`fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
208
209
|
)
|
|
209
210
|
parts.push(
|
|
210
211
|
' ' +
|
|
@@ -213,7 +214,7 @@ function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
|
|
|
213
214
|
x,
|
|
214
215
|
y + height / 2,
|
|
215
216
|
fontSizes.nodeLabel,
|
|
216
|
-
`font-size="${fontSizes.nodeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="var(--_text)"`,
|
|
217
|
+
f`font-size="${fontSizes.nodeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="var(--_text)"`,
|
|
217
218
|
),
|
|
218
219
|
)
|
|
219
220
|
}
|
|
@@ -228,8 +229,8 @@ function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
|
|
|
228
229
|
*/
|
|
229
230
|
function renderLifeline(lifeline: Lifeline): string {
|
|
230
231
|
const line =
|
|
231
|
-
`<line class="lifeline" data-actor="${escapeAttr(lifeline.actorId)}" ` +
|
|
232
|
-
`x1="${lifeline.x}" y1="${lifeline.topY}" x2="${lifeline.x}" y2="${lifeline.bottomY}" ` +
|
|
232
|
+
f`<line class="lifeline" data-actor="${escapeAttr(lifeline.actorId)}" ` +
|
|
233
|
+
f`x1="${lifeline.x}" y1="${lifeline.topY}" x2="${lifeline.x}" y2="${lifeline.bottomY}" ` +
|
|
233
234
|
`stroke="var(--_line)" stroke-width="0.75" stroke-dasharray="6 4" />`
|
|
234
235
|
if (!lifeline.destroyed) return line
|
|
235
236
|
// `destroy X`: the lifeline ends at the destroying message's row, marked
|
|
@@ -239,9 +240,9 @@ function renderLifeline(lifeline: Lifeline): string {
|
|
|
239
240
|
const r = DESTROY_CROSS_HALF
|
|
240
241
|
return (
|
|
241
242
|
line +
|
|
242
|
-
`\n<path class="destroy" data-actor="${escapeAttr(lifeline.actorId)}" ` +
|
|
243
|
-
`d="M${x - r} ${y - r} L${x + r} ${y + r} M${x + r} ${y - r} L${x - r} ${y + r}" ` +
|
|
244
|
-
`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.outerBox}" stroke-linecap="round" />`
|
|
243
|
+
f`\n<path class="destroy" data-actor="${escapeAttr(lifeline.actorId)}" ` +
|
|
244
|
+
f`d="M${x - r} ${y - r} L${x + r} ${y + r} M${x + r} ${y - r} L${x - r} ${y + r}" ` +
|
|
245
|
+
f`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.outerBox}" stroke-linecap="round" />`
|
|
245
246
|
)
|
|
246
247
|
}
|
|
247
248
|
|
|
@@ -254,9 +255,9 @@ const DESTROY_CROSS_HALF = 8
|
|
|
254
255
|
*/
|
|
255
256
|
function renderActivation(activation: Activation): string {
|
|
256
257
|
return (
|
|
257
|
-
`<rect class="activation" data-actor="${escapeAttr(activation.actorId)}" ` +
|
|
258
|
-
`x="${activation.x}" y="${activation.topY}" width="${activation.width}" height="${activation.bottomY - activation.topY}" ` +
|
|
259
|
-
`fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`
|
|
258
|
+
f`<rect class="activation" data-actor="${escapeAttr(activation.actorId)}" ` +
|
|
259
|
+
f`x="${activation.x}" y="${activation.topY}" width="${activation.width}" height="${activation.bottomY - activation.topY}" ` +
|
|
260
|
+
f`fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`
|
|
260
261
|
)
|
|
261
262
|
}
|
|
262
263
|
|
|
@@ -273,14 +274,14 @@ function renderMessage(msg: PositionedMessage, fontSizes: FontSizes): string {
|
|
|
273
274
|
// automatically flips 180° for marker-start so it points outward at the
|
|
274
275
|
// line's start instead of reusing the marker-end orientation.
|
|
275
276
|
const markerStart = msg.bidirectional
|
|
276
|
-
? ` marker-start="url(#${markerId})"`
|
|
277
|
+
? f` marker-start="url(#${markerId})"`
|
|
277
278
|
: ''
|
|
278
279
|
|
|
279
280
|
// Semantic wrapper with message metadata
|
|
280
281
|
parts.push(
|
|
281
|
-
`<g class="message" data-from="${escapeAttr(msg.from)}" data-to="${escapeAttr(msg.to)}" ` +
|
|
282
|
-
`data-label="${escapeAttr(msg.label)}" data-line-style="${msg.lineStyle}" ` +
|
|
283
|
-
`data-arrow-head="${msg.arrowHead}" data-self="${msg.isSelf}" data-bidirectional="${msg.bidirectional}">`,
|
|
282
|
+
f`<g class="message" data-from="${escapeAttr(msg.from)}" data-to="${escapeAttr(msg.to)}" ` +
|
|
283
|
+
f`data-label="${escapeAttr(msg.label)}" data-line-style="${msg.lineStyle}" ` +
|
|
284
|
+
f`data-arrow-head="${msg.arrowHead}" data-self="${msg.isSelf}" data-bidirectional="${msg.bidirectional}">`,
|
|
284
285
|
)
|
|
285
286
|
|
|
286
287
|
if (msg.isSelf) {
|
|
@@ -290,8 +291,8 @@ function renderMessage(msg: PositionedMessage, fontSizes: FontSizes): string {
|
|
|
290
291
|
const loopH = 20
|
|
291
292
|
const labelPadding = 8 // Space between loop and label
|
|
292
293
|
parts.push(
|
|
293
|
-
` <polyline points="${msg.x1},${msg.y} ${msg.x1 + loopW},${msg.y} ${msg.x1 + loopW},${msg.y + loopH} ${msg.x2},${msg.y + loopH}" ` +
|
|
294
|
-
`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}"${dashArray} marker-end="url(#${markerId})"${markerStart} />`,
|
|
294
|
+
f` <polyline points="${msg.x1},${msg.y} ${msg.x1 + loopW},${msg.y} ${msg.x1 + loopW},${msg.y + loopH} ${msg.x2},${msg.y + loopH}" ` +
|
|
295
|
+
f`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}"${dashArray} marker-end="url(#${markerId})"${markerStart} />`,
|
|
295
296
|
)
|
|
296
297
|
// Label to the right of the loop (supports multi-line)
|
|
297
298
|
parts.push(
|
|
@@ -301,14 +302,14 @@ function renderMessage(msg: PositionedMessage, fontSizes: FontSizes): string {
|
|
|
301
302
|
msg.x1 + loopW + labelPadding,
|
|
302
303
|
msg.y + loopH / 2,
|
|
303
304
|
fontSizes.edgeLabel,
|
|
304
|
-
`font-size="${fontSizes.edgeLabel}" text-anchor="start" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
305
|
+
f`font-size="${fontSizes.edgeLabel}" text-anchor="start" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
305
306
|
),
|
|
306
307
|
)
|
|
307
308
|
} else {
|
|
308
309
|
// Normal message: horizontal arrow
|
|
309
310
|
parts.push(
|
|
310
|
-
` <line x1="${msg.x1}" y1="${msg.y}" x2="${msg.x2}" y2="${msg.y}" ` +
|
|
311
|
-
`stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}"${dashArray} marker-end="url(#${markerId})"${markerStart} />`,
|
|
311
|
+
f` <line x1="${msg.x1}" y1="${msg.y}" x2="${msg.x2}" y2="${msg.y}" ` +
|
|
312
|
+
f`stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}"${dashArray} marker-end="url(#${markerId})"${markerStart} />`,
|
|
312
313
|
)
|
|
313
314
|
// Label above the arrow, centered (supports multi-line)
|
|
314
315
|
const midX = (msg.x1 + msg.x2) / 2
|
|
@@ -319,7 +320,7 @@ function renderMessage(msg: PositionedMessage, fontSizes: FontSizes): string {
|
|
|
319
320
|
midX,
|
|
320
321
|
msg.y - 10,
|
|
321
322
|
fontSizes.edgeLabel,
|
|
322
|
-
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
323
|
+
f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
323
324
|
),
|
|
324
325
|
)
|
|
325
326
|
}
|
|
@@ -353,13 +354,13 @@ function renderSeqNumberBadge(
|
|
|
353
354
|
: msg.x1
|
|
354
355
|
return (
|
|
355
356
|
`<g class="seq-number">` +
|
|
356
|
-
`<circle cx="${cx}" cy="${msg.y}" r="${radius}" fill="var(--bg)" stroke="var(--_arrow)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
357
|
+
f`<circle cx="${cx}" cy="${msg.y}" r="${radius}" fill="var(--bg)" stroke="var(--_arrow)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
357
358
|
renderMultilineText(
|
|
358
359
|
String(msg.seqNumber),
|
|
359
360
|
cx,
|
|
360
361
|
msg.y,
|
|
361
362
|
fontSize,
|
|
362
|
-
`font-size="${fontSize}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_arrow)"`,
|
|
363
|
+
f`font-size="${fontSize}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_arrow)"`,
|
|
363
364
|
) +
|
|
364
365
|
`</g>`
|
|
365
366
|
)
|
|
@@ -389,17 +390,17 @@ function renderParticipantBox(
|
|
|
389
390
|
box: PositionedParticipantBox,
|
|
390
391
|
fontSizes: FontSizes,
|
|
391
392
|
): string {
|
|
392
|
-
const labelAttr = box.label ? ` data-label="${escapeAttr(box.label)}"` : ''
|
|
393
|
+
const labelAttr = box.label ? f` data-label="${escapeAttr(box.label)}"` : ''
|
|
393
394
|
const colorAttr =
|
|
394
|
-
box.color !== undefined ? ` data-color="${escapeAttr(box.color)}"` : ''
|
|
395
|
+
box.color !== undefined ? f` data-color="${escapeAttr(box.color)}"` : ''
|
|
395
396
|
const fill =
|
|
396
397
|
box.color !== undefined
|
|
397
|
-
? `color-mix(in srgb, ${escapeAttr(box.color)} ${BOX_COLOR_MIX_PERCENT}%, var(--bg))`
|
|
398
|
+
? f`color-mix(in srgb, ${escapeAttr(box.color)} ${BOX_COLOR_MIX_PERCENT}%, var(--bg))`
|
|
398
399
|
: 'none'
|
|
399
400
|
const parts: string[] = [
|
|
400
|
-
`<g class="participant-box"${labelAttr}${colorAttr}>`,
|
|
401
|
-
` <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="4" ry="4" ` +
|
|
402
|
-
`fill="${fill}" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
401
|
+
f`<g class="participant-box"${labelAttr}${colorAttr}>`,
|
|
402
|
+
f` <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="4" ry="4" ` +
|
|
403
|
+
f`fill="${fill}" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />`,
|
|
403
404
|
]
|
|
404
405
|
if (box.label) {
|
|
405
406
|
// Centred in the label band the layout reserved above the actor boxes.
|
|
@@ -410,7 +411,7 @@ function renderParticipantBox(
|
|
|
410
411
|
box.x + box.width / 2,
|
|
411
412
|
box.y + boxLabelHeight(fontSizes.edgeLabel) / 2,
|
|
412
413
|
fontSizes.edgeLabel,
|
|
413
|
-
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)"`,
|
|
414
|
+
f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)"`,
|
|
414
415
|
),
|
|
415
416
|
)
|
|
416
417
|
}
|
|
@@ -427,21 +428,21 @@ function renderBlock(block: PositionedBlock, fontSizes: FontSizes): string {
|
|
|
427
428
|
|
|
428
429
|
// Semantic wrapper with block metadata
|
|
429
430
|
const labelAttr = block.label
|
|
430
|
-
? ` data-label="${escapeAttr(block.label)}"`
|
|
431
|
+
? f` data-label="${escapeAttr(block.label)}"`
|
|
431
432
|
: ''
|
|
432
433
|
parts.push(
|
|
433
|
-
`<g class="block" data-type="${escapeAttr(block.type)}"${labelAttr}>`,
|
|
434
|
+
f`<g class="block" data-type="${escapeAttr(block.type)}"${labelAttr}>`,
|
|
434
435
|
)
|
|
435
436
|
|
|
436
437
|
// Outer rectangle
|
|
437
438
|
parts.push(
|
|
438
|
-
` <rect x="${block.x}" y="${block.y}" width="${block.width}" height="${block.height}" ` +
|
|
439
|
-
`rx="0" ry="0" fill="none" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
439
|
+
f` <rect x="${block.x}" y="${block.y}" width="${block.width}" height="${block.height}" ` +
|
|
440
|
+
f`rx="0" ry="0" fill="none" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
440
441
|
)
|
|
441
442
|
|
|
442
443
|
// Type label tab (top-left corner)
|
|
443
444
|
// For multi-line block labels, we use the first line for the tab but show full label
|
|
444
|
-
const labelText = `${block.type}${block.label ? ` [${block.label}]` : ''}`
|
|
445
|
+
const labelText = f`${block.type}${block.label ? f` [${block.label}]` : ''}`
|
|
445
446
|
// Audited for issue #100: `String.prototype.split` always returns an
|
|
446
447
|
// array with at least one element (even splitting `''` yields `['']`),
|
|
447
448
|
// so index 0 is guaranteed to exist regardless of whether `labelText`
|
|
@@ -457,8 +458,8 @@ function renderBlock(block: PositionedBlock, fontSizes: FontSizes): string {
|
|
|
457
458
|
const tabHeight = 18
|
|
458
459
|
|
|
459
460
|
parts.push(
|
|
460
|
-
` <rect x="${block.x}" y="${block.y}" width="${tabWidth}" height="${tabHeight}" ` +
|
|
461
|
-
`fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
461
|
+
f` <rect x="${block.x}" y="${block.y}" width="${tabWidth}" height="${tabHeight}" ` +
|
|
462
|
+
f`fill="var(--_group-hdr)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.outerBox}" />`,
|
|
462
463
|
)
|
|
463
464
|
// Block type label (supports multi-line via <br> tags)
|
|
464
465
|
parts.push(
|
|
@@ -468,14 +469,14 @@ function renderBlock(block: PositionedBlock, fontSizes: FontSizes): string {
|
|
|
468
469
|
block.x + 6,
|
|
469
470
|
block.y + tabHeight / 2,
|
|
470
471
|
fontSizes.edgeLabel,
|
|
471
|
-
`font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)"`,
|
|
472
|
+
f`font-size="${fontSizes.edgeLabel}" font-weight="${FONT_WEIGHTS.groupHeader}" fill="var(--_text-sec)"`,
|
|
472
473
|
),
|
|
473
474
|
)
|
|
474
475
|
|
|
475
476
|
// Divider lines (for alt/else, par/and)
|
|
476
477
|
for (const divider of block.dividers) {
|
|
477
478
|
parts.push(
|
|
478
|
-
` <line x1="${block.x}" y1="${divider.y}" x2="${block.x + block.width}" y2="${divider.y}" ` +
|
|
479
|
+
f` <line x1="${block.x}" y1="${divider.y}" x2="${block.x + block.width}" y2="${divider.y}" ` +
|
|
479
480
|
`stroke="var(--_line)" stroke-width="0.75" stroke-dasharray="6 4" />`,
|
|
480
481
|
)
|
|
481
482
|
if (divider.label) {
|
|
@@ -483,11 +484,11 @@ function renderBlock(block: PositionedBlock, fontSizes: FontSizes): string {
|
|
|
483
484
|
parts.push(
|
|
484
485
|
' ' +
|
|
485
486
|
renderMultilineText(
|
|
486
|
-
`[${divider.label}]`,
|
|
487
|
+
f`[${divider.label}]`,
|
|
487
488
|
block.x + 8,
|
|
488
489
|
divider.y + 14,
|
|
489
490
|
fontSizes.edgeLabel,
|
|
490
|
-
`font-size="${fontSizes.edgeLabel}" text-anchor="start" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
491
|
+
f`font-size="${fontSizes.edgeLabel}" text-anchor="start" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
491
492
|
),
|
|
492
493
|
)
|
|
493
494
|
}
|
|
@@ -509,37 +510,37 @@ function renderNote(note: PositionedNote, fontSizes: FontSizes): string {
|
|
|
509
510
|
// Build actor reference attribute if present
|
|
510
511
|
const actorsAttr =
|
|
511
512
|
note.actors && note.actors.length > 0
|
|
512
|
-
? ` data-actors="${note.actors.map(escapeAttr).join(',')}"`
|
|
513
|
+
? f` data-actors="${note.actors.map(escapeAttr).join(',')}"`
|
|
513
514
|
: ''
|
|
514
515
|
const positionAttr = note.position
|
|
515
|
-
? ` data-position="${escapeAttr(note.position)}"`
|
|
516
|
+
? f` data-position="${escapeAttr(note.position)}"`
|
|
516
517
|
: ''
|
|
517
518
|
|
|
518
519
|
// Note body: polygon with top-right corner cut off
|
|
519
520
|
// (x,y) → (x+w-fold,y) → (x+w,y+fold) → (x+w,y+h) → (x,y+h)
|
|
520
521
|
const bodyPoints = [
|
|
521
|
-
`${x},${y}`,
|
|
522
|
-
`${x + w - foldSize},${y}`,
|
|
523
|
-
`${x + w},${y + foldSize}`,
|
|
524
|
-
`${x + w},${y + h}`,
|
|
525
|
-
`${x},${y + h}`,
|
|
522
|
+
f`${x},${y}`,
|
|
523
|
+
f`${x + w - foldSize},${y}`,
|
|
524
|
+
f`${x + w},${y + foldSize}`,
|
|
525
|
+
f`${x + w},${y + h}`,
|
|
526
|
+
f`${x},${y + h}`,
|
|
526
527
|
].join(' ')
|
|
527
528
|
|
|
528
529
|
return (
|
|
529
|
-
`<g class="note"${positionAttr}${actorsAttr}>` +
|
|
530
|
+
f`<g class="note"${positionAttr}${actorsAttr}>` +
|
|
530
531
|
// Note body with bg fill and clipped corner
|
|
531
|
-
`\n <polygon points="${bodyPoints}" ` +
|
|
532
|
-
`fill="var(--bg)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
532
|
+
f`\n <polygon points="${bodyPoints}" ` +
|
|
533
|
+
f`fill="var(--bg)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
533
534
|
// Fold triangle (the folded-over corner)
|
|
534
|
-
`\n <polygon points="${x + w - foldSize},${y} ${x + w},${y + foldSize} ${x + w - foldSize},${y + foldSize}" ` +
|
|
535
|
-
`fill="var(--_inner-stroke)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
535
|
+
f`\n <polygon points="${x + w - foldSize},${y} ${x + w},${y + foldSize} ${x + w - foldSize},${y + foldSize}" ` +
|
|
536
|
+
f`fill="var(--_inner-stroke)" stroke="var(--_node-stroke)" stroke-width="${STROKE_WIDTHS.innerBox}" />` +
|
|
536
537
|
// Note text (supports multi-line)
|
|
537
|
-
`\n ${renderMultilineText(
|
|
538
|
+
f`\n ${renderMultilineText(
|
|
538
539
|
note.text,
|
|
539
540
|
x + w / 2,
|
|
540
541
|
y + h / 2,
|
|
541
542
|
fontSizes.edgeLabel,
|
|
542
|
-
`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
543
|
+
f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
543
544
|
)}` +
|
|
544
545
|
`\n</g>`
|
|
545
546
|
)
|
package/src/xychart/layout.ts
CHANGED
|
@@ -82,13 +82,10 @@ function layoutVertical(chart: XYChart): PositionedXYChart {
|
|
|
82
82
|
throw new Error('XY chart: y-axis range was not set by the parser')
|
|
83
83
|
}
|
|
84
84
|
const yTicks = niceTickValues(yRange.min, yRange.max)
|
|
85
|
+
const yLabels = formatTickLabels(yTicks)
|
|
85
86
|
const maxYLabelWidth = Math.max(
|
|
86
|
-
...
|
|
87
|
-
estimateTextWidth(
|
|
88
|
-
formatTickValue(v),
|
|
89
|
-
XY.axisLabelFontSize,
|
|
90
|
-
XY.axisLabelFontWeight,
|
|
91
|
-
),
|
|
87
|
+
...yLabels.map((label) =>
|
|
88
|
+
estimateTextWidth(label, XY.axisLabelFontSize, XY.axisLabelFontWeight),
|
|
92
89
|
),
|
|
93
90
|
XY.yLabelWidth,
|
|
94
91
|
)
|
|
@@ -127,8 +124,8 @@ function layoutVertical(chart: XYChart): PositionedXYChart {
|
|
|
127
124
|
const xTicks = buildXTicks(chart, xScale, top + plotH, bandWidth)
|
|
128
125
|
|
|
129
126
|
// Y-axis ticks
|
|
130
|
-
const yAxisTicks: AxisTick[] = yTicks.map((v) => ({
|
|
131
|
-
label:
|
|
127
|
+
const yAxisTicks: AxisTick[] = yTicks.map((v, i) => ({
|
|
128
|
+
label: yLabels[i]!,
|
|
132
129
|
x: left,
|
|
133
130
|
y: yScale(v),
|
|
134
131
|
tx: left - XY.tickLength,
|
|
@@ -251,6 +248,7 @@ function layoutHorizontal(chart: XYChart): PositionedXYChart {
|
|
|
251
248
|
throw new Error('XY chart: y-axis range was not set by the parser')
|
|
252
249
|
}
|
|
253
250
|
const valueTicks = niceTickValues(yRange.min, yRange.max)
|
|
251
|
+
const valueLabels = formatTickLabels(valueTicks)
|
|
254
252
|
|
|
255
253
|
// Compute category label widths for left margin
|
|
256
254
|
const dataCount = getDataCount(chart)
|
|
@@ -293,8 +291,8 @@ function layoutHorizontal(chart: XYChart): PositionedXYChart {
|
|
|
293
291
|
const catScale = (i: number) => top + (i + 0.5) * bandHeight
|
|
294
292
|
|
|
295
293
|
// X-axis (bottom): value ticks
|
|
296
|
-
const xTicks: AxisTick[] = valueTicks.map((v) => ({
|
|
297
|
-
label:
|
|
294
|
+
const xTicks: AxisTick[] = valueTicks.map((v, i) => ({
|
|
295
|
+
label: valueLabels[i]!,
|
|
298
296
|
x: valueScale(v),
|
|
299
297
|
y: top + plotH,
|
|
300
298
|
tx: valueScale(v),
|
|
@@ -606,7 +604,7 @@ function layoutLines(
|
|
|
606
604
|
}
|
|
607
605
|
|
|
608
606
|
/** Generate "nice" tick values for a numeric range */
|
|
609
|
-
function niceTickValues(min: number, max: number): number[] {
|
|
607
|
+
export function niceTickValues(min: number, max: number): number[] {
|
|
610
608
|
const range = max - min
|
|
611
609
|
if (range <= 0) return [min]
|
|
612
610
|
|
|
@@ -628,6 +626,24 @@ function niceTickValues(min: number, max: number): number[] {
|
|
|
628
626
|
return ticks
|
|
629
627
|
}
|
|
630
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Format a set of evenly-spaced tick values with enough decimal places to
|
|
631
|
+
* keep every label distinct. Precision comes from the tick step, not the
|
|
632
|
+
* magnitude of the value: a 0.5 step around 100 needs one decimal, or
|
|
633
|
+
* 99.5 and 100.5 both collapse into neighbouring whole numbers.
|
|
634
|
+
*/
|
|
635
|
+
export function formatTickLabels(ticks: number[]): string[] {
|
|
636
|
+
const step = ticks.length > 1 ? Math.abs(ticks[1]! - ticks[0]!) : 0
|
|
637
|
+
// The -1e-9 keeps an exact power-of-ten step (0.1) from rounding up
|
|
638
|
+
const decimals =
|
|
639
|
+
step > 0 && step < 1 ? Math.ceil(-Math.log10(step) - 1e-9) : 0
|
|
640
|
+
return ticks.map((v) =>
|
|
641
|
+
Number.isInteger(v) && decimals === 0
|
|
642
|
+
? String(v)
|
|
643
|
+
: String(Number(v.toFixed(decimals))),
|
|
644
|
+
)
|
|
645
|
+
}
|
|
646
|
+
|
|
631
647
|
function formatTickValue(v: number): string {
|
|
632
648
|
if (Number.isInteger(v)) return String(v)
|
|
633
649
|
// Limit decimal places
|
package/src/xychart/renderer.ts
CHANGED
|
@@ -506,17 +506,21 @@ function roundedRightBarPath(
|
|
|
506
506
|
}
|
|
507
507
|
|
|
508
508
|
// ============================================================================
|
|
509
|
-
// Smooth line interpolation —
|
|
509
|
+
// Smooth line interpolation — monotone cubic (Fritsch-Carlson / PCHIP)
|
|
510
510
|
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
511
|
+
// A natural cubic spline is smooth but not shape-preserving: near a sharp
|
|
512
|
+
// spike it overshoots the data, drawing a peak above the maximum or a dip
|
|
513
|
+
// below the minimum (even below a zero baseline). Monotone cubic
|
|
514
|
+
// interpolation keeps each segment monotone between its two points and puts a
|
|
515
|
+
// flat tangent at every local extremum, so the curve never leaves the range of
|
|
516
|
+
// the data. y is treated as a function of x, so the curve never goes backwards.
|
|
514
517
|
//
|
|
515
|
-
//
|
|
516
|
-
// then convert each cubic segment to SVG cubic Bezier commands.
|
|
518
|
+
// Each cubic segment is converted to an SVG cubic Bezier command.
|
|
517
519
|
// ============================================================================
|
|
518
520
|
|
|
519
|
-
function smoothCurvePath(
|
|
521
|
+
export function smoothCurvePath(
|
|
522
|
+
points: Array<{ x: number; y: number }>,
|
|
523
|
+
): string {
|
|
520
524
|
if (points.length === 0) return ''
|
|
521
525
|
if (points.length === 1) return `M${r(points[0]!.x)},${r(points[0]!.y)}`
|
|
522
526
|
if (points.length === 2) {
|
|
@@ -526,12 +530,10 @@ function smoothCurvePath(points: Array<{ x: number; y: number }>): string {
|
|
|
526
530
|
const n = points.length
|
|
527
531
|
|
|
528
532
|
// From here on, n >= 3 (the n <= 2 cases returned above). Every index used
|
|
529
|
-
// below
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
// this codebase and are left as-is (justified, not asserting past a real
|
|
534
|
-
// gap in type info).
|
|
533
|
+
// below is bound by a `< n - 1` or `< n` loop condition, or is a fixed
|
|
534
|
+
// offset from n itself, into arrays sized exactly `n` or `n - 1`.
|
|
535
|
+
// Non-null assertions on those accesses are the same bounds-checked idiom as
|
|
536
|
+
// loop-index array access elsewhere in this codebase.
|
|
535
537
|
|
|
536
538
|
// 1. Interval widths and secant slopes
|
|
537
539
|
const h: number[] = []
|
|
@@ -541,37 +543,26 @@ function smoothCurvePath(points: Array<{ x: number; y: number }>): string {
|
|
|
541
543
|
delta.push(h[i]! === 0 ? 0 : (points[i + 1]!.y - points[i]!.y) / h[i]!)
|
|
542
544
|
}
|
|
543
545
|
|
|
544
|
-
// 2.
|
|
545
|
-
|
|
546
|
-
if (n > 2) {
|
|
547
|
-
// Forward elimination
|
|
548
|
-
const cp = new Array<number>(n).fill(0) // modified upper diagonal
|
|
549
|
-
const dp = new Array<number>(n).fill(0) // modified right-hand side
|
|
550
|
-
for (let i = 1; i < n - 1; i++) {
|
|
551
|
-
const diag = 2 * (h[i - 1]! + h[i]!)
|
|
552
|
-
const rhs = 3 * (delta[i]! - delta[i - 1]!)
|
|
553
|
-
if (i === 1) {
|
|
554
|
-
cp[i] = h[i]! / diag
|
|
555
|
-
dp[i] = rhs / diag
|
|
556
|
-
} else {
|
|
557
|
-
const w = diag - h[i - 1]! * cp[i - 1]!
|
|
558
|
-
cp[i] = h[i]! / w
|
|
559
|
-
dp[i] = (rhs - h[i - 1]! * dp[i - 1]!) / w
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
// Back substitution
|
|
563
|
-
for (let i = n - 2; i >= 1; i--) {
|
|
564
|
-
c[i] = dp[i]! - cp[i]! * c[i + 1]!
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// 3. Compute first derivatives (slopes) at each knot
|
|
546
|
+
// 2. Tangent at each knot (Fritsch-Carlson). Interior: weighted harmonic
|
|
547
|
+
// mean of the adjacent secants, or 0 at a local extremum / flat run.
|
|
569
548
|
const slopes = new Array<number>(n).fill(0)
|
|
570
|
-
for (let i =
|
|
571
|
-
|
|
549
|
+
for (let i = 1; i < n - 1; i++) {
|
|
550
|
+
const d0 = delta[i - 1]!
|
|
551
|
+
const d1 = delta[i]!
|
|
552
|
+
if (d0 * d1 <= 0) continue
|
|
553
|
+
const w1 = 2 * h[i]! + h[i - 1]!
|
|
554
|
+
const w2 = h[i]! + 2 * h[i - 1]!
|
|
555
|
+
slopes[i] = (w1 + w2) / (w1 / d0 + w2 / d1)
|
|
556
|
+
}
|
|
557
|
+
// Ends: one-sided three-point estimate, clamped so it can't overshoot.
|
|
558
|
+
const endSlope = (h0: number, h1: number, d0: number, d1: number): number => {
|
|
559
|
+
let m = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1)
|
|
560
|
+
if (m * d0 <= 0) m = 0
|
|
561
|
+
else if (d0 * d1 <= 0 && Math.abs(m) > 3 * Math.abs(d0)) m = 3 * d0
|
|
562
|
+
return m
|
|
572
563
|
}
|
|
573
|
-
|
|
574
|
-
slopes[n - 1] =
|
|
564
|
+
slopes[0] = endSlope(h[0]!, h[1]!, delta[0]!, delta[1]!)
|
|
565
|
+
slopes[n - 1] = endSlope(h[n - 2]!, h[n - 3]!, delta[n - 2]!, delta[n - 3]!)
|
|
575
566
|
|
|
576
567
|
// 4. Convert to cubic Bezier — control points strictly between endpoints in x
|
|
577
568
|
let path = `M${r(points[0]!.x)},${r(points[0]!.y)}`
|