@zombie-mermaid/svg-renderer 4.2.2 → 5.0.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.
Files changed (72) hide show
  1. package/README.md +15 -1
  2. package/dist/index.cjs +52 -52
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +23 -4
  5. package/dist/index.d.ts +23 -4
  6. package/dist/index.js +897 -849
  7. package/dist/index.js.map +1 -1
  8. package/package.json +14 -5
  9. package/src/__tests__/c4-label-clearance-1290.test.ts +0 -112
  10. package/src/__tests__/c4-line-ends-1208.test.ts +0 -52
  11. package/src/__tests__/c4-mermaid-reference.test.ts +0 -124
  12. package/src/__tests__/c4-svg.test.ts +0 -337
  13. package/src/__tests__/c4-text-width-1210.test.ts +0 -43
  14. package/src/__tests__/class-component-order-1249.test.ts +0 -40
  15. package/src/__tests__/class-diamond-markers-1232.test.ts +0 -59
  16. package/src/__tests__/class-member-right-padding-1238.test.ts +0 -43
  17. package/src/__tests__/class-namespace-1196.test.ts +0 -165
  18. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +0 -96
  19. package/src/__tests__/class-visibility-markers-1237.test.ts +0 -27
  20. package/src/__tests__/edge-jog-straightening-1388.test.ts +0 -57
  21. package/src/__tests__/elk-adapter-utils.test.ts +0 -166
  22. package/src/__tests__/er-attribute-right-padding-1262.test.ts +0 -47
  23. package/src/__tests__/er-cardinality-markers-1233.test.ts +0 -35
  24. package/src/__tests__/er-default-direction-1250.test.ts +0 -69
  25. package/src/__tests__/er-edge-label-contrast-1244.test.ts +0 -58
  26. package/src/__tests__/fixtures/c4-mermaid-reference.json +0 -626
  27. package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +0 -168
  28. package/src/__tests__/pie-layout.test.ts +0 -215
  29. package/src/__tests__/pie-svg.test.ts +0 -194
  30. package/src/__tests__/render-mermaid-svg.test.ts +0 -64
  31. package/src/__tests__/sequence-label-clearance-1242.test.ts +0 -162
  32. package/src/__tests__/sequence-nested-activation-1241.test.ts +0 -37
  33. package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +0 -32
  34. package/src/__tests__/xychart-smooth-curve.test.ts +0 -42
  35. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +0 -62
  36. package/src/c4/arial-widths.ts +0 -59
  37. package/src/c4/layout.ts +0 -680
  38. package/src/c4/metrics.ts +0 -261
  39. package/src/c4/renderer.ts +0 -366
  40. package/src/class/layout.ts +0 -525
  41. package/src/class/renderer.ts +0 -669
  42. package/src/edge-curves.ts +0 -205
  43. package/src/elk-instance.ts +0 -292
  44. package/src/er/layout.ts +0 -216
  45. package/src/er/renderer.ts +0 -516
  46. package/src/generated/mono-font-subset.ts +0 -32
  47. package/src/index.ts +0 -277
  48. package/src/layout-engine/back-edges.ts +0 -73
  49. package/src/layout-engine/compound-flat.ts +0 -916
  50. package/src/layout-engine/constants.ts +0 -19
  51. package/src/layout-engine/edge-bundling.ts +0 -458
  52. package/src/layout-engine/elk-adapter-utils.ts +0 -81
  53. package/src/layout-engine/elk-graph-builder.ts +0 -245
  54. package/src/layout-engine/from-elk.ts +0 -767
  55. package/src/layout-engine/inner-edges.ts +0 -329
  56. package/src/layout-engine/jog-straightening.ts +0 -52
  57. package/src/layout-engine/layer-alignment.ts +0 -174
  58. package/src/layout-engine/layout-hints.ts +0 -27
  59. package/src/layout-engine/to-elk.ts +0 -855
  60. package/src/layout-engine.ts +0 -115
  61. package/src/pie/layout.ts +0 -234
  62. package/src/pie/renderer.ts +0 -161
  63. package/src/registry.ts +0 -441
  64. package/src/renderer.ts +0 -1510
  65. package/src/resolve-colors.ts +0 -339
  66. package/src/sequence/layout.ts +0 -766
  67. package/src/sequence/renderer.ts +0 -687
  68. package/src/shape-clipping.ts +0 -197
  69. package/src/styles.ts +0 -145
  70. package/src/title-gaps.ts +0 -161
  71. package/src/xychart/layout.ts +0 -727
  72. package/src/xychart/renderer.ts +0 -676
