@zombie-mermaid/svg-renderer 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +524 -0
- package/dist/index.d.ts +524 -0
- package/dist/index.js +2994 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/elk-adapter-utils.test.ts +166 -0
- package/src/class/layout.ts +360 -0
- package/src/class/renderer.ts +636 -0
- package/src/edge-curves.ts +204 -0
- package/src/elk-instance.ts +292 -0
- package/src/er/layout.ts +200 -0
- package/src/er/renderer.ts +493 -0
- package/src/index.ts +58 -0
- package/src/layout-engine/constants.ts +19 -0
- package/src/layout-engine/edge-bundling.ts +379 -0
- package/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/src/layout-engine/elk-graph-builder.ts +240 -0
- package/src/layout-engine/from-elk.ts +685 -0
- package/src/layout-engine/layer-alignment.ts +174 -0
- package/src/layout-engine/to-elk.ts +695 -0
- package/src/layout-engine.ts +74 -0
- package/src/layout.ts +8 -0
- package/src/renderer.ts +1485 -0
- package/src/resolve-colors.ts +339 -0
- package/src/sequence/layout.ts +698 -0
- package/src/sequence/renderer.ts +546 -0
- package/src/shape-clipping.ts +197 -0
- package/src/styles.ts +118 -0
- package/src/xychart/layout.ts +682 -0
- package/src/xychart/renderer.ts +684 -0
|
@@ -0,0 +1,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, '&')
|
|
681
|
+
.replace(/</g, '<')
|
|
682
|
+
.replace(/>/g, '>')
|
|
683
|
+
.replace(/"/g, '"')
|
|
684
|
+
}
|