@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.
@@ -0,0 +1,684 @@
1
+ import type { PositionedXYChart } from '@zombie-mermaid/mermaid-parser'
2
+ import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
3
+ import { svgOpenTag, buildStyleBlock, styleOpenTag } from '@zombie-mermaid/core'
4
+ import { withDataSrc } from '../renderer.ts'
5
+ import { TEXT_BASELINE_SHIFT, estimateTextWidth } from '../styles.ts'
6
+ import {
7
+ getSeriesColor,
8
+ CHART_ACCENT_FALLBACK,
9
+ } from '@zombie-mermaid/mermaid-parser'
10
+
11
+ // ============================================================================
12
+ // XY Chart SVG renderer
13
+ //
14
+ // Renders positioned XY charts to SVG strings.
15
+ // All colors use CSS custom properties (var(--_xxx)) from the theme system.
16
+ //
17
+ // Visual style: clean, minimal, modern. Inspired by Apple/Craft chart design.
18
+ // - No axis lines or tick marks — labels float freely
19
+ // - Ultra-subtle solid grid lines
20
+ // - Bars with rounded tops, flat at baseline
21
+ // - Smooth curved lines, no visible dots (dots appear on hover)
22
+ //
23
+ // Render order (back to front):
24
+ // 1. Grid lines
25
+ // 2. Bars (as paths with rounded tops)
26
+ // 3. Lines (smooth curves)
27
+ // 4. Dots (hidden by default, visible on hover when interactive)
28
+ // 5. Axis labels
29
+ // 6. Axis titles
30
+ // 7. Chart title
31
+ // 8. Legend
32
+ // ============================================================================
33
+
34
+ const CHART_FONT = {
35
+ titleSize: 18,
36
+ titleWeight: 600,
37
+ axisTitleSize: 15,
38
+ axisTitleWeight: 500,
39
+ labelSize: 14,
40
+ labelWeight: 400,
41
+ legendSize: 14,
42
+ legendWeight: 400,
43
+ dotRadius: 5,
44
+ lineWidth: 2.5,
45
+ barRadius: 8,
46
+ } as const
47
+
48
+ const TIP = {
49
+ fontSize: 15,
50
+ fontWeight: 500,
51
+ height: 32,
52
+ padX: 14,
53
+ offsetY: 12,
54
+ rx: 8,
55
+ minY: 4,
56
+ pointerSize: 6,
57
+ } as const
58
+
59
+ /**
60
+ * Render a positioned XY chart as an SVG string.
61
+ *
62
+ * @param embedSource - Original diagram source to stamp onto the root
63
+ * `<svg>` as `data-src` (from `options.embedSource`).
64
+ * Omitted when the option is off.
65
+ * @param title - Accessible name (from `options.title`). See svgOpenTag() in
66
+ * packages/core/src/theme.ts.
67
+ * @param decorative - Marks the SVG decorative (from `options.decorative`).
68
+ * @param emit - Strict-CSP controls (from `options.nonce` /
69
+ * `options.styleAttribute`, see #216). Default: no nonce,
70
+ * root `style` attribute on.
71
+ */
72
+ export function renderXYChartSvg(
73
+ chart: PositionedXYChart,
74
+ colors: DiagramColors,
75
+ font: string = 'Inter',
76
+ transparent: boolean = false,
77
+ interactive: boolean = false,
78
+ embedSource?: string,
79
+ title?: string,
80
+ decorative?: boolean,
81
+ emit: SvgEmitOptions = {},
82
+ ): string {
83
+ const parts: string[] = []
84
+
85
+ // SVG root + base styles
86
+ // Stamp data-xychart-colors so theme-switching JS knows how many series color vars to update
87
+ const maxColorIdx = Math.max(
88
+ 0,
89
+ ...chart.bars.map((b) => b.colorIndex),
90
+ ...chart.lines.map((l) => l.colorIndex),
91
+ )
92
+ const svgTag = withDataSrc(
93
+ svgOpenTag(
94
+ chart.width,
95
+ chart.height,
96
+ colors,
97
+ transparent,
98
+ title,
99
+ decorative,
100
+ undefined,
101
+ emit.styleAttribute,
102
+ ).replace('<svg ', `<svg data-xychart-colors="${maxColorIdx}" `),
103
+ embedSource,
104
+ )
105
+ parts.push(svgTag)
106
+ parts.push(buildStyleBlock(font, false, emit.nonce))
107
+
108
+ // Sparse lines (≤12 points) show dots by default
109
+ const maxLinePoints = Math.max(...chart.lines.map((l) => l.points.length), 0)
110
+ const sparse = maxLinePoints > 0 && maxLinePoints <= 12
111
+
112
+ // Chart-specific styles + gradient defs
113
+ const { style: chartCss, defs: chartDefs } = chartStyles(
114
+ chart,
115
+ interactive,
116
+ sparse,
117
+ colors.accent,
118
+ colors.bg,
119
+ emit.nonce,
120
+ )
121
+ parts.push(chartCss)
122
+ if (chartDefs) parts.push(chartDefs)
123
+
124
+ // 1. Dot grid (dense dots across plot area, aligned to tick spacing)
125
+ const { plotArea } = chart
126
+ const xTicks = chart.xAxis.ticks.map((t) => t.x)
127
+ const yVals = chart.horizontal
128
+ ? chart.yAxis.ticks.map((t) => t.y)
129
+ : chart.gridLines.map((g) => g.y1)
130
+ const xBase =
131
+ xTicks.length > 1 ? Math.abs(xTicks[1]! - xTicks[0]!) : plotArea.width / 6
132
+ const yBase =
133
+ yVals.length > 1 ? Math.abs(yVals[1]! - yVals[0]!) : plotArea.height / 6
134
+ const xGap = xBase / Math.max(1, Math.round(xBase / 20))
135
+ const yGap = yBase / Math.max(1, Math.round(yBase / 20))
136
+ const xAnchor = xTicks[0] ?? plotArea.x
137
+ const yAnchor = yVals[0] ?? plotArea.y
138
+ const xStart = xAnchor - Math.ceil((xAnchor - plotArea.x) / xGap) * xGap
139
+ const yStart = yAnchor - Math.ceil((yAnchor - plotArea.y) / yGap) * yGap
140
+ for (let y = yStart; y <= plotArea.y + plotArea.height + 0.5; y += yGap) {
141
+ for (let x = xStart; x <= plotArea.x + plotArea.width + 0.5; x += xGap) {
142
+ parts.push(
143
+ `<circle cx="${r(x)}" cy="${r(y)}" r="1.5" class="xychart-grid"/>`,
144
+ )
145
+ }
146
+ }
147
+
148
+ // 2. Bars — always render bar paths inline (before lines for correct z-order)
149
+ // Interactive: also build overlay groups with transparent hit-areas + tooltips (deferred to step 9)
150
+ const barOverlay: string[] = []
151
+ for (const bar of chart.bars) {
152
+ const dataAttrs = ` data-value="${bar.value}"${bar.label ? ` data-label="${escapeXml(bar.label)}"` : ''}`
153
+ const barPath = chart.horizontal
154
+ ? roundedRightBarPath(
155
+ bar.x,
156
+ bar.y,
157
+ bar.width,
158
+ bar.height,
159
+ CHART_FONT.barRadius,
160
+ )
161
+ : roundedTopBarPath(
162
+ bar.x,
163
+ bar.y,
164
+ bar.width,
165
+ bar.height,
166
+ CHART_FONT.barRadius,
167
+ )
168
+ parts.push(
169
+ `<path d="${barPath}" class="xychart-bar xychart-color-${bar.colorIndex}"${dataAttrs}/>`,
170
+ )
171
+ if (interactive) {
172
+ const tipText = formatTipValue(bar.value)
173
+ const tipTitle = bar.label ? `${bar.label}: ${tipText}` : tipText
174
+ const tip = tooltipAbove(bar.x + bar.width / 2, bar.y, tipText)
175
+ barOverlay.push(
176
+ `<g class="xychart-bar-group">` +
177
+ `<rect x="${r(bar.x)}" y="${r(bar.y)}" width="${r(bar.width)}" height="${r(bar.height)}" fill="transparent"/>` +
178
+ `<title>${escapeXml(tipTitle)}</title>` +
179
+ tip +
180
+ `</g>`,
181
+ )
182
+ }
183
+ }
184
+
185
+ // 3. Lines — shadow first (wider, low opacity), then crisp line on top
186
+ for (const line of chart.lines) {
187
+ if (line.points.length === 0) continue
188
+ const d = smoothCurvePath(line.points)
189
+ parts.push(
190
+ `<path d="${d}" class="xychart-line-shadow xychart-color-${line.colorIndex}" transform="translate(0,2)"/>`,
191
+ )
192
+ parts.push(
193
+ `<path d="${d}" class="xychart-line xychart-color-${line.colorIndex}"/>`,
194
+ )
195
+ }
196
+
197
+ // 4. Dots — grouped by x-position; interactive groups deferred to overlay
198
+ const dotOverlay: string[] = []
199
+ if (interactive || sparse) {
200
+ // Build legend label lookup: line seriesIndex → "Line 1", "Line 2", etc.
201
+ const lineLegendLabels = new Map<number, string>()
202
+ for (const item of chart.legend) {
203
+ if (item.type === 'line')
204
+ lineLegendLabels.set(item.seriesIndex, item.label)
205
+ }
206
+
207
+ type DotEntry = {
208
+ x: number
209
+ y: number
210
+ value: number
211
+ label?: string
212
+ seriesIndex: number
213
+ colorIndex: number
214
+ }
215
+ const columns = new Map<string, DotEntry[]>()
216
+
217
+ for (const line of chart.lines) {
218
+ for (const p of line.points) {
219
+ const key = r(p.x)
220
+ const column = columns.get(key) ?? []
221
+ columns.set(key, column)
222
+ column.push({
223
+ x: p.x,
224
+ y: p.y,
225
+ value: p.value,
226
+ label: p.label,
227
+ seriesIndex: line.seriesIndex,
228
+ colorIndex: line.colorIndex,
229
+ })
230
+ }
231
+ }
232
+
233
+ // Every array stored in `columns` was created and immediately pushed to
234
+ // above, so `entries` is never empty here — safe to index entries[0]
235
+ // below without an extra length check.
236
+ for (const entries of columns.values()) {
237
+ const cx = entries[0]!.x
238
+ const label = entries[0]!.label || ''
239
+
240
+ if (interactive && entries.length > 1) {
241
+ const topY = Math.min(...entries.map((e) => e.y))
242
+ const botY = Math.max(...entries.map((e) => e.y))
243
+ const hitPad = CHART_FONT.dotRadius * 3
244
+ const hitArea = `<rect x="${r(cx - hitPad)}" y="${r(topY - hitPad)}" width="${r(hitPad * 2)}" height="${r(botY - topY + hitPad * 2)}" fill="transparent" class="xychart-hit"/>`
245
+ const tipEntries = entries.map((e) => ({
246
+ text: formatTipValue(e.value),
247
+ legendLabel:
248
+ lineLegendLabels.get(e.seriesIndex) || `Line ${e.seriesIndex + 1}`,
249
+ }))
250
+ const tip = multiTooltipAbove(
251
+ cx,
252
+ topY - CHART_FONT.dotRadius,
253
+ label,
254
+ tipEntries,
255
+ )
256
+ const valStrs = tipEntries.map((e) => e.text)
257
+ const titleText = label
258
+ ? `${label}: ${valStrs.join(' · ')}`
259
+ : valStrs.join(' · ')
260
+
261
+ let group = `<g class="xychart-dot-group">${hitArea}`
262
+ for (const e of entries) {
263
+ const dataAttrs = ` data-value="${e.value}"${e.label ? ` data-label="${escapeXml(e.label)}"` : ''}`
264
+ group += `<circle cx="${r(e.x)}" cy="${r(e.y)}" r="${CHART_FONT.dotRadius}" class="xychart-dot xychart-color-${e.colorIndex}"${dataAttrs}/>`
265
+ }
266
+ group += `<title>${escapeXml(titleText)}</title>${tip}</g>`
267
+ dotOverlay.push(group)
268
+ } else if (interactive) {
269
+ const e = entries[0]!
270
+ const dataAttrs = ` data-value="${e.value}"${e.label ? ` data-label="${escapeXml(e.label)}"` : ''}`
271
+ const tipText = formatTipValue(e.value)
272
+ const tipTitle = e.label ? `${e.label}: ${tipText}` : tipText
273
+ const tip = tooltipAbove(cx, e.y - CHART_FONT.dotRadius, tipText)
274
+ const hitArea = sparse
275
+ ? `<circle cx="${r(cx)}" cy="${r(e.y)}" r="${CHART_FONT.dotRadius * 3}" fill="transparent" class="xychart-hit"/>`
276
+ : ''
277
+ dotOverlay.push(
278
+ `<g class="xychart-dot-group">${hitArea}` +
279
+ `<circle cx="${r(e.x)}" cy="${r(e.y)}" r="${CHART_FONT.dotRadius}" class="xychart-dot xychart-color-${e.colorIndex}"${dataAttrs}/>` +
280
+ `<title>${escapeXml(tipTitle)}</title>${tip}</g>`,
281
+ )
282
+ } else {
283
+ // Sparse, not interactive: static dots render inline
284
+ for (const e of entries) {
285
+ const dataAttrs = ` data-value="${e.value}"${e.label ? ` data-label="${escapeXml(e.label)}"` : ''}`
286
+ parts.push(
287
+ `<circle cx="${r(e.x)}" cy="${r(e.y)}" r="${CHART_FONT.dotRadius}" class="xychart-dot xychart-color-${e.colorIndex}"${dataAttrs}/>`,
288
+ )
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ // 5. Axis labels (no axis lines, no tick marks — just floating labels)
295
+ for (const tick of chart.xAxis.ticks) {
296
+ parts.push(
297
+ `<text x="${tick.labelX}" y="${tick.labelY}" text-anchor="${tick.textAnchor}" ` +
298
+ `font-size="${CHART_FONT.labelSize}" font-weight="${CHART_FONT.labelWeight}" ` +
299
+ `dy="${TEXT_BASELINE_SHIFT}" class="xychart-label">${escapeXml(tick.label)}</text>`,
300
+ )
301
+ }
302
+ for (const tick of chart.yAxis.ticks) {
303
+ parts.push(
304
+ `<text x="${tick.labelX}" y="${tick.labelY}" text-anchor="${tick.textAnchor}" ` +
305
+ `font-size="${CHART_FONT.labelSize}" font-weight="${CHART_FONT.labelWeight}" ` +
306
+ `dy="${TEXT_BASELINE_SHIFT}" class="xychart-label">${escapeXml(tick.label)}</text>`,
307
+ )
308
+ }
309
+
310
+ // 6. Axis titles
311
+ if (chart.xAxis.title) {
312
+ const t = chart.xAxis.title
313
+ const transform = t.rotate
314
+ ? ` transform="rotate(${t.rotate},${t.x},${t.y})"`
315
+ : ''
316
+ parts.push(
317
+ `<text x="${t.x}" y="${t.y}" text-anchor="middle"${transform} ` +
318
+ `font-size="${CHART_FONT.axisTitleSize}" font-weight="${CHART_FONT.axisTitleWeight}" ` +
319
+ `dy="${TEXT_BASELINE_SHIFT}" class="xychart-axis-title">${escapeXml(t.text)}</text>`,
320
+ )
321
+ }
322
+ if (chart.yAxis.title) {
323
+ const t = chart.yAxis.title
324
+ const transform = t.rotate
325
+ ? ` transform="rotate(${t.rotate},${t.x},${t.y})"`
326
+ : ''
327
+ parts.push(
328
+ `<text x="${t.x}" y="${t.y}" text-anchor="middle"${transform} ` +
329
+ `font-size="${CHART_FONT.axisTitleSize}" font-weight="${CHART_FONT.axisTitleWeight}" ` +
330
+ `dy="${TEXT_BASELINE_SHIFT}" class="xychart-axis-title">${escapeXml(t.text)}</text>`,
331
+ )
332
+ }
333
+
334
+ // 7. Chart title
335
+ if (chart.title) {
336
+ parts.push(
337
+ `<text x="${chart.title.x}" y="${chart.title.y}" text-anchor="middle" ` +
338
+ `font-size="${CHART_FONT.titleSize}" font-weight="${CHART_FONT.titleWeight}" ` +
339
+ `dy="${TEXT_BASELINE_SHIFT}" class="xychart-title">${escapeXml(chart.title.text)}</text>`,
340
+ )
341
+ }
342
+
343
+ // 8. Legend
344
+ for (const item of chart.legend) {
345
+ const swatchW = 14,
346
+ swatchH = 14
347
+ const gap = 6
348
+ if (item.type === 'bar') {
349
+ parts.push(
350
+ `<rect x="${item.x}" y="${item.y - swatchH / 2}" width="${swatchW}" height="${swatchH}" rx="3" ` +
351
+ `class="xychart-bar xychart-color-${item.colorIndex}"/>`,
352
+ )
353
+ } else {
354
+ const ly = item.y
355
+ parts.push(
356
+ `<line x1="${item.x}" y1="${ly}" x2="${item.x + swatchW}" y2="${ly}" ` +
357
+ `stroke-width="${CHART_FONT.lineWidth}" stroke-linecap="round" class="xychart-legend-line xychart-color-${item.colorIndex}"/>`,
358
+ )
359
+ }
360
+ parts.push(
361
+ `<text x="${item.x + swatchW + gap}" y="${item.y}" text-anchor="start" ` +
362
+ `font-size="${CHART_FONT.legendSize}" font-weight="${CHART_FONT.legendWeight}" ` +
363
+ `dy="${TEXT_BASELINE_SHIFT}" class="xychart-label">${escapeXml(item.label)}</text>`,
364
+ )
365
+ }
366
+
367
+ // 9. Interactive overlay — rendered last so tooltips are always on top
368
+ for (const g of barOverlay) parts.push(g)
369
+ for (const g of dotOverlay) parts.push(g)
370
+
371
+ parts.push('</svg>')
372
+ return parts.join('\n')
373
+ }
374
+
375
+ // ============================================================================
376
+ // Chart-specific CSS styles
377
+ // ============================================================================
378
+
379
+ function chartStyles(
380
+ chart: PositionedXYChart,
381
+ interactive: boolean,
382
+ sparse: boolean,
383
+ themeAccent?: string,
384
+ bgColor?: string,
385
+ nonce?: string,
386
+ ): { style: string; defs: string } {
387
+ const accentHex = themeAccent ?? CHART_ACCENT_FALLBACK
388
+
389
+ // Collect all unique global color indices from bars + lines
390
+ const colorIndices = new Set<number>()
391
+ for (const b of chart.bars) colorIndices.add(b.colorIndex)
392
+ for (const l of chart.lines) colorIndices.add(l.colorIndex)
393
+
394
+ // Define --xychart-color-N CSS custom properties (updatable by theme-switching JS)
395
+ // Also define --xychart-bar-fill-N via color-mix() so it stays dynamic on theme change
396
+ const colorVarDefs: string[] = []
397
+ for (const idx of [...colorIndices].sort((a, b) => a - b)) {
398
+ const value =
399
+ idx === 0
400
+ ? `var(--accent, ${CHART_ACCENT_FALLBACK})`
401
+ : getSeriesColor(idx, accentHex, bgColor)
402
+ colorVarDefs.push(` --xychart-color-${idx}: ${value};`)
403
+ colorVarDefs.push(
404
+ ` --xychart-bar-fill-${idx}: color-mix(in srgb, var(--bg) 75%, var(--xychart-color-${idx}) 25%);`,
405
+ )
406
+ }
407
+
408
+ // Generate unified color rules — one per global index, referencing the CSS vars
409
+ const seriesRules: string[] = []
410
+ for (const idx of [...colorIndices].sort((a, b) => a - b)) {
411
+ const color = `var(--xychart-color-${idx})`
412
+ // Bar-specific: stroke + solid blended fill (no opacity)
413
+ seriesRules.push(
414
+ ` .xychart-bar.xychart-color-${idx} { stroke: ${color}; fill: var(--xychart-bar-fill-${idx}); }`,
415
+ )
416
+ // Line/dot-specific: stroke for paths, fill for circles
417
+ seriesRules.push(
418
+ ` path.xychart-color-${idx}, line.xychart-color-${idx} { stroke: ${color}; }`,
419
+ )
420
+ seriesRules.push(` circle.xychart-color-${idx} { fill: ${color}; }`)
421
+ }
422
+
423
+ const tipRules = interactive
424
+ ? `
425
+ .xychart-tip { opacity: 0; pointer-events: none; }
426
+ .xychart-tip-bg { fill: var(--_text); filter: drop-shadow(0 1px 3px color-mix(in srgb, var(--fg) 20%, transparent)); }
427
+ .xychart-tip-text { fill: var(--bg); font-size: ${TIP.fontSize}px; font-weight: ${TIP.fontWeight}; }
428
+ .xychart-tip-ptr { fill: var(--_text); }
429
+ .xychart-bar-group:hover .xychart-tip,
430
+ .xychart-dot-group:hover .xychart-tip { opacity: 1; }`
431
+ : ''
432
+
433
+ const colorVarsBlock =
434
+ colorVarDefs.length > 0 ? `\n svg {\n${colorVarDefs.join('\n')}\n }` : ''
435
+
436
+ const style = `${styleOpenTag(nonce)}
437
+ .xychart-grid { fill: var(--_inner-stroke); stroke: none; opacity: 0.65; }
438
+ .xychart-bar { stroke-width: 1.5; }
439
+ .xychart-line { fill: none; stroke-width: ${CHART_FONT.lineWidth}; stroke-linecap: round; stroke-linejoin: round; }
440
+ .xychart-line-shadow { fill: none; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; opacity: 0.12; }
441
+ .xychart-dot { stroke: var(--bg); stroke-width: 2; }
442
+ .xychart-label { fill: var(--_text-muted); }
443
+ .xychart-axis-title { fill: var(--_text-sec); }
444
+ .xychart-title { fill: var(--_text); }${colorVarsBlock}
445
+ ${seriesRules.join('\n')}${tipRules}
446
+ </style>`
447
+
448
+ return { style, defs: '' }
449
+ }
450
+
451
+ // ============================================================================
452
+ // Bar path with all corners rounded
453
+ // ============================================================================
454
+
455
+ function roundedTopBarPath(
456
+ x: number,
457
+ y: number,
458
+ w: number,
459
+ h: number,
460
+ radius: number,
461
+ ): string {
462
+ const rr = Math.min(radius, w / 2, h / 2)
463
+ if (rr <= 0) {
464
+ return `M${r(x)},${r(y)} h${r(w)} v${r(h)} h${r(-w)} Z`
465
+ }
466
+ return [
467
+ `M${r(x)},${r(y + rr)}`, // start below top-left
468
+ `Q${r(x)},${r(y)} ${r(x + rr)},${r(y)}`, // top-left
469
+ `L${r(x + w - rr)},${r(y)}`, // top edge
470
+ `Q${r(x + w)},${r(y)} ${r(x + w)},${r(y + rr)}`, // top-right
471
+ `L${r(x + w)},${r(y + h - rr)}`, // right edge
472
+ `Q${r(x + w)},${r(y + h)} ${r(x + w - rr)},${r(y + h)}`, // bottom-right
473
+ `L${r(x + rr)},${r(y + h)}`, // bottom edge
474
+ `Q${r(x)},${r(y + h)} ${r(x)},${r(y + h - rr)}`, // bottom-left
475
+ 'Z',
476
+ ].join(' ')
477
+ }
478
+
479
+ // ============================================================================
480
+ // Bar path with all corners rounded (for horizontal charts)
481
+ // ============================================================================
482
+
483
+ function roundedRightBarPath(
484
+ x: number,
485
+ y: number,
486
+ w: number,
487
+ h: number,
488
+ radius: number,
489
+ ): string {
490
+ const rr = Math.min(radius, w / 2, h / 2)
491
+ if (rr <= 0) {
492
+ return `M${r(x)},${r(y)} h${r(w)} v${r(h)} h${r(-w)} Z`
493
+ }
494
+ return [
495
+ `M${r(x + rr)},${r(y)}`, // start after top-left
496
+ `L${r(x + w - rr)},${r(y)}`, // top edge
497
+ `Q${r(x + w)},${r(y)} ${r(x + w)},${r(y + rr)}`, // top-right
498
+ `L${r(x + w)},${r(y + h - rr)}`, // right edge
499
+ `Q${r(x + w)},${r(y + h)} ${r(x + w - rr)},${r(y + h)}`, // bottom-right
500
+ `L${r(x + rr)},${r(y + h)}`, // bottom edge
501
+ `Q${r(x)},${r(y + h)} ${r(x)},${r(y + h - rr)}`, // bottom-left
502
+ `L${r(x)},${r(y + rr)}`, // left edge
503
+ `Q${r(x)},${r(y)} ${r(x + rr)},${r(y)}`, // top-left
504
+ 'Z',
505
+ ].join(' ')
506
+ }
507
+
508
+ // ============================================================================
509
+ // Smooth line interpolation — Natural cubic spline
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.
514
+ //
515
+ // Algorithm: tridiagonal system for second derivatives (Thomas algorithm),
516
+ // then convert each cubic segment to SVG cubic Bezier commands.
517
+ // ============================================================================
518
+
519
+ function smoothCurvePath(points: Array<{ x: number; y: number }>): string {
520
+ if (points.length === 0) return ''
521
+ if (points.length === 1) return `M${r(points[0]!.x)},${r(points[0]!.y)}`
522
+ if (points.length === 2) {
523
+ return `M${r(points[0]!.x)},${r(points[0]!.y)} L${r(points[1]!.x)},${r(points[1]!.y)}`
524
+ }
525
+
526
+ const n = points.length
527
+
528
+ // 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).
535
+
536
+ // 1. Interval widths and secant slopes
537
+ const h: number[] = []
538
+ const delta: number[] = []
539
+ for (let i = 0; i < n - 1; i++) {
540
+ h.push(points[i + 1]!.x - points[i]!.x)
541
+ delta.push(h[i]! === 0 ? 0 : (points[i + 1]!.y - points[i]!.y) / h[i]!)
542
+ }
543
+
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
569
+ 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
572
+ }
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
575
+
576
+ // 4. Convert to cubic Bezier — control points strictly between endpoints in x
577
+ let path = `M${r(points[0]!.x)},${r(points[0]!.y)}`
578
+ for (let i = 0; i < n - 1; i++) {
579
+ const seg = h[i]! / 3
580
+ const cp1x = points[i]!.x + seg
581
+ const cp1y = points[i]!.y + slopes[i]! * seg
582
+ const cp2x = points[i + 1]!.x - seg
583
+ const cp2y = points[i + 1]!.y - slopes[i + 1]! * seg
584
+ path += ` C${r(cp1x)},${r(cp1y)} ${r(cp2x)},${r(cp2y)} ${r(points[i + 1]!.x)},${r(points[i + 1]!.y)}`
585
+ }
586
+
587
+ return path
588
+ }
589
+
590
+ // ============================================================================
591
+ // Tooltip rendering
592
+ // ============================================================================
593
+
594
+ /**
595
+ * Multi-value tooltip: category label on top, each series value below with legend text label.
596
+ */
597
+ function multiTooltipAbove(
598
+ cx: number,
599
+ topY: number,
600
+ label: string,
601
+ entries: Array<{ text: string; legendLabel: string }>,
602
+ ): string {
603
+ const lineH = 20
604
+ const padY = 6
605
+ const labelGap = 10
606
+ const headingW = estimateTextWidth(label, TIP.fontSize, 600)
607
+ const maxRowW = Math.max(
608
+ ...entries.map((e) => {
609
+ const legendW = estimateTextWidth(
610
+ e.legendLabel,
611
+ TIP.fontSize,
612
+ TIP.fontWeight,
613
+ )
614
+ const valW = estimateTextWidth(e.text, TIP.fontSize, TIP.fontWeight)
615
+ return legendW + labelGap + valW
616
+ }),
617
+ )
618
+ const bgW = Math.max(headingW, maxRowW) + TIP.padX * 2
619
+ const bgH = padY + lineH + entries.length * lineH + padY
620
+
621
+ const tipY = Math.max(TIP.minY, topY - TIP.offsetY - bgH - TIP.pointerSize)
622
+ const bgX = cx - bgW / 2
623
+
624
+ const ptrX = cx
625
+ const ptrY = tipY + bgH
626
+ const ps = TIP.pointerSize
627
+ const pointer = `<polygon points="${r(ptrX - ps)},${r(ptrY)} ${r(ptrX + ps)},${r(ptrY)} ${r(ptrX)},${r(ptrY + ps)}" class="xychart-tip xychart-tip-ptr"/>`
628
+
629
+ let svg = `<rect x="${r(bgX)}" y="${r(tipY)}" width="${r(bgW)}" height="${bgH}" rx="${TIP.rx}" class="xychart-tip xychart-tip-bg"/>`
630
+ svg += pointer
631
+
632
+ // Category label (bold, centered)
633
+ let textY = tipY + padY + lineH / 2
634
+ svg += `<text x="${r(cx)}" y="${r(textY)}" text-anchor="middle" font-weight="600" font-size="${TIP.fontSize}" dy="${TEXT_BASELINE_SHIFT}" class="xychart-tip xychart-tip-text">${escapeXml(label)}</text>`
635
+
636
+ // Value lines: legend label left-aligned, value right-aligned
637
+ const rowLeft = bgX + TIP.padX
638
+ const rowRight = bgX + bgW - TIP.padX
639
+ for (const entry of entries) {
640
+ textY += lineH
641
+ svg += `<text x="${r(rowLeft)}" y="${r(textY)}" text-anchor="start" font-size="${TIP.fontSize}" font-weight="${TIP.fontWeight}" dy="${TEXT_BASELINE_SHIFT}" class="xychart-tip xychart-tip-text">${escapeXml(entry.legendLabel)}</text>`
642
+ svg += `<text x="${r(rowRight)}" y="${r(textY)}" text-anchor="end" font-size="${TIP.fontSize}" font-weight="${TIP.fontWeight}" dy="${TEXT_BASELINE_SHIFT}" class="xychart-tip xychart-tip-text">${escapeXml(entry.text)}</text>`
643
+ }
644
+
645
+ return svg
646
+ }
647
+
648
+ function tooltipAbove(cx: number, topY: number, text: string): string {
649
+ const textW = estimateTextWidth(text, TIP.fontSize, TIP.fontWeight)
650
+ const bgW = textW + TIP.padX * 2
651
+ const bgH = TIP.height
652
+ const tipY = Math.max(TIP.minY, topY - TIP.offsetY - bgH - TIP.pointerSize)
653
+ const bgX = cx - bgW / 2
654
+ const textX = cx
655
+ const textY = tipY + bgH / 2
656
+
657
+ const ptrX = cx
658
+ const ptrY = tipY + bgH
659
+ const ps = TIP.pointerSize
660
+ const pointer = `<polygon points="${r(ptrX - ps)},${r(ptrY)} ${r(ptrX + ps)},${r(ptrY)} ${r(ptrX)},${r(ptrY + ps)}" class="xychart-tip xychart-tip-ptr"/>`
661
+
662
+ return (
663
+ `<rect x="${r(bgX)}" y="${r(tipY)}" width="${r(bgW)}" height="${bgH}" rx="${TIP.rx}" class="xychart-tip xychart-tip-bg"/>` +
664
+ pointer +
665
+ `<text x="${r(textX)}" y="${r(textY)}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" class="xychart-tip xychart-tip-text">${escapeXml(text)}</text>`
666
+ )
667
+ }
668
+
669
+ function formatTipValue(v: number): string {
670
+ if (Number.isInteger(v)) return v.toLocaleString('en-US')
671
+ return v.toFixed(Math.abs(v) < 10 ? 1 : 0)
672
+ }
673
+
674
+ function r(n: number): string {
675
+ return String(Math.round(n * 10) / 10)
676
+ }
677
+
678
+ function escapeXml(text: string): string {
679
+ return text
680
+ .replace(/&/g, '&amp;')
681
+ .replace(/</g, '&lt;')
682
+ .replace(/>/g, '&gt;')
683
+ .replace(/"/g, '&quot;')
684
+ }