@aurum-grid/charts 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/chart.js ADDED
@@ -0,0 +1,1158 @@
1
+ import { toNumber, niceTicks, compactNumber, histogramBins, stack, waterfall, treemap, linearRegression, holtForecast, nextLabels, extremes, } from './transform.js';
2
+ export const CHART_TYPES = [
3
+ { value: 'column', label: 'Column' }, { value: 'stackedColumn', label: 'Stacked column' }, { value: 'normalizedColumn', label: '100% stacked column' },
4
+ { value: 'bar', label: 'Bar' }, { value: 'stackedBar', label: 'Stacked bar' }, { value: 'normalizedBar', label: '100% stacked bar' },
5
+ { value: 'pie', label: 'Pie' }, { value: 'donut', label: 'Donut' }, { value: 'nightingale', label: 'Nightingale rose' },
6
+ { value: 'line', label: 'Line' }, { value: 'area', label: 'Area' }, { value: 'stackedArea', label: 'Stacked area' }, { value: 'normalizedArea', label: '100% stacked area' },
7
+ { value: 'scatter', label: 'Scatter' }, { value: 'bubble', label: 'Bubble' },
8
+ { value: 'radar', label: 'Radar line' }, { value: 'radarArea', label: 'Radar area' }, { value: 'radialBar', label: 'Radial bar' },
9
+ { value: 'histogram', label: 'Histogram' }, { value: 'boxPlot', label: 'Box plot' }, { value: 'rangeBar', label: 'Range bar' },
10
+ { value: 'treemap', label: 'Treemap' }, { value: 'sunburst', label: 'Sunburst' },
11
+ { value: 'heatmap', label: 'Heatmap' }, { value: 'waterfall', label: 'Waterfall' }, { value: 'gauge', label: 'Gauge' },
12
+ { value: 'funnel', label: 'Funnel' }, { value: 'pyramid', label: 'Pyramid' },
13
+ { value: 'combo', label: 'Combo (column + line)' },
14
+ ];
15
+ /** Chart types grouped the way the chart gallery shows them. */
16
+ export const CHART_GROUPS = [
17
+ { group: 'Column', types: ['column', 'stackedColumn', 'normalizedColumn'] },
18
+ { group: 'Bar', types: ['bar', 'stackedBar', 'normalizedBar'] },
19
+ { group: 'Pie', types: ['pie', 'donut', 'nightingale'] },
20
+ { group: 'Line & area', types: ['line', 'area', 'stackedArea', 'normalizedArea'] },
21
+ { group: 'X Y', types: ['scatter', 'bubble'] },
22
+ { group: 'Polar', types: ['radar', 'radarArea', 'radialBar'] },
23
+ { group: 'Statistical', types: ['histogram', 'boxPlot', 'rangeBar'] },
24
+ { group: 'Hierarchical', types: ['treemap', 'sunburst'] },
25
+ { group: 'Specialized', types: ['heatmap', 'waterfall', 'gauge'] },
26
+ { group: 'Funnel', types: ['funnel', 'pyramid'] },
27
+ { group: 'Combination', types: ['combo'] },
28
+ ];
29
+ export const PALETTES = {
30
+ theme: null,
31
+ ocean: ['#1f5fa8', '#3aa5c9', '#6cc3a0', '#a7d36f', '#2b3f8c', '#7fa6d9', '#4c8c7b', '#c2d9e8'],
32
+ vivid: ['#e4572e', '#29335c', '#f3a712', '#669bbc', '#a8c686', '#b05b9c', '#3d9970', '#ff7f50'],
33
+ mono: ['#3b3b3b', '#5a5a5a', '#787878', '#969696', '#b4b4b4', '#2a2a2a', '#d2d2d2', '#4a4a4a'],
34
+ };
35
+ const NS = 'http://www.w3.org/2000/svg';
36
+ function svg(tag, attrs = {}, parent) {
37
+ const el = document.createElementNS(NS, tag);
38
+ for (const [k, v] of Object.entries(attrs))
39
+ if (v !== undefined)
40
+ el.setAttribute(k, String(v));
41
+ parent?.append(el);
42
+ return el;
43
+ }
44
+ function div(cls, text) {
45
+ const d = document.createElement('div');
46
+ d.className = cls;
47
+ if (text != null)
48
+ d.textContent = text;
49
+ return d;
50
+ }
51
+ export const paletteVar = (i) => `var(--agc-${(i % 8) + 1})`;
52
+ const TIME_RE = /^(\d{4}(-\d{2}(-\d{2})?)?|Q[1-4]\s*\d{4}|(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s*\d{0,4})$/i;
53
+ export function looksLikeTime(labels) {
54
+ return labels.length >= 3 && labels.filter((l) => TIME_RE.test(l.trim())).length / labels.length > 0.8;
55
+ }
56
+ export class Chart {
57
+ container;
58
+ root;
59
+ plot;
60
+ svgEl;
61
+ legendEl;
62
+ tooltip;
63
+ titleEl;
64
+ tableEl;
65
+ emptyEl;
66
+ hidden = new Set();
67
+ ro = null;
68
+ lastSize = '';
69
+ destroyed = false;
70
+ options;
71
+ constructor(container, options) {
72
+ this.container = container;
73
+ this.options = { ...options };
74
+ this.root = div('agc-root');
75
+ this.root.setAttribute('role', 'figure');
76
+ this.titleEl = div('agc-title');
77
+ this.plot = div('agc-plot');
78
+ this.svgEl = svg('svg', { class: 'agc-svg', role: 'img' });
79
+ this.plot.append(this.svgEl);
80
+ this.legendEl = div('agc-legend');
81
+ this.tooltip = div('agc-tooltip');
82
+ this.tooltip.setAttribute('role', 'tooltip');
83
+ this.tooltip.hidden = true;
84
+ this.emptyEl = div('agc-empty');
85
+ this.tableEl = document.createElement('table');
86
+ this.tableEl.className = 'agc-sr-only';
87
+ this.root.append(this.titleEl, this.plot, this.emptyEl, this.legendEl, this.tooltip, this.tableEl);
88
+ container.append(this.root);
89
+ if (typeof ResizeObserver === 'function') {
90
+ this.ro = new ResizeObserver(() => {
91
+ const sig = `${this.plot.clientWidth}x${this.options.height === 'fill' ? this.plot.clientHeight : ''}`;
92
+ if (this.plot.clientWidth && sig !== this.lastSize)
93
+ this.render();
94
+ });
95
+ this.ro.observe(this.plot);
96
+ }
97
+ this.render();
98
+ }
99
+ update(patch) {
100
+ Object.assign(this.options, patch);
101
+ if (patch.series)
102
+ for (const k of [...this.hidden])
103
+ if (!patch.series.some((s) => s.key === k))
104
+ this.hidden.delete(k);
105
+ this.render();
106
+ }
107
+ setType(type) {
108
+ this.update({ type });
109
+ }
110
+ getSvg() {
111
+ return this.svgEl;
112
+ }
113
+ /** Standalone SVG with computed colours and fonts inlined, so it renders identically elsewhere. */
114
+ toSvgString() {
115
+ const clone = this.svgEl.cloneNode(true);
116
+ clone.setAttribute('xmlns', NS);
117
+ const src = [this.svgEl, ...this.svgEl.querySelectorAll('*')];
118
+ const dst = [clone, ...clone.querySelectorAll('*')];
119
+ const props = ['fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'opacity', 'fill-opacity', 'font-size', 'font-weight', 'font-family'];
120
+ src.forEach((el, i) => {
121
+ const cs = getComputedStyle(el);
122
+ const target = dst[i];
123
+ for (const p of props) {
124
+ const v = cs.getPropertyValue(p);
125
+ if ((v && v !== 'none') || p === 'fill' || p === 'stroke')
126
+ target.setAttribute(p, v || 'none');
127
+ }
128
+ target.removeAttribute('class');
129
+ target.removeAttribute('style');
130
+ target.removeAttribute('tabindex');
131
+ });
132
+ const vb = this.viewSize();
133
+ const bg = getComputedStyle(this.root).getPropertyValue('--agr-bg').trim() || '#ffffff';
134
+ const rect = svg('rect', { x: 0, y: 0, width: vb.w, height: vb.h, fill: bg });
135
+ clone.insertBefore(rect, clone.firstChild);
136
+ return new XMLSerializer().serializeToString(clone);
137
+ }
138
+ /** Rasterise the chart to PNG (2× by default). */
139
+ async toPngBlob(scale = 2) {
140
+ const svgText = this.toSvgString();
141
+ const vb = this.viewSize();
142
+ const w = Math.round(vb.w * scale);
143
+ const hgt = Math.round(vb.h * scale);
144
+ const url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
145
+ try {
146
+ const img = new Image();
147
+ img.width = w;
148
+ img.height = hgt;
149
+ await new Promise((res, rej) => {
150
+ img.onload = () => res();
151
+ img.onerror = () => rej(new Error('Could not render chart image'));
152
+ img.src = url;
153
+ });
154
+ const canvas = document.createElement('canvas');
155
+ canvas.width = w;
156
+ canvas.height = hgt;
157
+ const ctx = canvas.getContext('2d');
158
+ if (!ctx)
159
+ throw new Error('Canvas is not available');
160
+ ctx.drawImage(img, 0, 0, w, hgt);
161
+ return await new Promise((res, rej) => canvas.toBlob((b) => (b ? res(b) : rej(new Error('PNG encoding failed'))), 'image/png'));
162
+ }
163
+ finally {
164
+ URL.revokeObjectURL(url);
165
+ }
166
+ }
167
+ viewSize() {
168
+ const parts = (this.svgEl.getAttribute('viewBox') ?? '').split(/\s+/).map(Number);
169
+ return { w: parts[2] || 600, h: parts[3] || 300 };
170
+ }
171
+ destroy() {
172
+ if (this.destroyed)
173
+ return;
174
+ this.destroyed = true;
175
+ this.ro?.disconnect();
176
+ this.root.remove();
177
+ }
178
+ fmt = (v) => (this.options.valueFormat ? this.options.valueFormat(v) : compactNumber(v));
179
+ visibleSeries() {
180
+ return this.options.series.filter((s) => !this.hidden.has(s.key));
181
+ }
182
+ color(s, fallbackIndex = 0) {
183
+ if (!s)
184
+ return paletteVar(fallbackIndex);
185
+ if (s.color)
186
+ return s.color;
187
+ const i = this.options.series.indexOf(s);
188
+ const pal = this.options.palette;
189
+ return pal && pal.length ? pal[i % pal.length] : paletteVar(i);
190
+ }
191
+ catColor(i) {
192
+ const pal = this.options.palette;
193
+ return pal && pal.length ? pal[i % pal.length] : paletteVar(i);
194
+ }
195
+ render() {
196
+ if (this.destroyed)
197
+ return;
198
+ const o = this.options;
199
+ const fill = o.height === 'fill';
200
+ this.root.classList.toggle('agc-fill', fill);
201
+ this.titleEl.textContent = o.title ?? '';
202
+ this.titleEl.hidden = !o.title;
203
+ this.root.setAttribute('aria-label', o.ariaLabel ?? o.title ?? 'Chart');
204
+ const hasData = o.data.length > 0 && o.series.length > 0;
205
+ this.emptyEl.textContent = hasData ? '' : o.emptyText ?? 'No data to display';
206
+ this.emptyEl.hidden = hasData;
207
+ this.plot.hidden = !hasData;
208
+ this.renderLegend();
209
+ this.renderTable();
210
+ this.svgEl.replaceChildren();
211
+ this.tooltip.hidden = true;
212
+ if (!hasData)
213
+ return;
214
+ const width = o.width ?? (this.plot.clientWidth || this.container.clientWidth || 600);
215
+ const height = fill ? Math.max(120, this.plot.clientHeight || 240) : o.height ?? 300;
216
+ this.lastSize = `${this.plot.clientWidth}x${fill ? this.plot.clientHeight : ''}`;
217
+ if (!fill)
218
+ this.plot.style.height = `${height}px`;
219
+ else
220
+ this.plot.style.removeProperty('height');
221
+ this.svgEl.setAttribute('viewBox', `0 0 ${width} ${height}`);
222
+ this.svgEl.setAttribute('width', String(width));
223
+ this.svgEl.setAttribute('height', String(height));
224
+ this.svgEl.setAttribute('aria-label', this.summary());
225
+ const box = { w: width, h: height };
226
+ switch (o.type) {
227
+ case 'pie':
228
+ case 'donut':
229
+ return this.renderPie(box, o.type === 'donut');
230
+ case 'nightingale':
231
+ return this.renderNightingale(box);
232
+ case 'radialBar':
233
+ return this.renderRadialBar(box);
234
+ case 'sunburst':
235
+ return this.renderSunburst(box);
236
+ case 'pyramid':
237
+ return this.renderPyramid(box);
238
+ case 'boxPlot':
239
+ case 'rangeBar':
240
+ return this.renderStat(box, o.type);
241
+ case 'funnel':
242
+ return this.renderFunnel(box);
243
+ case 'treemap':
244
+ return this.renderTreemap(box);
245
+ case 'heatmap':
246
+ return this.renderHeatmap(box);
247
+ case 'gauge':
248
+ return this.renderGauge(box);
249
+ case 'radar':
250
+ case 'radarArea':
251
+ return this.renderRadar(box, o.type === 'radarArea');
252
+ case 'scatter':
253
+ case 'bubble':
254
+ return this.renderScatter(box, o.type === 'bubble');
255
+ default:
256
+ return this.renderCartesian(box);
257
+ }
258
+ }
259
+ summary() {
260
+ const o = this.options;
261
+ const label = CHART_TYPES.find((t) => t.value === o.type)?.label ?? o.type;
262
+ return `${o.title ?? 'Chart'}: ${label} chart with ${o.data.length} categories and ${o.series.length} series`;
263
+ }
264
+ renderLegend() {
265
+ const o = this.options;
266
+ const show = o.legend !== false && !['gauge', 'heatmap', 'histogram', 'waterfall', 'treemap', 'funnel', 'pyramid', 'scatter', 'bubble', 'boxPlot', 'rangeBar'].includes(o.type);
267
+ this.legendEl.replaceChildren();
268
+ this.legendEl.hidden = !show;
269
+ if (!show)
270
+ return;
271
+ const cats = [...new Set(o.data.map((d) => String(d[o.categoryKey])))];
272
+ const items = ['pie', 'donut', 'nightingale', 'radialBar', 'sunburst'].includes(o.type)
273
+ ? cats.slice(0, 24).map((c, i) => ({ key: c, name: c, color: this.catColor(i), toggle: false, dash: false }))
274
+ : o.series.map((s) => ({ key: s.key, name: s.name ?? s.key, color: this.color(s), toggle: o.series.length > 1, dash: false }));
275
+ if (o.forecast && ['line', 'area', 'column', 'combo'].includes(o.type))
276
+ items.push({ key: '__fc', name: 'Forecast', color: this.color(o.series[0]), toggle: false, dash: true });
277
+ if (o.trendline && ['line', 'area', 'column', 'combo', 'bar'].includes(o.type))
278
+ items.push({ key: '__tr', name: 'Trend', color: 'var(--agr-muted, #777)', toggle: false, dash: true });
279
+ for (const it of items) {
280
+ const b = document.createElement(it.toggle ? 'button' : 'span');
281
+ b.className = 'agc-legend-item';
282
+ if (it.toggle) {
283
+ b.type = 'button';
284
+ b.setAttribute('aria-pressed', String(!this.hidden.has(it.key)));
285
+ b.title = `Show or hide ${it.name}`;
286
+ b.addEventListener('click', () => {
287
+ if (this.hidden.has(it.key))
288
+ this.hidden.delete(it.key);
289
+ else if (this.visibleSeries().length > 1)
290
+ this.hidden.add(it.key);
291
+ this.render();
292
+ });
293
+ }
294
+ const sw = document.createElement('span');
295
+ sw.className = it.dash ? 'agc-swatch agc-swatch-dash' : 'agc-swatch';
296
+ sw.style.background = it.dash ? 'none' : it.color;
297
+ if (it.dash)
298
+ sw.style.borderColor = it.color;
299
+ const t = document.createElement('span');
300
+ t.textContent = it.name;
301
+ b.append(sw, t);
302
+ if (this.hidden.has(it.key))
303
+ b.classList.add('agc-legend-off');
304
+ this.legendEl.append(b);
305
+ }
306
+ }
307
+ renderTable() {
308
+ const o = this.options;
309
+ this.tableEl.replaceChildren();
310
+ const cap = document.createElement('caption');
311
+ cap.textContent = o.title ?? 'Chart data';
312
+ const thead = document.createElement('thead');
313
+ const head = document.createElement('tr');
314
+ for (const name of [o.categoryKey, ...o.series.map((s) => s.name ?? s.key)]) {
315
+ const th = document.createElement('th');
316
+ th.scope = 'col';
317
+ th.textContent = name;
318
+ head.append(th);
319
+ }
320
+ thead.append(head);
321
+ const tb = document.createElement('tbody');
322
+ for (const d of o.data.slice(0, 500)) {
323
+ const tr = document.createElement('tr');
324
+ for (const v of [d[o.categoryKey], ...o.series.map((s) => d[s.key])]) {
325
+ const td = document.createElement('td');
326
+ td.textContent = typeof v === 'number' ? this.fmt(v) : String(v ?? '');
327
+ tr.append(td);
328
+ }
329
+ tb.append(tr);
330
+ }
331
+ this.tableEl.append(cap, thead, tb);
332
+ }
333
+ /** The data table (for a visible "Show data" view). */
334
+ getDataTable() {
335
+ const t = this.tableEl.cloneNode(true);
336
+ t.className = 'agc-data-table';
337
+ return t;
338
+ }
339
+ /* ---------- interaction ---------- */
340
+ bindMark(el, p, label) {
341
+ const o = this.options;
342
+ el.classList.add('agc-mark');
343
+ if (o.selectedCategory != null && o.selectedCategory !== p.category)
344
+ el.classList.add('agc-dim');
345
+ if (o.selectedCategory != null && o.selectedCategory === p.category)
346
+ el.classList.add('agc-selected');
347
+ const show = (x, y) => {
348
+ this.tooltip.textContent = label;
349
+ this.tooltip.hidden = false;
350
+ const rb = this.root.getBoundingClientRect();
351
+ const tw = this.tooltip.offsetWidth || 120;
352
+ this.tooltip.style.left = `${Math.min(Math.max(4, x - rb.left + 12), rb.width - tw - 4)}px`;
353
+ this.tooltip.style.top = `${Math.max(4, y - rb.top - 36)}px`;
354
+ };
355
+ el.addEventListener('mousemove', (e) => show(e.clientX, e.clientY));
356
+ el.addEventListener('mouseleave', () => (this.tooltip.hidden = true));
357
+ if (o.onPointClick) {
358
+ el.setAttribute('tabindex', '0');
359
+ el.setAttribute('role', 'button');
360
+ el.setAttribute('aria-label', label);
361
+ el.style.cursor = 'pointer';
362
+ el.addEventListener('click', () => o.onPointClick?.(p));
363
+ el.addEventListener('keydown', (e) => {
364
+ const k = e.key;
365
+ if (k === 'Enter' || k === ' ') {
366
+ e.preventDefault();
367
+ o.onPointClick?.(p);
368
+ }
369
+ });
370
+ el.addEventListener('focus', () => {
371
+ const r = el.getBoundingClientRect();
372
+ show(r.left + r.width / 2, r.top);
373
+ });
374
+ el.addEventListener('blur', () => (this.tooltip.hidden = true));
375
+ }
376
+ else {
377
+ const t = svg('title', {}, el);
378
+ t.textContent = label;
379
+ }
380
+ }
381
+ text(parent, x, y, s, attrs = {}) {
382
+ const t = svg('text', { x, y, class: 'agc-text', ...attrs }, parent);
383
+ t.textContent = s;
384
+ return t;
385
+ }
386
+ /** Text with a background "pill" so annotations stay legible over marks. */
387
+ callout(parent, x, y, s, cls, anchor = 'middle') {
388
+ const g = svg('g', { class: `agc-callout ${cls}` }, parent);
389
+ const w = s.length * 6.4 + 10;
390
+ const left = anchor === 'middle' ? x - w / 2 : anchor === 'end' ? x - w : x;
391
+ svg('rect', { x: left, y: y - 12, width: w, height: 17, rx: 8.5, class: 'agc-callout-bg' }, g);
392
+ this.text(g, left + w / 2, y + 1, s, { 'text-anchor': 'middle', class: 'agc-callout-text' });
393
+ }
394
+ /* ---------- cartesian ---------- */
395
+ renderCartesian(box) {
396
+ const o = this.options;
397
+ let categories = o.data.map((d) => String(d[o.categoryKey] ?? ''));
398
+ let series = this.visibleSeries();
399
+ let values = series.map((s) => o.data.map((d) => toNumber(d[s.key])));
400
+ let type = o.type;
401
+ let wf = null;
402
+ if (type === 'histogram') {
403
+ const bins = histogramBins(o.data.map((d) => toNumber(d[series[0]?.key ?? ''])), o.bins ?? 10);
404
+ categories = bins.map((b) => b.label);
405
+ series = [{ key: 'count', name: 'Count', color: this.color(o.series[0]) }];
406
+ values = [bins.map((b) => b.count)];
407
+ type = 'column';
408
+ }
409
+ if (type === 'waterfall') {
410
+ wf = waterfall(categories, values[0] ?? []);
411
+ categories = wf.map((s) => s.label);
412
+ }
413
+ const normalized = type === 'normalizedColumn' || type === 'normalizedBar' || type === 'normalizedArea';
414
+ if (normalized) {
415
+ const totals = categories.map((_, c) => values.reduce((s, v) => s + Math.max(0, v[c]), 0) || 1);
416
+ values = values.map((v) => v.map((x, c) => (Math.max(0, x) / totals[c]) * 100));
417
+ }
418
+ const horizontal = type === 'bar' || type === 'stackedBar' || type === 'normalizedBar';
419
+ const stacked = type === 'stackedColumn' || type === 'stackedBar' || type === 'stackedArea' || normalized;
420
+ const secondaryKeys = new Set(type === 'combo' || type === 'line' || type === 'column' ? o.secondaryAxis ?? [] : []);
421
+ const primaryIdx = series.map((s, i) => (secondaryKeys.has(s.key) ? -1 : i)).filter((i) => i >= 0);
422
+ const secondaryIdx = series.map((s, i) => (secondaryKeys.has(s.key) ? i : -1)).filter((i) => i >= 0);
423
+ const stacks = stacked ? stack(values) : null;
424
+ const canProject = !horizontal && !stacked && !wf && ['line', 'area', 'column', 'combo'].includes(type);
425
+ const nActual = categories.length;
426
+ // smart overlays
427
+ const fcPeriods = canProject && o.forecast ? Math.max(0, Math.min(24, Math.round(o.forecast))) : 0;
428
+ const fc = fcPeriods && values[0] ? holtForecast(values[0], fcPeriods) : [];
429
+ if (fc.length)
430
+ categories = [...categories, ...nextLabels(categories, fc.length)];
431
+ const regs = canProject || type === 'bar' ? values.map((v) => linearRegression(v)) : [];
432
+ let lo = 0;
433
+ let hi = 0;
434
+ if (wf) {
435
+ for (const s of wf) {
436
+ lo = Math.min(lo, s.start, s.end);
437
+ hi = Math.max(hi, s.start, s.end);
438
+ }
439
+ }
440
+ else if (stacks) {
441
+ for (const s of stacks)
442
+ for (const [a, b] of s) {
443
+ lo = Math.min(lo, a);
444
+ hi = Math.max(hi, b);
445
+ }
446
+ }
447
+ else {
448
+ const all = primaryIdx.flatMap((i) => values[i]);
449
+ lo = all.length ? Math.min(...all) : 0;
450
+ hi = all.length ? Math.max(...all) : 1;
451
+ for (const p of fc) {
452
+ lo = Math.min(lo, p.low);
453
+ hi = Math.max(hi, p.high);
454
+ }
455
+ if (o.trendline)
456
+ for (const i of primaryIdx) {
457
+ lo = Math.min(lo, regs[i]?.at(0) ?? lo, regs[i]?.at(categories.length - 1) ?? lo);
458
+ hi = Math.max(hi, regs[i]?.at(0) ?? hi, regs[i]?.at(categories.length - 1) ?? hi);
459
+ }
460
+ if (type !== 'line') {
461
+ lo = Math.min(0, lo);
462
+ hi = Math.max(0, hi);
463
+ }
464
+ }
465
+ for (const r of o.referenceLines ?? []) {
466
+ lo = Math.min(lo, r.value);
467
+ hi = Math.max(hi, r.value);
468
+ }
469
+ if (normalized) {
470
+ lo = 0;
471
+ hi = 100;
472
+ }
473
+ const ticks = niceTicks(lo, hi, 5);
474
+ const vMin = ticks[0];
475
+ const vMax = ticks[ticks.length - 1];
476
+ const fmtTick = normalized ? (v) => `${v}%` : this.fmt;
477
+ const tickLabels = ticks.map((t) => fmtTick(t));
478
+ // secondary axis
479
+ let sTicks = [];
480
+ if (secondaryIdx.length) {
481
+ const all = secondaryIdx.flatMap((i) => values[i]);
482
+ sTicks = niceTicks(Math.min(0, ...all), Math.max(0, ...all), 5);
483
+ }
484
+ const sMin = sTicks[0] ?? 0;
485
+ const sMax = sTicks[sTicks.length - 1] ?? 1;
486
+ const maxCatLen = Math.max(...categories.map((c) => c.length), 1);
487
+ const valLabelW = Math.max(...tickLabels.map((t) => t.length)) * 7 + 14;
488
+ const sLabelW = sTicks.length ? Math.max(...sTicks.map((t) => this.fmt(t).length)) * 7 + 14 : 16;
489
+ const m = { top: o.highlights ? 24 : 14, right: secondaryIdx.length ? sLabelW : 16, bottom: 30, left: horizontal ? Math.min(160, maxCatLen * 7 + 16) : valLabelW + (o.axisLabel ? 14 : 0) };
490
+ const plotW = Math.max(20, box.w - m.left - m.right);
491
+ const band0 = (horizontal ? box.h - m.top - m.bottom : plotW) / Math.max(1, categories.length);
492
+ const rotate = !horizontal && maxCatLen * 6.5 > band0;
493
+ if (rotate)
494
+ m.bottom = Math.min(90, 18 + Math.min(maxCatLen, 16) * 5);
495
+ if (horizontal)
496
+ m.bottom = 26;
497
+ const plotH = Math.max(20, box.h - m.top - m.bottom);
498
+ const g = svg('g', { transform: `translate(${m.left},${m.top})` }, this.svgEl);
499
+ const vScale = (v) => (horizontal ? ((v - vMin) / (vMax - vMin || 1)) * plotW : plotH - ((v - vMin) / (vMax - vMin || 1)) * plotH);
500
+ const sScale = (v) => plotH - ((v - sMin) / (sMax - sMin || 1)) * plotH;
501
+ const bandLen = (horizontal ? plotH : plotW) / Math.max(1, categories.length);
502
+ const catPos = (i) => i * bandLen;
503
+ const mid = (i) => catPos(i) + bandLen / 2;
504
+ if (o.axisLabel && !horizontal) {
505
+ this.text(g, -m.left + 10, plotH / 2, o.axisLabel, { 'text-anchor': 'middle', class: 'agc-axis-title', transform: `rotate(-90 ${-m.left + 10} ${plotH / 2})` });
506
+ }
507
+ // forecast zone shading
508
+ if (fc.length) {
509
+ const x0 = catPos(nActual);
510
+ svg('rect', { x: x0, y: 0, width: plotW - x0, height: plotH, class: 'agc-forecast-zone' }, g);
511
+ this.text(g, x0 + 4, 11, 'Forecast', { class: 'agc-axis-text' });
512
+ }
513
+ // grid + value axis
514
+ for (let i = 0; i < ticks.length; i++) {
515
+ const p = vScale(ticks[i]);
516
+ if (horizontal) {
517
+ svg('line', { x1: p, x2: p, y1: 0, y2: plotH, class: ticks[i] === 0 ? 'agc-zero' : 'agc-grid' }, g);
518
+ this.text(g, p, plotH + 16, tickLabels[i], { 'text-anchor': 'middle', class: 'agc-axis-text' });
519
+ }
520
+ else {
521
+ svg('line', { x1: 0, x2: plotW, y1: p, y2: p, class: ticks[i] === 0 ? 'agc-zero' : 'agc-grid' }, g);
522
+ this.text(g, -8, p + 4, tickLabels[i], { 'text-anchor': 'end', class: 'agc-axis-text' });
523
+ }
524
+ }
525
+ for (const t of sTicks)
526
+ this.text(g, plotW + 8, sScale(t) + 4, this.fmt(t), { 'text-anchor': 'start', class: 'agc-axis-text agc-axis-secondary' });
527
+ // category axis
528
+ const every = Math.max(1, Math.ceil(categories.length / Math.max(1, Math.floor((horizontal ? plotH : plotW) / (horizontal ? 15 : rotate ? 16 : 54)))));
529
+ categories.forEach((c, i) => {
530
+ if (i % every)
531
+ return;
532
+ const label = c.length > 18 ? c.slice(0, 17) + '…' : c;
533
+ const cls = i >= nActual ? 'agc-axis-text agc-axis-future' : 'agc-axis-text';
534
+ if (horizontal)
535
+ this.text(g, -8, mid(i) + 4, label, { 'text-anchor': 'end', class: cls });
536
+ else if (rotate)
537
+ this.text(g, mid(i), plotH + 12, label, { 'text-anchor': 'end', class: cls, transform: `rotate(-38 ${mid(i)} ${plotH + 12})` });
538
+ else
539
+ this.text(g, mid(i), plotH + 18, label, { 'text-anchor': 'middle', class: cls });
540
+ });
541
+ const pad = Math.min(14, bandLen * 0.18);
542
+ if (wf) {
543
+ wf.forEach((s, i) => {
544
+ const a = vScale(s.start);
545
+ const b = vScale(s.end);
546
+ const fillC = s.kind === 'total' ? 'var(--agr-accent)' : s.kind === 'up' ? 'var(--agr-success)' : 'var(--agr-danger)';
547
+ const r = svg('rect', { x: catPos(i) + pad, y: Math.min(a, b), width: Math.max(1, bandLen - pad * 2), height: Math.max(1, Math.abs(a - b)), rx: 2 }, g);
548
+ r.style.fill = fillC;
549
+ this.bindMark(r, { category: s.label, seriesKey: o.series[0].key, value: s.value, datum: o.data[i] ?? null }, `${s.label}: ${s.value >= 0 && s.kind !== 'total' ? '+' : ''}${this.fmt(s.value)}`);
550
+ if (o.dataLabels)
551
+ this.text(g, catPos(i) + bandLen / 2, Math.min(a, b) - 4, this.fmt(s.value), { 'text-anchor': 'middle', class: 'agc-data-label' });
552
+ });
553
+ this.drawReferenceLines(g, vScale, plotW, horizontal);
554
+ return;
555
+ }
556
+ const isLineType = type === 'line' || type === 'area' || type === 'stackedArea' || type === 'normalizedArea';
557
+ const isAreaType = type === 'area' || type === 'stackedArea' || type === 'normalizedArea';
558
+ const barSeries = type === 'combo' ? primaryIdx.slice(0, 1).map((i) => series[i]) : isLineType ? [] : primaryIdx.map((i) => series[i]);
559
+ const lineSeries = type === 'combo'
560
+ ? series.filter((s) => !barSeries.includes(s))
561
+ : isLineType ? series : secondaryIdx.map((i) => series[i]);
562
+ const nb = stacked ? 1 : Math.max(1, barSeries.length);
563
+ const inner = Math.max(1, bandLen - pad * 2);
564
+ const bw = inner / nb;
565
+ barSeries.forEach((s, si) => {
566
+ const idx = series.indexOf(s);
567
+ values[idx].forEach((v, ci) => {
568
+ const c = categories[ci];
569
+ let a;
570
+ let b;
571
+ if (stacks) {
572
+ a = vScale(stacks[idx][ci][0]);
573
+ b = vScale(stacks[idx][ci][1]);
574
+ }
575
+ else {
576
+ a = vScale(0 < vMin ? vMin : 0);
577
+ b = vScale(v);
578
+ }
579
+ const off = catPos(ci) + pad + (stacked ? 0 : si * bw);
580
+ const thickness = Math.max(1, (stacked ? inner : bw) - (nb > 1 && !stacked ? 2 : 0));
581
+ const rect = horizontal
582
+ ? svg('rect', { x: Math.min(a, b), y: off, width: Math.max(1, Math.abs(b - a)), height: thickness, rx: 2 }, g)
583
+ : svg('rect', { x: off, y: Math.min(a, b), width: thickness, height: Math.max(1, Math.abs(a - b)), rx: 2 }, g);
584
+ rect.style.fill = this.color(s);
585
+ const shown = normalized ? `${v.toFixed(1)}%` : this.fmt(v);
586
+ this.bindMark(rect, { category: c, seriesKey: s.key, value: v, datum: o.data[ci] ?? null }, `${c} · ${s.name ?? s.key}: ${shown}`);
587
+ if (o.dataLabels && !stacked) {
588
+ if (horizontal)
589
+ this.text(g, Math.max(a, b) + 4, off + thickness / 2 + 4, shown, { class: 'agc-data-label' });
590
+ else
591
+ this.text(g, off + thickness / 2, (v >= 0 ? Math.min(a, b) - 4 : Math.max(a, b) + 12), shown, { 'text-anchor': 'middle', class: 'agc-data-label' });
592
+ }
593
+ });
594
+ });
595
+ const lineSeriesPts = [];
596
+ lineSeries.forEach((s) => {
597
+ const idx = series.indexOf(s);
598
+ const onSecondary = secondaryKeys.has(s.key);
599
+ const scale = onSecondary ? sScale : vScale;
600
+ const stackTop = stacks && isAreaType ? stacks[idx] : null;
601
+ const pts = values[idx].map((v, ci) => [mid(ci), stackTop ? vScale(stackTop[ci][1]) : scale(v)]);
602
+ lineSeriesPts.push({ s, pts });
603
+ const d = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join('');
604
+ if (isAreaType && !onSecondary) {
605
+ let closing;
606
+ if (stackTop) {
607
+ closing = pts.map((p, i) => [p[0], vScale(stackTop[i][0])]).reverse().map((p) => `L${p[0].toFixed(1)},${p[1].toFixed(1)}`).join('') + 'Z';
608
+ }
609
+ else {
610
+ const base = vScale(Math.max(vMin, 0));
611
+ closing = `L${pts[pts.length - 1][0]},${base}L${pts[0][0]},${base}Z`;
612
+ }
613
+ const area = svg('path', { d: `${d}${closing}`, class: `agc-area ${stackTop ? 'agc-area-stacked' : ''}` }, g);
614
+ area.style.fill = this.color(s);
615
+ }
616
+ const path = svg('path', { d, class: `agc-line ${onSecondary ? 'agc-line-secondary' : ''}` }, g);
617
+ path.style.stroke = this.color(s);
618
+ pts.forEach((p, ci) => {
619
+ const dot = svg('circle', { cx: p[0], cy: p[1], r: pts.length > 60 ? 2 : 3.5, class: 'agc-dot' }, g);
620
+ dot.style.fill = this.color(s);
621
+ const v = values[idx][ci];
622
+ this.bindMark(dot, { category: categories[ci], seriesKey: s.key, value: v, datum: o.data[ci] ?? null }, `${categories[ci]} · ${s.name ?? s.key}: ${normalized ? `${v.toFixed(1)}%` : this.fmt(v)}`);
623
+ if (o.dataLabels && pts.length <= 24)
624
+ this.text(g, p[0], p[1] - 8, this.fmt(v), { 'text-anchor': 'middle', class: 'agc-data-label' });
625
+ });
626
+ });
627
+ // trend lines (primary series)
628
+ if (o.trendline && !stacked) {
629
+ for (const i of primaryIdx) {
630
+ const reg = regs[i];
631
+ if (!reg || values[i].length < 2)
632
+ continue;
633
+ const end = categories.length - 1;
634
+ if (horizontal) {
635
+ const t = svg('line', { x1: vScale(reg.at(0)), y1: mid(0), x2: vScale(reg.at(end)), y2: mid(end), class: 'agc-trend' }, g);
636
+ t.setAttribute('aria-hidden', 'true');
637
+ }
638
+ else {
639
+ svg('line', { x1: mid(0), y1: vScale(reg.at(0)), x2: mid(end), y2: vScale(reg.at(end)), class: 'agc-trend' }, g);
640
+ }
641
+ }
642
+ }
643
+ // forecast (first series)
644
+ if (fc.length && values[0]?.length) {
645
+ const sc = (v) => vScale(v);
646
+ const lastX = mid(nActual - 1);
647
+ const lastY = sc(values[0][nActual - 1]);
648
+ const upper = fc.map((p, k) => `${mid(nActual + k).toFixed(1)},${sc(p.high).toFixed(1)}`);
649
+ const lower = fc.map((p, k) => `${mid(nActual + k).toFixed(1)},${sc(p.low).toFixed(1)}`).reverse();
650
+ const band = svg('polygon', { points: [`${lastX},${lastY}`, ...upper, ...lower].join(' '), class: 'agc-forecast-band' }, g);
651
+ band.style.fill = this.color(series[0]);
652
+ const d = `M${lastX},${lastY}` + fc.map((p, k) => `L${mid(nActual + k).toFixed(1)},${sc(p.value).toFixed(1)}`).join('');
653
+ const fl = svg('path', { d, class: 'agc-forecast-line' }, g);
654
+ fl.style.stroke = this.color(series[0]);
655
+ fc.forEach((p, k) => {
656
+ const dot = svg('circle', { cx: mid(nActual + k), cy: sc(p.value), r: 3.5, class: 'agc-dot agc-dot-forecast' }, g);
657
+ dot.style.stroke = this.color(series[0]);
658
+ this.bindMark(dot, { category: categories[nActual + k], seriesKey: series[0].key, value: p.value, datum: null }, `${categories[nActual + k]} forecast: ${this.fmt(p.value)} (range ${this.fmt(p.low)} to ${this.fmt(p.high)})`);
659
+ });
660
+ }
661
+ this.drawReferenceLines(g, vScale, plotW, horizontal);
662
+ // highlights on first series
663
+ if (o.highlights && values[0]?.length > 1 && !stacked && !horizontal) {
664
+ const { maxIndex, minIndex } = extremes(values[0].slice(0, nActual));
665
+ const isLine = lineSeries.includes(series[0]);
666
+ const yOf = (i) => vScale(values[0][i]);
667
+ for (const [i, kind] of [[maxIndex, 'High'], [minIndex, 'Low']]) {
668
+ if (i < 0)
669
+ continue;
670
+ const x = mid(i);
671
+ const y = isLine ? yOf(i) : Math.min(yOf(i), vScale(0));
672
+ svg('circle', { cx: x, cy: isLine ? y : yOf(i), r: 7, class: `agc-highlight agc-highlight-${kind.toLowerCase()}` }, g);
673
+ const above = kind === 'High' || values[0][i] >= 0;
674
+ const ly = above ? Math.max(12, y - 14) : Math.min(plotH - 4, y + 22);
675
+ const anchor = x < 50 ? 'start' : x > plotW - 50 ? 'end' : 'middle';
676
+ this.callout(g, anchor === 'start' ? x - 6 : anchor === 'end' ? x + 6 : x, ly, `${kind} ${this.fmt(values[0][i])}`, `agc-callout-${kind.toLowerCase()}`, anchor);
677
+ }
678
+ }
679
+ }
680
+ drawReferenceLines(g, vScale, plotW, horizontal) {
681
+ for (const r of this.options.referenceLines ?? []) {
682
+ const p = vScale(r.value);
683
+ if (horizontal) {
684
+ svg('line', { x1: p, x2: p, y1: 0, y2: '100%', class: 'agc-reference' }, g);
685
+ }
686
+ else {
687
+ svg('line', { x1: 0, x2: plotW, y1: p, y2: p, class: 'agc-reference' }, g);
688
+ if (r.label)
689
+ this.text(g, plotW - 4, p - 5, r.label, { 'text-anchor': 'end', class: 'agc-reference-label' });
690
+ }
691
+ }
692
+ }
693
+ /* ---------- pie / donut ---------- */
694
+ renderPie(box, donut) {
695
+ const o = this.options;
696
+ const s = o.series[0];
697
+ const vals = o.data.map((d) => Math.max(0, toNumber(d[s.key])));
698
+ const total = vals.reduce((a, b) => a + b, 0) || 1;
699
+ const cx = box.w / 2;
700
+ const cy = box.h / 2;
701
+ const r = Math.max(10, Math.min(box.w * 0.32, box.h / 2 - 18));
702
+ const ir = donut ? r * 0.58 : 0;
703
+ let a0 = -Math.PI / 2;
704
+ const g = svg('g', {}, this.svgEl);
705
+ o.data.forEach((d, i) => {
706
+ const frac = vals[i] / total;
707
+ const a1 = a0 + frac * Math.PI * 2;
708
+ const large = a1 - a0 > Math.PI ? 1 : 0;
709
+ const p = (rad, a) => `${cx + rad * Math.cos(a)},${cy + rad * Math.sin(a)}`;
710
+ const path = frac >= 0.9999
711
+ ? `M${cx - r},${cy}a${r},${r} 0 1,0 ${r * 2},0a${r},${r} 0 1,0 ${-r * 2},0` + (ir ? `M${cx - ir},${cy}a${ir},${ir} 0 1,1 ${ir * 2},0a${ir},${ir} 0 1,1 ${-ir * 2},0` : '')
712
+ : ir
713
+ ? `M${p(r, a0)}A${r},${r} 0 ${large} 1 ${p(r, a1)}L${p(ir, a1)}A${ir},${ir} 0 ${large} 0 ${p(ir, a0)}Z`
714
+ : `M${cx},${cy}L${p(r, a0)}A${r},${r} 0 ${large} 1 ${p(r, a1)}Z`;
715
+ const el = svg('path', { d: path, class: 'agc-slice', 'fill-rule': 'evenodd' }, g);
716
+ el.style.fill = this.catColor(i);
717
+ const cat = String(d[o.categoryKey]);
718
+ this.bindMark(el, { category: cat, seriesKey: s.key, value: vals[i], datum: d }, `${cat}: ${this.fmt(vals[i])} (${(frac * 100).toFixed(1)}%)`);
719
+ if (frac > 0.06) {
720
+ const midA = (a0 + a1) / 2;
721
+ const lr = r + 14;
722
+ const x = cx + lr * Math.cos(midA);
723
+ const pct = `${(frac * 100).toFixed(0)}%`;
724
+ this.text(g, x, cy + lr * Math.sin(midA) + 4, o.dataLabels ? `${cat} ${pct}` : pct, { 'text-anchor': x < cx - 2 ? 'end' : x > cx + 2 ? 'start' : 'middle', class: 'agc-axis-text' });
725
+ }
726
+ a0 = a1;
727
+ });
728
+ if (donut) {
729
+ this.text(g, cx, cy - 2, this.fmt(total), { 'text-anchor': 'middle', class: 'agc-big' });
730
+ this.text(g, cx, cy + 16, s.name ?? s.key, { 'text-anchor': 'middle', class: 'agc-axis-text' });
731
+ }
732
+ }
733
+ renderFunnel(box) {
734
+ const o = this.options;
735
+ const s = o.series[0];
736
+ const rows = o.data.map((d) => ({ d, c: String(d[o.categoryKey]), v: Math.max(0, toNumber(d[s.key])) }));
737
+ const max = Math.max(...rows.map((r) => r.v), 1);
738
+ const first = rows[0]?.v || 1;
739
+ const rowH = Math.min(46, (box.h - 10) / rows.length);
740
+ const labelW = Math.min(140, Math.max(...rows.map((r) => r.c.length)) * 7 + 12);
741
+ const valueW = 110;
742
+ const maxW = Math.max(40, box.w - labelW - valueW - 20);
743
+ const g = svg('g', { transform: 'translate(0,5)' }, this.svgEl);
744
+ rows.forEach((r, i) => {
745
+ const w = Math.max(4, (r.v / max) * maxW);
746
+ const x = labelW + 10 + (maxW - w) / 2;
747
+ const rect = svg('rect', { x, y: i * rowH + 2, width: w, height: rowH - 4, rx: 3 }, g);
748
+ rect.style.fill = this.catColor(i);
749
+ const conv = i === 0 ? '' : ` · ${((r.v / first) * 100).toFixed(0)}% of ${rows[0].c}`;
750
+ const step = i === 0 ? '' : ` (${((r.v / (rows[i - 1].v || 1)) * 100).toFixed(0)}% from ${rows[i - 1].c})`;
751
+ this.bindMark(rect, { category: r.c, seriesKey: s.key, value: r.v, datum: r.d }, `${r.c}: ${this.fmt(r.v)}${conv}${step}`);
752
+ this.text(g, labelW, i * rowH + rowH / 2 + 4, r.c, { 'text-anchor': 'end', class: 'agc-axis-text' });
753
+ this.text(g, labelW + 20 + maxW, i * rowH + rowH / 2 + 4, `${this.fmt(r.v)}${i ? ` · ${((r.v / first) * 100).toFixed(0)}%` : ''}`, { class: 'agc-axis-text' });
754
+ });
755
+ }
756
+ renderTreemap(box) {
757
+ const o = this.options;
758
+ const s = o.series[0];
759
+ const vals = o.data.map((d) => toNumber(d[s.key]));
760
+ const total = vals.reduce((a, b) => a + Math.max(0, b), 0) || 1;
761
+ const rects = treemap(vals, { x: 2, y: 2, w: box.w - 4, h: box.h - 4 });
762
+ const g = svg('g', {}, this.svgEl);
763
+ rects.forEach((r, i) => {
764
+ if (!r.w || !r.h)
765
+ return;
766
+ const el = svg('rect', { x: r.x + 1, y: r.y + 1, width: Math.max(0, r.w - 2), height: Math.max(0, r.h - 2), rx: 3 }, g);
767
+ el.style.fill = this.catColor(i);
768
+ const cat = String(o.data[i][o.categoryKey]);
769
+ this.bindMark(el, { category: cat, seriesKey: s.key, value: vals[i], datum: o.data[i] }, `${cat}: ${this.fmt(vals[i])} (${((Math.max(0, vals[i]) / total) * 100).toFixed(1)}%)`);
770
+ if (r.w > 60 && r.h > 30) {
771
+ this.text(g, r.x + 8, r.y + 18, cat.length * 7 > r.w - 12 ? cat.slice(0, Math.max(1, Math.floor((r.w - 16) / 7))) + '…' : cat, { class: 'agc-on-mark' });
772
+ if (r.h > 44)
773
+ this.text(g, r.x + 8, r.y + 34, this.fmt(vals[i]), { class: 'agc-on-mark agc-on-mark-sub' });
774
+ }
775
+ });
776
+ }
777
+ renderHeatmap(box) {
778
+ const o = this.options;
779
+ const series = this.visibleSeries();
780
+ const cats = o.data.map((d) => String(d[o.categoryKey]));
781
+ const vals = o.data.map((d) => series.map((s) => toNumber(d[s.key])));
782
+ const flat = vals.flat();
783
+ const lo = Math.min(...flat);
784
+ const hi = Math.max(...flat);
785
+ const m = { top: 26, left: Math.min(150, Math.max(...cats.map((c) => c.length)) * 7 + 14), right: 8, bottom: 6 };
786
+ const cw = (box.w - m.left - m.right) / Math.max(1, series.length);
787
+ const ch = (box.h - m.top - m.bottom) / Math.max(1, cats.length);
788
+ const g = svg('g', { transform: `translate(${m.left},${m.top})` }, this.svgEl);
789
+ series.forEach((s, si) => this.text(g, si * cw + cw / 2, -9, s.name ?? s.key, { 'text-anchor': 'middle', class: 'agc-axis-text' }));
790
+ cats.forEach((c, ci) => {
791
+ this.text(g, -8, ci * ch + ch / 2 + 4, c, { 'text-anchor': 'end', class: 'agc-axis-text' });
792
+ series.forEach((s, si) => {
793
+ const v = vals[ci][si];
794
+ const t = hi === lo ? 0.6 : (v - lo) / (hi - lo);
795
+ const rect = svg('rect', { x: si * cw + 1, y: ci * ch + 1, width: Math.max(0, cw - 2), height: Math.max(0, ch - 2), rx: 2 }, g);
796
+ rect.style.fill = `color-mix(in srgb, var(--agr-accent) ${Math.round(12 + t * 88)}%, var(--agr-bg))`;
797
+ this.bindMark(rect, { category: c, seriesKey: s.key, value: v, datum: o.data[ci] }, `${c} · ${s.name ?? s.key}: ${this.fmt(v)}`);
798
+ if (cw > 44 && ch > 18)
799
+ this.text(g, si * cw + cw / 2, ci * ch + ch / 2 + 4, this.fmt(v), { 'text-anchor': 'middle', class: t > 0.55 ? 'agc-on-accent' : 'agc-axis-text' });
800
+ });
801
+ });
802
+ }
803
+ renderGauge(box) {
804
+ const o = this.options;
805
+ const s = o.series[0];
806
+ const v = toNumber(o.data[0]?.[s.key]);
807
+ const max = o.max ?? niceTicks(0, Math.max(v, o.target ?? 0, 1), 4).pop();
808
+ const frac = Math.max(0, Math.min(1, v / (max || 1)));
809
+ const cx = box.w / 2;
810
+ const r = Math.max(30, Math.min(box.w / 2 - 34, box.h * 0.62));
811
+ const cy = Math.min(box.h - 34, 18 + r);
812
+ const pt = (f, rad = r) => {
813
+ const a = Math.PI * (1 + f);
814
+ return [cx + rad * Math.cos(a), cy + rad * Math.sin(a)];
815
+ };
816
+ const arc = (f0, f1, rad = r) => {
817
+ const [x0, y0] = pt(f0, rad);
818
+ const [x1, y1] = pt(f1, rad);
819
+ return `M${x0},${y0}A${rad},${rad} 0 0 1 ${x1},${y1}`;
820
+ };
821
+ svg('path', { d: arc(0, 1), class: 'agc-gauge-track' }, this.svgEl);
822
+ for (const b of o.thresholds ?? []) {
823
+ const f0 = Math.max(0, Math.min(1, b.from / max));
824
+ const f1 = Math.max(0, Math.min(1, b.to / max));
825
+ if (f1 <= f0)
826
+ continue;
827
+ const band = svg('path', { d: arc(f0, f1, r + 15), class: `agc-gauge-band agc-band-${b.tone}` }, this.svgEl);
828
+ const t = svg('title', {}, band);
829
+ t.textContent = `${b.label ?? b.tone}: ${this.fmt(b.from)} – ${this.fmt(b.to)}`;
830
+ }
831
+ const val = svg('path', { d: arc(0, Math.max(0.001, frac)), class: 'agc-gauge-value' }, this.svgEl);
832
+ val.style.stroke = this.color(s);
833
+ this.bindMark(val, { category: String(o.data[0]?.[o.categoryKey] ?? ''), seriesKey: s.key, value: v, datum: o.data[0] ?? null }, `${s.name ?? s.key}: ${this.fmt(v)} of ${this.fmt(max)}${o.target != null ? `, target ${this.fmt(o.target)}` : ''}`);
834
+ if (o.target != null) {
835
+ const tf = Math.max(0, Math.min(1, o.target / max));
836
+ const [x0, y0] = pt(tf, r - 14);
837
+ const [x1, y1] = pt(tf, r + 20);
838
+ svg('line', { x1: x0, y1: y0, x2: x1, y2: y1, class: 'agc-gauge-target' }, this.svgEl);
839
+ const [lx, ly] = pt(tf, r + 30);
840
+ this.text(this.svgEl, lx, ly, `Target ${this.fmt(o.target)}`, { 'text-anchor': tf < 0.4 ? 'end' : tf > 0.6 ? 'start' : 'middle', class: 'agc-axis-text agc-target-label' });
841
+ }
842
+ const status = o.target == null ? '' : v >= o.target ? ' · on target' : ` · ${this.fmt(o.target - v)} below target`;
843
+ this.text(this.svgEl, cx, cy - 10, this.fmt(v), { 'text-anchor': 'middle', class: 'agc-big' });
844
+ this.text(this.svgEl, cx, cy + 12, `${s.name ?? s.key}${status}`, { 'text-anchor': 'middle', class: 'agc-axis-text' });
845
+ this.text(this.svgEl, cx - r, cy + 18, this.fmt(0), { 'text-anchor': 'middle', class: 'agc-axis-text' });
846
+ this.text(this.svgEl, cx + r, cy + 18, this.fmt(max), { 'text-anchor': 'middle', class: 'agc-axis-text' });
847
+ }
848
+ renderRadar(box, filled = true) {
849
+ const o = this.options;
850
+ const series = this.visibleSeries();
851
+ const cats = o.data.map((d) => String(d[o.categoryKey]));
852
+ const raw = o.data.map((d) => series.map((s) => toNumber(d[s.key])));
853
+ const vals = o.normalize
854
+ ? raw.map((row) => {
855
+ const mx = Math.max(...row.map(Math.abs), 1e-9);
856
+ return row.map((v) => (Math.max(0, v) / mx) * 100);
857
+ })
858
+ : raw;
859
+ const max = o.normalize ? 100 : niceTicks(0, Math.max(...vals.flat(), 1), 4).pop();
860
+ const cx = box.w / 2;
861
+ const cy = box.h / 2;
862
+ const r = Math.min(box.w, box.h) / 2 - 36;
863
+ const n = Math.max(3, cats.length);
864
+ const pt = (i, f) => [cx + r * f * Math.cos(-Math.PI / 2 + (i * 2 * Math.PI) / n), cy + r * f * Math.sin(-Math.PI / 2 + (i * 2 * Math.PI) / n)];
865
+ for (const f of [0.25, 0.5, 0.75, 1]) {
866
+ svg('polygon', { points: cats.map((_, i) => pt(i, f).join(',')).join(' '), class: 'agc-grid agc-radar-ring' }, this.svgEl);
867
+ }
868
+ if (o.normalize)
869
+ this.text(this.svgEl, cx + 4, cy - r - 4, '100', { class: 'agc-axis-text' });
870
+ cats.forEach((c, i) => {
871
+ const [x, y] = pt(i, 1);
872
+ svg('line', { x1: cx, y1: cy, x2: x, y2: y, class: 'agc-grid' }, this.svgEl);
873
+ const [lx, ly] = pt(i, 1.13);
874
+ this.text(this.svgEl, lx, ly + 4, c, { 'text-anchor': lx < cx - 4 ? 'end' : lx > cx + 4 ? 'start' : 'middle', class: 'agc-axis-text' });
875
+ });
876
+ series.forEach((s, si) => {
877
+ const pts = vals.map((row, i) => pt(i, row[si] / max));
878
+ const poly = svg('polygon', { points: pts.map((p) => p.join(',')).join(' '), class: filled ? 'agc-radar-area' : 'agc-radar-line' }, this.svgEl);
879
+ poly.style.fill = filled ? this.color(s) : 'none';
880
+ poly.style.stroke = this.color(s);
881
+ pts.forEach((p, i) => {
882
+ const dot = svg('circle', { cx: p[0], cy: p[1], r: 3.5, class: 'agc-dot' }, this.svgEl);
883
+ dot.style.fill = this.color(s);
884
+ const shown = o.normalize ? `${vals[i][si].toFixed(0)} / 100 (actual ${this.fmt(raw[i][si])})` : this.fmt(raw[i][si]);
885
+ this.bindMark(dot, { category: cats[i], seriesKey: s.key, value: raw[i][si], datum: o.data[i] }, `${cats[i]} · ${s.name ?? s.key}: ${shown}`);
886
+ });
887
+ });
888
+ }
889
+ /* ---------- polar, hierarchical, statistical, pyramid ---------- */
890
+ arcPath(cx, cy, r0, r1, a0, a1) {
891
+ const p = (rad, a) => `${(cx + rad * Math.cos(a)).toFixed(2)},${(cy + rad * Math.sin(a)).toFixed(2)}`;
892
+ const large = a1 - a0 > Math.PI ? 1 : 0;
893
+ if (a1 - a0 >= Math.PI * 2 - 1e-6)
894
+ a1 = a0 + Math.PI * 2 - 1e-4;
895
+ return r0 > 0
896
+ ? `M${p(r1, a0)}A${r1},${r1} 0 ${large} 1 ${p(r1, a1)}L${p(r0, a1)}A${r0},${r0} 0 ${large} 0 ${p(r0, a0)}Z`
897
+ : `M${cx},${cy}L${p(r1, a0)}A${r1},${r1} 0 ${large} 1 ${p(r1, a1)}Z`;
898
+ }
899
+ /** Nightingale rose: equal angles, radius ∝ √value so area stays proportional. */
900
+ renderNightingale(box) {
901
+ const o = this.options;
902
+ const s = o.series[0];
903
+ const vals = o.data.map((d) => Math.max(0, toNumber(d[s.key])));
904
+ const max = Math.max(...vals, 1e-9);
905
+ const cx = box.w / 2;
906
+ const cy = box.h / 2;
907
+ const R = Math.max(10, Math.min(box.w * 0.34, box.h / 2 - 22));
908
+ const step = (Math.PI * 2) / Math.max(1, vals.length);
909
+ const g = svg('g', {}, this.svgEl);
910
+ for (const f of [0.25, 0.5, 0.75, 1])
911
+ svg('circle', { cx, cy, r: R * Math.sqrt(f), class: 'agc-grid', fill: 'none' }, g);
912
+ o.data.forEach((d, i) => {
913
+ const a0 = -Math.PI / 2 + i * step + 0.01;
914
+ const a1 = a0 + step - 0.02;
915
+ const r = R * Math.sqrt(vals[i] / max);
916
+ const el = svg('path', { d: this.arcPath(cx, cy, 0, Math.max(1, r), a0, a1), class: 'agc-slice' }, g);
917
+ el.style.fill = this.catColor(i);
918
+ const cat = String(d[o.categoryKey]);
919
+ this.bindMark(el, { category: cat, seriesKey: s.key, value: vals[i], datum: d }, `${cat}: ${this.fmt(vals[i])}`);
920
+ const midA = (a0 + a1) / 2;
921
+ const lr = Math.max(r, R * 0.35) + 12;
922
+ const x = cx + lr * Math.cos(midA);
923
+ if (vals.length <= 16)
924
+ this.text(g, x, cy + lr * Math.sin(midA) + 4, cat, { 'text-anchor': x < cx - 2 ? 'end' : x > cx + 2 ? 'start' : 'middle', class: 'agc-axis-text' });
925
+ });
926
+ }
927
+ /** Radial bar: one ring per category, arc length ∝ value. */
928
+ renderRadialBar(box) {
929
+ const o = this.options;
930
+ const s = o.series[0];
931
+ const vals = o.data.map((d) => Math.max(0, toNumber(d[s.key])));
932
+ const max = niceTicks(0, Math.max(...vals, 1), 4).pop();
933
+ const cx = box.w / 2;
934
+ const cy = box.h / 2;
935
+ const R = Math.max(20, Math.min(box.w / 2 - 20, box.h / 2 - 12));
936
+ const inner = R * 0.28;
937
+ const n = Math.max(1, vals.length);
938
+ const ring = (R - inner) / n;
939
+ const g = svg('g', {}, this.svgEl);
940
+ o.data.forEach((d, i) => {
941
+ const r1 = R - i * ring - 1;
942
+ const r0 = Math.max(inner, r1 - ring + 3);
943
+ svg('path', { d: this.arcPath(cx, cy, r0, r1, -Math.PI / 2, Math.PI * 1.5 - 0.001), class: 'agc-gauge-track-fill' }, g);
944
+ const a1 = -Math.PI / 2 + Math.max(0.002, vals[i] / max) * Math.PI * 1.5;
945
+ const el = svg('path', { d: this.arcPath(cx, cy, r0, r1, -Math.PI / 2, a1), class: 'agc-slice' }, g);
946
+ el.style.fill = this.catColor(i);
947
+ const cat = String(d[o.categoryKey]);
948
+ this.bindMark(el, { category: cat, seriesKey: s.key, value: vals[i], datum: d }, `${cat}: ${this.fmt(vals[i])}`);
949
+ if (ring >= 11)
950
+ this.text(g, cx - 6, cy - (r0 + r1) / 2 + 4, `${cat.length > 14 ? cat.slice(0, 13) + '…' : cat} ${this.fmt(vals[i])}`, { 'text-anchor': 'end', class: 'agc-axis-text' });
951
+ });
952
+ }
953
+ /**
954
+ * Sunburst: inner ring = categories, outer ring = each category split by series
955
+ * (or by `subCategoryKey` when the data is not pre-aggregated).
956
+ */
957
+ renderSunburst(box) {
958
+ const o = this.options;
959
+ const series = this.visibleSeries();
960
+ const sub = o.subCategoryKey;
961
+ const nodes = [];
962
+ if (sub) {
963
+ const map = new Map();
964
+ for (const d of o.data) {
965
+ const c = String(d[o.categoryKey]);
966
+ const k = String(d[sub]);
967
+ const m = map.get(c) ?? new Map();
968
+ m.set(k, (m.get(k) ?? 0) + Math.max(0, toNumber(d[series[0].key])));
969
+ map.set(c, m);
970
+ }
971
+ for (const [c, m] of map)
972
+ nodes.push({ name: c, value: [...m.values()].reduce((a, b) => a + b, 0), children: [...m].map(([k, v]) => ({ name: k, value: v, key: series[0].key })) });
973
+ }
974
+ else {
975
+ for (const d of o.data) {
976
+ const children = series.map((s) => ({ name: s.name ?? s.key, value: Math.max(0, toNumber(d[s.key])), key: s.key }));
977
+ nodes.push({ name: String(d[o.categoryKey]), value: children.reduce((a, c) => a + c.value, 0), children });
978
+ }
979
+ }
980
+ const total = nodes.reduce((a, n) => a + n.value, 0) || 1;
981
+ const cx = box.w / 2;
982
+ const cy = box.h / 2;
983
+ const R = Math.max(20, Math.min(box.w * 0.36, box.h / 2 - 8));
984
+ const r0 = R * 0.22;
985
+ const r1 = R * 0.6;
986
+ const g = svg('g', {}, this.svgEl);
987
+ let a = -Math.PI / 2;
988
+ nodes.forEach((n, i) => {
989
+ const span = (n.value / total) * Math.PI * 2;
990
+ if (span <= 0)
991
+ return;
992
+ const el = svg('path', { d: this.arcPath(cx, cy, r0, r1, a, a + span), class: 'agc-slice agc-sun-inner' }, g);
993
+ el.style.fill = this.catColor(i);
994
+ this.bindMark(el, { category: n.name, seriesKey: series[0]?.key ?? '', value: n.value, datum: null }, `${n.name}: ${this.fmt(n.value)} (${((n.value / total) * 100).toFixed(1)}%)`);
995
+ if (span > 0.35) {
996
+ const m = a + span / 2;
997
+ this.text(g, cx + ((r0 + r1) / 2) * Math.cos(m), cy + ((r0 + r1) / 2) * Math.sin(m) + 4, n.name.length > 10 ? n.name.slice(0, 9) + '…' : n.name, { 'text-anchor': 'middle', class: 'agc-on-mark' });
998
+ }
999
+ let b = a;
1000
+ const nt = n.value || 1;
1001
+ n.children.forEach((c, k) => {
1002
+ const cs = (c.value / nt) * span;
1003
+ if (cs <= 0)
1004
+ return;
1005
+ const ce = svg('path', { d: this.arcPath(cx, cy, r1 + 2, R, b, b + cs), class: 'agc-slice agc-sun-outer' }, g);
1006
+ ce.style.fill = `color-mix(in srgb, ${this.catColor(i)} ${Math.max(35, 85 - k * 14)}%, var(--agr-bg, #fff))`;
1007
+ this.bindMark(ce, { category: n.name, seriesKey: c.key, value: c.value, datum: null }, `${n.name} › ${c.name}: ${this.fmt(c.value)}`);
1008
+ b += cs;
1009
+ });
1010
+ a += span;
1011
+ });
1012
+ this.text(g, cx, cy + 4, this.fmt(total), { 'text-anchor': 'middle', class: 'agc-axis-text agc-sun-total' });
1013
+ }
1014
+ /** Pyramid: segments stacked into a triangle, height ∝ value. */
1015
+ renderPyramid(box) {
1016
+ const o = this.options;
1017
+ const s = o.series[0];
1018
+ const rows = o.data.map((d) => ({ d, c: String(d[o.categoryKey]), v: Math.max(0, toNumber(d[s.key])) })).sort((a, b) => a.v - b.v);
1019
+ const total = rows.reduce((a, r) => a + r.v, 0) || 1;
1020
+ const labelW = 150;
1021
+ const H = box.h - 10;
1022
+ const W = Math.max(60, Math.min(box.w - labelW - 20, H * 1.3));
1023
+ const x0 = (box.w - labelW - W) / 2;
1024
+ const cx = x0 + W / 2;
1025
+ const g = svg('g', { transform: 'translate(0,5)' }, this.svgEl);
1026
+ let y = 0;
1027
+ rows.forEach((r, i) => {
1028
+ const h = (r.v / total) * H;
1029
+ const wTop = (y / H) * W;
1030
+ const wBot = ((y + h) / H) * W;
1031
+ const pts = [[cx - wTop / 2, y], [cx + wTop / 2, y], [cx + wBot / 2, y + h - 1], [cx - wBot / 2, y + h - 1]];
1032
+ const el = svg('polygon', { points: pts.map((p) => p.join(',')).join(' ') }, g);
1033
+ el.style.fill = this.catColor(i);
1034
+ this.bindMark(el, { category: r.c, seriesKey: s.key, value: r.v, datum: r.d }, `${r.c}: ${this.fmt(r.v)} (${((r.v / total) * 100).toFixed(1)}%)`);
1035
+ if (h >= 12)
1036
+ this.text(g, cx + W / 2 + 14, y + h / 2 + 4, `${r.c} · ${this.fmt(r.v)}`, { class: 'agc-axis-text' });
1037
+ y += h;
1038
+ });
1039
+ }
1040
+ /**
1041
+ * Box plot (quartiles) and range bar (min–max with mean) per category, computed from raw rows:
1042
+ * every row is one observation of series[0] in its category.
1043
+ */
1044
+ renderStat(box, kind) {
1045
+ const o = this.options;
1046
+ const s = o.series[0];
1047
+ const groups = new Map();
1048
+ for (const d of o.data) {
1049
+ const v = toNumber(d[s.key]);
1050
+ if (!Number.isFinite(v))
1051
+ continue;
1052
+ const c = String(d[o.categoryKey]);
1053
+ (groups.get(c) ?? groups.set(c, []).get(c)).push(v);
1054
+ }
1055
+ const q = (sorted, p) => {
1056
+ const i = (sorted.length - 1) * p;
1057
+ const lo = Math.floor(i);
1058
+ return sorted[lo] + (sorted[Math.min(lo + 1, sorted.length - 1)] - sorted[lo]) * (i - lo);
1059
+ };
1060
+ const stats = [...groups].map(([c, vs]) => {
1061
+ const v = [...vs].sort((a, b) => a - b);
1062
+ return { c, min: v[0], q1: q(v, 0.25), med: q(v, 0.5), q3: q(v, 0.75), max: v[v.length - 1], mean: v.reduce((a, b) => a + b, 0) / v.length, n: v.length };
1063
+ });
1064
+ if (!stats.length)
1065
+ return;
1066
+ const ticks = niceTicks(Math.min(...stats.map((x) => x.min)), Math.max(...stats.map((x) => x.max)), 5);
1067
+ const lo = ticks[0];
1068
+ const hi = ticks[ticks.length - 1];
1069
+ const horizontal = kind === 'rangeBar';
1070
+ const catW = Math.min(150, Math.max(...stats.map((x) => x.c.length)) * 7 + 16);
1071
+ const valW = Math.max(...ticks.map((t) => this.fmt(t).length)) * 7 + 14;
1072
+ const m = { top: 12, right: 16, bottom: 28, left: horizontal ? catW : valW };
1073
+ const pw = box.w - m.left - m.right;
1074
+ const ph = box.h - m.top - m.bottom;
1075
+ const g = svg('g', { transform: `translate(${m.left},${m.top})` }, this.svgEl);
1076
+ const vs = (v) => (horizontal ? ((v - lo) / (hi - lo || 1)) * pw : ph - ((v - lo) / (hi - lo || 1)) * ph);
1077
+ const band = (horizontal ? ph : pw) / stats.length;
1078
+ for (const t of ticks) {
1079
+ const p = vs(t);
1080
+ if (horizontal) {
1081
+ svg('line', { x1: p, x2: p, y1: 0, y2: ph, class: 'agc-grid' }, g);
1082
+ this.text(g, p, ph + 16, this.fmt(t), { 'text-anchor': 'middle', class: 'agc-axis-text' });
1083
+ }
1084
+ else {
1085
+ svg('line', { x1: 0, x2: pw, y1: p, y2: p, class: 'agc-grid' }, g);
1086
+ this.text(g, -8, p + 4, this.fmt(t), { 'text-anchor': 'end', class: 'agc-axis-text' });
1087
+ }
1088
+ }
1089
+ stats.forEach((st, i) => {
1090
+ const c0 = i * band;
1091
+ const midP = c0 + band / 2;
1092
+ const thick = Math.max(4, Math.min(40, band * 0.5));
1093
+ const color = this.catColor(i);
1094
+ const label = st.c.length > 16 ? st.c.slice(0, 15) + '…' : st.c;
1095
+ if (horizontal) {
1096
+ this.text(g, -8, midP + 4, label, { 'text-anchor': 'end', class: 'agc-axis-text' });
1097
+ const r = svg('rect', { x: vs(st.min), y: midP - thick / 2, width: Math.max(2, vs(st.max) - vs(st.min)), height: thick, rx: thick / 2 }, g);
1098
+ r.style.fill = color;
1099
+ svg('line', { x1: vs(st.mean), x2: vs(st.mean), y1: midP - thick / 2 - 3, y2: midP + thick / 2 + 3, class: 'agc-stat-mean' }, g);
1100
+ this.bindMark(r, { category: st.c, seriesKey: s.key, value: st.mean, datum: null }, `${st.c}: ${this.fmt(st.min)} – ${this.fmt(st.max)}, mean ${this.fmt(st.mean)} (${st.n} rows)`);
1101
+ }
1102
+ else {
1103
+ this.text(g, midP, ph + 18, label, { 'text-anchor': 'middle', class: 'agc-axis-text' });
1104
+ svg('line', { x1: midP, x2: midP, y1: vs(st.max), y2: vs(st.q3), class: 'agc-stat-whisker' }, g);
1105
+ svg('line', { x1: midP, x2: midP, y1: vs(st.q1), y2: vs(st.min), class: 'agc-stat-whisker' }, g);
1106
+ for (const v of [st.min, st.max])
1107
+ svg('line', { x1: midP - thick / 4, x2: midP + thick / 4, y1: vs(v), y2: vs(v), class: 'agc-stat-whisker' }, g);
1108
+ const r = svg('rect', { x: midP - thick / 2, y: vs(st.q3), width: thick, height: Math.max(1, vs(st.q1) - vs(st.q3)), rx: 2, class: 'agc-stat-box' }, g);
1109
+ r.style.fill = color;
1110
+ svg('line', { x1: midP - thick / 2, x2: midP + thick / 2, y1: vs(st.med), y2: vs(st.med), class: 'agc-stat-median' }, g);
1111
+ this.bindMark(r, { category: st.c, seriesKey: s.key, value: st.med, datum: null }, `${st.c}: median ${this.fmt(st.med)}, Q1 ${this.fmt(st.q1)}, Q3 ${this.fmt(st.q3)}, range ${this.fmt(st.min)} – ${this.fmt(st.max)} (${st.n} rows)`);
1112
+ }
1113
+ });
1114
+ }
1115
+ renderScatter(box, bubble) {
1116
+ const o = this.options;
1117
+ const xKey = o.xKey ?? o.series[0]?.key;
1118
+ const yKey = o.yKey ?? o.series[1]?.key ?? o.series[0]?.key;
1119
+ const sKey = o.sizeKey ?? o.series[2]?.key;
1120
+ const xs = o.data.map((d) => toNumber(d[xKey]));
1121
+ const ys = o.data.map((d) => toNumber(d[yKey]));
1122
+ const ss = o.data.map((d) => (sKey ? Math.abs(toNumber(d[sKey])) : 1));
1123
+ const xt = niceTicks(Math.min(...xs), Math.max(...xs), 5);
1124
+ const yt = niceTicks(Math.min(...ys), Math.max(...ys), 5);
1125
+ const m = { top: 14, right: 18, bottom: 40, left: Math.max(...yt.map((t) => this.fmt(t).length)) * 7 + 16 };
1126
+ const pw = box.w - m.left - m.right;
1127
+ const ph = box.h - m.top - m.bottom;
1128
+ const g = svg('g', { transform: `translate(${m.left},${m.top})` }, this.svgEl);
1129
+ const sx = (v) => ((v - xt[0]) / (xt[xt.length - 1] - xt[0] || 1)) * pw;
1130
+ const sy = (v) => ph - ((v - yt[0]) / (yt[yt.length - 1] - yt[0] || 1)) * ph;
1131
+ for (const t of yt) {
1132
+ svg('line', { x1: 0, x2: pw, y1: sy(t), y2: sy(t), class: 'agc-grid' }, g);
1133
+ this.text(g, -8, sy(t) + 4, this.fmt(t), { 'text-anchor': 'end', class: 'agc-axis-text' });
1134
+ }
1135
+ for (const t of xt) {
1136
+ svg('line', { x1: sx(t), x2: sx(t), y1: 0, y2: ph, class: 'agc-grid' }, g);
1137
+ this.text(g, sx(t), ph + 16, this.fmt(t), { 'text-anchor': 'middle', class: 'agc-axis-text' });
1138
+ }
1139
+ const name = (k) => o.series.find((s) => s.key === k)?.name ?? k;
1140
+ this.text(g, pw / 2, ph + 34, name(xKey), { 'text-anchor': 'middle', class: 'agc-axis-title' });
1141
+ const smax = Math.max(...ss, 1);
1142
+ o.data.forEach((d, i) => {
1143
+ const r = bubble ? 4 + Math.sqrt(ss[i] / smax) * 18 : 4.5;
1144
+ const c = svg('circle', { cx: sx(xs[i]), cy: sy(ys[i]), r, class: 'agc-scatter-dot' }, g);
1145
+ c.style.fill = this.color(o.series[0]);
1146
+ const cat = String(d[o.categoryKey] ?? '');
1147
+ this.bindMark(c, { category: cat, seriesKey: yKey, value: ys[i], datum: d }, `${cat}: ${name(xKey)} ${this.fmt(xs[i])}, ${name(yKey)} ${this.fmt(ys[i])}${bubble && sKey ? `, ${name(sKey)} ${this.fmt(ss[i])}` : ''}`);
1148
+ });
1149
+ if (o.trendline && xs.length > 2) {
1150
+ const reg = linearRegression(ys, xs);
1151
+ svg('line', { x1: sx(xt[0]), y1: sy(reg.at(xt[0])), x2: sx(xt[xt.length - 1]), y2: sy(reg.at(xt[xt.length - 1])), class: 'agc-trend' }, g);
1152
+ }
1153
+ }
1154
+ }
1155
+ export function createChart(container, options) {
1156
+ return new Chart(container, options);
1157
+ }
1158
+ //# sourceMappingURL=chart.js.map