@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.
@@ -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
  )
@@ -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
- ...yTicks.map((v) =>
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: formatTickValue(v),
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: formatTickValue(v),
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
@@ -506,17 +506,21 @@ function roundedRightBarPath(
506
506
  }
507
507
 
508
508
  // ============================================================================
509
- // Smooth line interpolation — Natural cubic spline
509
+ // Smooth line interpolation — monotone cubic (Fritsch-Carlson / PCHIP)
510
510
  //
511
- // Computes the mathematically smoothest curve through all data points by
512
- // minimizing total curvature (integrated second derivative). Treats y as a
513
- // function of x, so the curve can never go backwards.
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
- // Algorithm: tridiagonal system for second derivatives (Thomas algorithm),
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(points: Array<{ x: number; y: number }>): string {
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 — including offsets like i-1/i+1 and n-2 — is bound by a `< n - 1`
530
- // or `< n` loop condition, or is a fixed offset from n itself, into arrays
531
- // sized exactly `n` or `n - 1`. Non-null assertions on those accesses are
532
- // the same bounds-checked idiom as loop-index array access elsewhere in
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. Solve tridiagonal system for second derivatives c[] (natural boundary: c[0] = c[n-1] = 0)
545
- const c = new Array<number>(n).fill(0)
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 = 0; i < n - 1; i++) {
571
- slopes[i] = delta[i]! - (h[i]! * (2 * c[i]! + c[i + 1]!)) / 3
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
- // Slope at last point: derivative of last segment at its end
574
- slopes[n - 1] = delta[n - 2]! + (h[n - 2]! * c[n - 2]!) / 3
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)}`