@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/LICENSE.md +24 -0
- package/README.md +6 -2
- package/dist/chart.d.ts +146 -0
- package/dist/chart.js +1158 -0
- package/dist/chart.js.map +1 -0
- package/dist/icons.d.ts +2 -0
- package/dist/icons.js +54 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +105 -0
- package/dist/index.js.map +1 -0
- package/dist/integrated.d.ts +78 -0
- package/dist/integrated.js +563 -0
- package/dist/integrated.js.map +1 -0
- package/dist/smart.d.ts +17 -0
- package/dist/smart.js +121 -0
- package/dist/smart.js.map +1 -0
- package/dist/sparkline.d.ts +14 -0
- package/dist/sparkline.js +61 -0
- package/dist/sparkline.js.map +1 -0
- package/dist/studio.d.ts +50 -0
- package/dist/studio.js +271 -0
- package/dist/studio.js.map +1 -0
- package/dist/styles.css +221 -0
- package/dist/transform.d.ts +62 -0
- package/dist/transform.js +274 -0
- package/dist/transform.js.map +1 -0
- package/package.json +37 -3
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
|