@@ -1,676 +0,0 @@
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
- if (tick.label === '') continue // dropped by the layout's overlap step
297
- parts.push(
298
- `<text x="${tick.labelX}" y="${tick.labelY}" text-anchor="${tick.textAnchor}" ` +
299
- `font-size="${CHART_FONT.labelSize}" font-weight="${CHART_FONT.labelWeight}" ` +
300
- `dy="${TEXT_BASELINE_SHIFT}" class="xychart-label">${escapeXml(tick.label)}</text>`,
301
- )
302
- }
303
- for (const tick of chart.yAxis.ticks) {
304
- parts.push(
305
- `<text x="${tick.labelX}" y="${tick.labelY}" text-anchor="${tick.textAnchor}" ` +
306
- `font-size="${CHART_FONT.labelSize}" font-weight="${CHART_FONT.labelWeight}" ` +
307
- `dy="${TEXT_BASELINE_SHIFT}" class="xychart-label">${escapeXml(tick.label)}</text>`,
308
- )
309
- }
310
-
311
- // 6. Axis titles
312
- if (chart.xAxis.title) {
313
- const t = chart.xAxis.title
314
- const transform = t.rotate
315
- ? ` transform="rotate(${t.rotate},${t.x},${t.y})"`
316
- : ''
317
- parts.push(
318
- `<text x="${t.x}" y="${t.y}" text-anchor="middle"${transform} ` +
319
- `font-size="${CHART_FONT.axisTitleSize}" font-weight="${CHART_FONT.axisTitleWeight}" ` +
320
- `dy="${TEXT_BASELINE_SHIFT}" class="xychart-axis-title">${escapeXml(t.text)}</text>`,
321
- )
322
- }
323
- if (chart.yAxis.title) {
324
- const t = chart.yAxis.title
325
- const transform = t.rotate
326
- ? ` transform="rotate(${t.rotate},${t.x},${t.y})"`
327
- : ''
328
- parts.push(
329
- `<text x="${t.x}" y="${t.y}" text-anchor="middle"${transform} ` +
330
- `font-size="${CHART_FONT.axisTitleSize}" font-weight="${CHART_FONT.axisTitleWeight}" ` +
331
- `dy="${TEXT_BASELINE_SHIFT}" class="xychart-axis-title">${escapeXml(t.text)}</text>`,
332
- )
333
- }
334
-
335
- // 7. Chart title
336
- if (chart.title) {
337
- parts.push(
338
- `<text x="${chart.title.x}" y="${chart.title.y}" text-anchor="middle" ` +
339
- `font-size="${CHART_FONT.titleSize}" font-weight="${CHART_FONT.titleWeight}" ` +
340
- `dy="${TEXT_BASELINE_SHIFT}" class="xychart-title">${escapeXml(chart.title.text)}</text>`,
341
- )
342
- }
343
-
344
- // 8. Legend
345
- for (const item of chart.legend) {
346
- const swatchW = 14,
347
- swatchH = 14
348
- const gap = 6
349
- if (item.type === 'bar') {
350
- parts.push(
351
- `<rect x="${item.x}" y="${item.y - swatchH / 2}" width="${swatchW}" height="${swatchH}" rx="3" ` +
352
- `class="xychart-bar xychart-color-${item.colorIndex}"/>`,
353
- )
354
- } else {
355
- const ly = item.y
356
- parts.push(
357
- `<line x1="${item.x}" y1="${ly}" x2="${item.x + swatchW}" y2="${ly}" ` +
358
- `stroke-width="${CHART_FONT.lineWidth}" stroke-linecap="round" class="xychart-legend-line xychart-color-${item.colorIndex}"/>`,
359
- )
360
- }
361
- parts.push(
362
- `<text x="${item.x + swatchW + gap}" y="${item.y}" text-anchor="start" ` +
363
- `font-size="${CHART_FONT.legendSize}" font-weight="${CHART_FONT.legendWeight}" ` +
364
- `dy="${TEXT_BASELINE_SHIFT}" class="xychart-label">${escapeXml(item.label)}</text>`,
365
- )
366
- }
367
-
368
- // 9. Interactive overlay — rendered last so tooltips are always on top
369
- for (const g of barOverlay) parts.push(g)
370
- for (const g of dotOverlay) parts.push(g)
371
-
372
- parts.push('</svg>')
373
- return parts.join('\n')
374
- }
375
-
376
- // ============================================================================
377
- // Chart-specific CSS styles
378
- // ============================================================================
379
-
380
- function chartStyles(
381
- chart: PositionedXYChart,
382
- interactive: boolean,
383
- sparse: boolean,
384
- themeAccent?: string,
385
- bgColor?: string,
386
- nonce?: string,
387
- ): { style: string; defs: string } {
388
- const accentHex = themeAccent ?? CHART_ACCENT_FALLBACK
389
-
390
- // Collect all unique global color indices from bars + lines
391
- const colorIndices = new Set<number>()
392
- for (const b of chart.bars) colorIndices.add(b.colorIndex)
393
- for (const l of chart.lines) colorIndices.add(l.colorIndex)
394
-
395
- // Define --xychart-color-N CSS custom properties (updatable by theme-switching JS)
396
- // Also define --xychart-bar-fill-N via color-mix() so it stays dynamic on theme change
397
- const colorVarDefs: string[] = []
398
- for (const idx of [...colorIndices].sort((a, b) => a - b)) {
399
- const value =
400
- idx === 0
401
- ? `var(--accent, ${CHART_ACCENT_FALLBACK})`
402
- : getSeriesColor(idx, accentHex, bgColor)
403
- colorVarDefs.push(` --xychart-color-${idx}: ${value};`)
404
- colorVarDefs.push(
405
- ` --xychart-bar-fill-${idx}: color-mix(in srgb, var(--bg) 75%, var(--xychart-color-${idx}) 25%);`,
406
- )
407
- }
408
-
409
- // Generate unified color rules — one per global index, referencing the CSS vars
410
- const seriesRules: string[] = []
411
- for (const idx of [...colorIndices].sort((a, b) => a - b)) {
412
- const color = `var(--xychart-color-${idx})`
413
- // Bar-specific: stroke + solid blended fill (no opacity)
414
- seriesRules.push(
415
- ` .xychart-bar.xychart-color-${idx} { stroke: ${color}; fill: var(--xychart-bar-fill-${idx}); }`,
416
- )
417
- // Line/dot-specific: stroke for paths, fill for circles
418
- seriesRules.push(
419
- ` path.xychart-color-${idx}, line.xychart-color-${idx} { stroke: ${color}; }`,
420
- )
421
- seriesRules.push(` circle.xychart-color-${idx} { fill: ${color}; }`)
422
- }
423
-
424
- const tipRules = interactive
425
- ? `
426
- .xychart-tip { opacity: 0; pointer-events: none; }
427
- .xychart-tip-bg { fill: var(--_text); filter: drop-shadow(0 1px 3px color-mix(in srgb, var(--fg) 20%, transparent)); }
428
- .xychart-tip-text { fill: var(--bg); font-size: ${TIP.fontSize}px; font-weight: ${TIP.fontWeight}; }
429
- .xychart-tip-ptr { fill: var(--_text); }
430
- .xychart-bar-group:hover .xychart-tip,
431
- .xychart-dot-group:hover .xychart-tip { opacity: 1; }`
432
- : ''
433
-
434
- const colorVarsBlock =
435
- colorVarDefs.length > 0 ? `\n svg {\n${colorVarDefs.join('\n')}\n }` : ''
436
-
437
- const style = `${styleOpenTag(nonce)}
438
- .xychart-grid { fill: var(--_inner-stroke); stroke: none; opacity: 0.65; }
439
- .xychart-bar { stroke-width: 1.5; }
440
- .xychart-line { fill: none; stroke-width: ${CHART_FONT.lineWidth}; stroke-linecap: round; stroke-linejoin: round; }
441
- .xychart-line-shadow { fill: none; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; opacity: 0.12; }
442
- .xychart-dot { stroke: var(--bg); stroke-width: 2; }
443
- .xychart-label { fill: var(--_text-muted); }
444
- .xychart-axis-title { fill: var(--_text-sec); }
445
- .xychart-title { fill: var(--_text); }${colorVarsBlock}
446
- ${seriesRules.join('\n')}${tipRules}
447
- </style>`
448
-
449
- return { style, defs: '' }
450
- }
451
-
452
- // ============================================================================
453
- // Bar path with all corners rounded
454
- // ============================================================================
455
-
456
- function roundedTopBarPath(
457
- x: number,
458
- y: number,
459
- w: number,
460
- h: number,
461
- radius: number,
462
- ): string {
463
- const rr = Math.min(radius, w / 2, h / 2)
464
- if (rr <= 0) {
465
- return `M${r(x)},${r(y)} h${r(w)} v${r(h)} h${r(-w)} Z`
466
- }
467
- return [
468
- `M${r(x)},${r(y + rr)}`, // start below top-left
469
- `Q${r(x)},${r(y)} ${r(x + rr)},${r(y)}`, // top-left
470
- `L${r(x + w - rr)},${r(y)}`, // top edge
471
- `Q${r(x + w)},${r(y)} ${r(x + w)},${r(y + rr)}`, // top-right
472
- `L${r(x + w)},${r(y + h - rr)}`, // right edge
473
- `Q${r(x + w)},${r(y + h)} ${r(x + w - rr)},${r(y + h)}`, // bottom-right
474
- `L${r(x + rr)},${r(y + h)}`, // bottom edge
475
- `Q${r(x)},${r(y + h)} ${r(x)},${r(y + h - rr)}`, // bottom-left
476
- 'Z',
477
- ].join(' ')
478
- }
479
-
480
- // ============================================================================
481
- // Bar path with all corners rounded (for horizontal charts)
482
- // ============================================================================
483
-
484
- function roundedRightBarPath(
485
- x: number,
486
- y: number,
487
- w: number,
488
- h: number,
489
- radius: number,
490
- ): string {
491
- const rr = Math.min(radius, w / 2, h / 2)
492
- if (rr <= 0) {
493
- return `M${r(x)},${r(y)} h${r(w)} v${r(h)} h${r(-w)} Z`
494
- }
495
- return [
496
- `M${r(x + rr)},${r(y)}`, // start after top-left
497
- `L${r(x + w - rr)},${r(y)}`, // top edge
498
- `Q${r(x + w)},${r(y)} ${r(x + w)},${r(y + rr)}`, // top-right
499
- `L${r(x + w)},${r(y + h - rr)}`, // right edge
500
- `Q${r(x + w)},${r(y + h)} ${r(x + w - rr)},${r(y + h)}`, // bottom-right
501
- `L${r(x + rr)},${r(y + h)}`, // bottom edge
502
- `Q${r(x)},${r(y + h)} ${r(x)},${r(y + h - rr)}`, // bottom-left
503
- `L${r(x)},${r(y + rr)}`, // left edge
504
- `Q${r(x)},${r(y)} ${r(x + rr)},${r(y)}`, // top-left
505
- 'Z',
506
- ].join(' ')
507
- }
508
-
509
- // ============================================================================
510
- // Smooth line interpolation — monotone cubic (Fritsch-Carlson / PCHIP)
511
- //
512
- // A natural cubic spline is smooth but not shape-preserving: near a sharp
513
- // spike it overshoots the data, drawing a peak above the maximum or a dip
514
- // below the minimum (even below a zero baseline). Monotone cubic
515
- // interpolation keeps each segment monotone between its two points and puts a
516
- // flat tangent at every local extremum, so the curve never leaves the range of
517
- // the data. y is treated as a function of x, so the curve never goes backwards.
518
- //
519
- // Each cubic segment is converted to an SVG cubic Bezier command.
520
- // ============================================================================
521
-
522
- export function smoothCurvePath(
523
- points: Array<{ x: number; y: number }>,
524
- ): string {
525
- if (points.length === 0) return ''
526
- if (points.length === 1) return `M${r(points[0]!.x)},${r(points[0]!.y)}`
527
- if (points.length === 2) {
528
- return `M${r(points[0]!.x)},${r(points[0]!.y)} L${r(points[1]!.x)},${r(points[1]!.y)}`
529
- }
530
-
531
- const n = points.length
532
-
533
- // From here on, n >= 3 (the n <= 2 cases returned above). Every index used
534
- // below is bound by a `< n - 1` or `< n` loop condition, or is a fixed
535
- // offset from n itself, into arrays sized exactly `n` or `n - 1`.
536
- // Non-null assertions on those accesses are the same bounds-checked idiom as
537
- // loop-index array access elsewhere in this codebase.
538
-
539
- // 1. Interval widths and secant slopes
540
- const h: number[] = []
541
- const delta: number[] = []
542
- for (let i = 0; i < n - 1; i++) {
543
- h.push(points[i + 1]!.x - points[i]!.x)
544
- delta.push(h[i]! === 0 ? 0 : (points[i + 1]!.y - points[i]!.y) / h[i]!)
545
- }
546
-
547
- // 2. Tangent at each knot (Fritsch-Carlson). Interior: weighted harmonic
548
- // mean of the adjacent secants, or 0 at a local extremum / flat run.
549
- const slopes = new Array<number>(n).fill(0)
550
- for (let i = 1; i < n - 1; i++) {
551
- const d0 = delta[i - 1]!
552
- const d1 = delta[i]!
553
- if (d0 * d1 <= 0) continue
554
- const w1 = 2 * h[i]! + h[i - 1]!
555
- const w2 = h[i]! + 2 * h[i - 1]!
556
- slopes[i] = (w1 + w2) / (w1 / d0 + w2 / d1)
557
- }
558
- // Ends: one-sided three-point estimate, clamped so it can't overshoot.
559
- const endSlope = (h0: number, h1: number, d0: number, d1: number): number => {
560
- let m = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1)
561
- if (m * d0 <= 0) m = 0
562
- else if (d0 * d1 <= 0 && Math.abs(m) > 3 * Math.abs(d0)) m = 3 * d0
563
- return m
564
- }
565
- slopes[0] = endSlope(h[0]!, h[1]!, delta[0]!, delta[1]!)
566
- slopes[n - 1] = endSlope(h[n - 2]!, h[n - 3]!, delta[n - 2]!, delta[n - 3]!)
567
-
568
- // 4. Convert to cubic Bezier — control points strictly between endpoints in x
569
- let path = `M${r(points[0]!.x)},${r(points[0]!.y)}`
570
- for (let i = 0; i < n - 1; i++) {
571
- const seg = h[i]! / 3
572
- const cp1x = points[i]!.x + seg
573
- const cp1y = points[i]!.y + slopes[i]! * seg
574
- const cp2x = points[i + 1]!.x - seg
575
- const cp2y = points[i + 1]!.y - slopes[i + 1]! * seg
576
- path += ` C${r(cp1x)},${r(cp1y)} ${r(cp2x)},${r(cp2y)} ${r(points[i + 1]!.x)},${r(points[i + 1]!.y)}`
577
- }
578
-
579
- return path
580
- }
581
-
582
- // ============================================================================
583
- // Tooltip rendering
584
- // ============================================================================
585
-
586
- /**
587
- * Multi-value tooltip: category label on top, each series value below with legend text label.
588
- */
589
- function multiTooltipAbove(
590
- cx: number,
591
- topY: number,
592
- label: string,
593
- entries: Array<{ text: string; legendLabel: string }>,
594
- ): string {
595
- const lineH = 20
596
- const padY = 6
597
- const labelGap = 10
598
- const headingW = estimateTextWidth(label, TIP.fontSize, 600)
599
- const maxRowW = Math.max(
600
- ...entries.map((e) => {
601
- const legendW = estimateTextWidth(
602
- e.legendLabel,
603
- TIP.fontSize,
604
- TIP.fontWeight,
605
- )
606
- const valW = estimateTextWidth(e.text, TIP.fontSize, TIP.fontWeight)
607
- return legendW + labelGap + valW
608
- }),
609
- )
610
- const bgW = Math.max(headingW, maxRowW) + TIP.padX * 2
611
- const bgH = padY + lineH + entries.length * lineH + padY
612
-
613
- const tipY = Math.max(TIP.minY, topY - TIP.offsetY - bgH - TIP.pointerSize)
614
- const bgX = cx - bgW / 2
615
-
616
- const ptrX = cx
617
- const ptrY = tipY + bgH
618
- const ps = TIP.pointerSize
619
- 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"/>`
620
-
621
- let svg = `<rect x="${r(bgX)}" y="${r(tipY)}" width="${r(bgW)}" height="${bgH}" rx="${TIP.rx}" class="xychart-tip xychart-tip-bg"/>`
622
- svg += pointer
623
-
624
- // Category label (bold, centered)
625
- let textY = tipY + padY + lineH / 2
626
- 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>`
627
-
628
- // Value lines: legend label left-aligned, value right-aligned
629
- const rowLeft = bgX + TIP.padX
630
- const rowRight = bgX + bgW - TIP.padX
631
- for (const entry of entries) {
632
- textY += lineH
633
- 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>`
634
- 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>`
635
- }
636
-
637
- return svg
638
- }
639
-
640
- function tooltipAbove(cx: number, topY: number, text: string): string {
641
- const textW = estimateTextWidth(text, TIP.fontSize, TIP.fontWeight)
642
- const bgW = textW + TIP.padX * 2
643
- const bgH = TIP.height
644
- const tipY = Math.max(TIP.minY, topY - TIP.offsetY - bgH - TIP.pointerSize)
645
- const bgX = cx - bgW / 2
646
- const textX = cx
647
- const textY = tipY + bgH / 2
648
-
649
- const ptrX = cx
650
- const ptrY = tipY + bgH
651
- const ps = TIP.pointerSize
652
- 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"/>`
653
-
654
- return (
655
- `<rect x="${r(bgX)}" y="${r(tipY)}" width="${r(bgW)}" height="${bgH}" rx="${TIP.rx}" class="xychart-tip xychart-tip-bg"/>` +
656
- pointer +
657
- `<text x="${r(textX)}" y="${r(textY)}" text-anchor="middle" dy="${TEXT_BASELINE_SHIFT}" class="xychart-tip xychart-tip-text">${escapeXml(text)}</text>`
658
- )
659
- }
660
-
661
- function formatTipValue(v: number): string {
662
- if (Number.isInteger(v)) return v.toLocaleString('en-US')
663
- return v.toFixed(Math.abs(v) < 10 ? 1 : 0)
664
- }
665
-
666
- function r(n: number): string {
667
- return String(Math.round(n * 10) / 10)
668
- }
669
-
670
- function escapeXml(text: string): string {
671
- return text
672
- .replace(/&/g, '&amp;')
673
- .replace(/</g, '&lt;')
674
- .replace(/>/g, '&gt;')
675
- .replace(/"/g, '&quot;')
676
- }