@aeriz/chart 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +488 -0
- package/dist/aeriz.cjs +2609 -0
- package/dist/aeriz.esm.js +2597 -0
- package/dist/aeriz.esm.min.js +1 -0
- package/dist/aeriz.js +2609 -0
- package/dist/aeriz.min.cjs +1 -0
- package/dist/aeriz.min.js +1 -0
- package/package.json +89 -0
- package/src/base.js +562 -0
- package/src/charts.js +1365 -0
- package/src/composite.js +187 -0
- package/src/engine.js +287 -0
- package/src/index.d.ts +517 -0
- package/src/index.js +18 -0
- package/src/math.js +178 -0
package/src/charts.js
ADDED
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
// charts.js — Concrete chart implementations.
|
|
2
|
+
// Each subclass implements _render(). All share Chart's lifecycle, hit
|
|
3
|
+
// testing, tooltip, theme, and animation infrastructure.
|
|
4
|
+
|
|
5
|
+
import { Chart, scheduler } from './base.js';
|
|
6
|
+
import * as M from './math.js';
|
|
7
|
+
import { TAU } from './math.js';
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────
|
|
10
|
+
// 1. BAR (vertical)
|
|
11
|
+
// ─────────────────────────────────────────────────────────
|
|
12
|
+
export class BarChart extends Chart {
|
|
13
|
+
_render() {
|
|
14
|
+
const data = this.options.data || [];
|
|
15
|
+
if (data.length === 0) { this.setEmpty('No data'); return; }
|
|
16
|
+
const e = this.engine;
|
|
17
|
+
const t = this.theme;
|
|
18
|
+
const labelOf = this.options.label || (d => d.label);
|
|
19
|
+
const valueOf = this.options.value || (d => d.value);
|
|
20
|
+
|
|
21
|
+
const values = data.map((d, i) => valueOf(d, i));
|
|
22
|
+
const yMin = Math.min(0, ...values);
|
|
23
|
+
const yMax = Math.max(...values);
|
|
24
|
+
const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
|
|
25
|
+
|
|
26
|
+
const ax = this._drawCartesian({
|
|
27
|
+
yMin: yLo, yMax: yHi,
|
|
28
|
+
xLabels: data.map(labelOf),
|
|
29
|
+
yTickFormat: this.options.yTickFormat,
|
|
30
|
+
xTickFormat: this.options.xTickFormat,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const slot = ax.pw / data.length;
|
|
34
|
+
const barW = Math.max(2, Math.min(60, slot - 8));
|
|
35
|
+
const prog = this._anim.progress;
|
|
36
|
+
const radius = t.barRadius;
|
|
37
|
+
|
|
38
|
+
// Group by color for batching.
|
|
39
|
+
const groups = new Map();
|
|
40
|
+
const fmt = this.options.dataLabels;
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < data.length; i++) {
|
|
43
|
+
const v = values[i];
|
|
44
|
+
const x = ax.px + (i + 0.5) * slot - barW / 2;
|
|
45
|
+
const y0 = ax.yToPx(0);
|
|
46
|
+
const y1 = ax.yToPx(v);
|
|
47
|
+
const top = Math.min(y0, y1);
|
|
48
|
+
const h = Math.abs(y1 - y0) * prog;
|
|
49
|
+
const animTop = v >= 0 ? y0 - h : y0;
|
|
50
|
+
const color = this._colorOf(i, data[i]);
|
|
51
|
+
if (!groups.has(color)) groups.set(color, []);
|
|
52
|
+
groups.get(color).push({ x, y: animTop, w: barW, h, radius });
|
|
53
|
+
|
|
54
|
+
this.hits.add({
|
|
55
|
+
_id: i, x, y: Math.min(animTop, animTop + h), w: barW,
|
|
56
|
+
h: Math.abs(h),
|
|
57
|
+
tipX: x + barW / 2, tipY: animTop,
|
|
58
|
+
tooltip: `${labelOf(data[i], i)}: ${v}`,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (fmt && prog > 0.95) {
|
|
62
|
+
const txt = typeof fmt === 'function' ? fmt(data[i], i) : String(v);
|
|
63
|
+
e.text(txt, x + barW / 2, animTop - 4, {
|
|
64
|
+
fill: t.text, font: `${t.fontSize}px ${t.font}`,
|
|
65
|
+
align: 'center', baseline: 'bottom',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const [color, items] of groups) e.rects(items, color);
|
|
71
|
+
|
|
72
|
+
if (this._hover) {
|
|
73
|
+
const r = this._hover;
|
|
74
|
+
e.rect(r.x, r.y, r.w, r.h, { stroke: t.text, lineWidth: 1.5 });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────
|
|
80
|
+
// 2. HORIZONTAL BAR
|
|
81
|
+
// ─────────────────────────────────────────────────────────
|
|
82
|
+
export class HBarChart extends Chart {
|
|
83
|
+
_axisPad() {
|
|
84
|
+
return { l: 80, r: 16, t: 16, b: 32 };
|
|
85
|
+
}
|
|
86
|
+
_render() {
|
|
87
|
+
const data = this.options.data || [];
|
|
88
|
+
if (data.length === 0) { this.setEmpty('No data'); return; }
|
|
89
|
+
const e = this.engine;
|
|
90
|
+
const t = this.theme;
|
|
91
|
+
const labelOf = this.options.label || (d => d.label);
|
|
92
|
+
const valueOf = this.options.value || (d => d.value);
|
|
93
|
+
const values = data.map((d, i) => valueOf(d, i));
|
|
94
|
+
const xMin = Math.min(0, ...values);
|
|
95
|
+
const xMax = Math.max(...values);
|
|
96
|
+
const [xLo, xHi] = M.niceDomain(xMin, xMax, 5);
|
|
97
|
+
|
|
98
|
+
const pad = this._axisPad();
|
|
99
|
+
const px = pad.l, py = pad.t;
|
|
100
|
+
const pw = e.w - pad.l - pad.r;
|
|
101
|
+
const ph = e.h - pad.t - pad.b;
|
|
102
|
+
const font = `${t.fontSize}px ${t.font}`;
|
|
103
|
+
|
|
104
|
+
// X grid + labels
|
|
105
|
+
const xTicks = M.niceTicks(xLo, xHi, 5);
|
|
106
|
+
const xToPx = v => px + pw * (v - xLo) / (xHi - xLo);
|
|
107
|
+
e.ctx.strokeStyle = t.grid;
|
|
108
|
+
e.ctx.lineWidth = 1;
|
|
109
|
+
e.ctx.beginPath();
|
|
110
|
+
for (const v of xTicks) {
|
|
111
|
+
const x = Math.round(xToPx(v)) + 0.5;
|
|
112
|
+
e.ctx.moveTo(x, py); e.ctx.lineTo(x, py + ph);
|
|
113
|
+
}
|
|
114
|
+
e.ctx.stroke();
|
|
115
|
+
for (const v of xTicks) {
|
|
116
|
+
e.text(String(v), xToPx(v), py + ph + 6, {
|
|
117
|
+
fill: t.muted, font, align: 'center', baseline: 'top',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
e.poly([{x: px, y: py + ph}, {x: px + pw, y: py + ph}], { stroke: t.axis, lineWidth: 1 });
|
|
121
|
+
|
|
122
|
+
const slot = ph / data.length;
|
|
123
|
+
const barH = Math.max(2, Math.min(40, slot - 6));
|
|
124
|
+
const prog = this._anim.progress;
|
|
125
|
+
const groups = new Map();
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < data.length; i++) {
|
|
128
|
+
const v = values[i];
|
|
129
|
+
const yMid = py + (i + 0.5) * slot;
|
|
130
|
+
const y = yMid - barH / 2;
|
|
131
|
+
const x0 = xToPx(0);
|
|
132
|
+
const x1 = xToPx(v);
|
|
133
|
+
const start = Math.min(x0, x1);
|
|
134
|
+
const w = Math.abs(x1 - x0) * prog;
|
|
135
|
+
const animLeft = v >= 0 ? x0 : x0 - w;
|
|
136
|
+
const color = this._colorOf(i, data[i]);
|
|
137
|
+
if (!groups.has(color)) groups.set(color, []);
|
|
138
|
+
groups.get(color).push({ x: animLeft, y, w, h: barH, radius: t.barRadius });
|
|
139
|
+
|
|
140
|
+
this.hits.add({
|
|
141
|
+
_id: i, x: animLeft, y, w, h: barH,
|
|
142
|
+
tipX: animLeft + w / 2, tipY: y,
|
|
143
|
+
tooltip: `${labelOf(data[i], i)}: ${v}`,
|
|
144
|
+
});
|
|
145
|
+
// Y label
|
|
146
|
+
e.text(labelOf(data[i], i), px - 6, yMid, {
|
|
147
|
+
fill: t.muted, font, align: 'right', baseline: 'middle',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const [color, items] of groups) e.rects(items, color);
|
|
152
|
+
if (this._hover) {
|
|
153
|
+
const r = this._hover;
|
|
154
|
+
e.rect(r.x, r.y, r.w, r.h, { stroke: t.text, lineWidth: 1.5 });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─────────────────────────────────────────────────────────
|
|
160
|
+
// 3. LINE
|
|
161
|
+
// ─────────────────────────────────────────────────────────
|
|
162
|
+
export class LineChart extends Chart {
|
|
163
|
+
_render() {
|
|
164
|
+
const data = this.options.data || [];
|
|
165
|
+
if (data.length === 0) { this.setEmpty('No data'); return; }
|
|
166
|
+
const e = this.engine;
|
|
167
|
+
const t = this.theme;
|
|
168
|
+
const labelOf = this.options.label || (d => d.label);
|
|
169
|
+
const valueOf = this.options.value || (d => d.value);
|
|
170
|
+
const values = data.map((d, i) => valueOf(d, i));
|
|
171
|
+
const yMin = Math.min(0, ...values);
|
|
172
|
+
const yMax = Math.max(...values);
|
|
173
|
+
const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
|
|
174
|
+
const useTime = this.options.xScale === 'time';
|
|
175
|
+
let spec = {
|
|
176
|
+
yMin: yLo, yMax: yHi,
|
|
177
|
+
yScale: this.options.yScale,
|
|
178
|
+
yTickFormat: this.options.yTickFormat,
|
|
179
|
+
xTickFormat: this.options.xTickFormat,
|
|
180
|
+
};
|
|
181
|
+
if (useTime) {
|
|
182
|
+
const xs = data.map((d, i) => +labelOf(d, i));
|
|
183
|
+
spec.xTime = { min: xs[0], max: xs[xs.length - 1] };
|
|
184
|
+
} else {
|
|
185
|
+
spec.xLabels = data.map(labelOf);
|
|
186
|
+
}
|
|
187
|
+
const ax = this._drawCartesian(spec);
|
|
188
|
+
|
|
189
|
+
const color = this.options.color || this.palette[0];
|
|
190
|
+
const interp = this.options.interpolation || 'smooth';
|
|
191
|
+
const lineW = t.lineWidth;
|
|
192
|
+
const prog = this._anim.progress;
|
|
193
|
+
|
|
194
|
+
// Decimation
|
|
195
|
+
let renderData = data;
|
|
196
|
+
if (M.shouldDecimate(data.length, ax.pw)) {
|
|
197
|
+
renderData = M.minMaxDecimate(data, Math.floor(ax.pw), valueOf);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const points = renderData.map((d, i) => {
|
|
201
|
+
const xv = useTime ? +labelOf(d, i) : i;
|
|
202
|
+
const idx = useTime ? null : i;
|
|
203
|
+
// For category x scale, map by index in renderData.
|
|
204
|
+
const x = useTime
|
|
205
|
+
? ax.xToPx(xv)
|
|
206
|
+
: ax.px + ((idx + 0.5) / renderData.length) * ax.pw;
|
|
207
|
+
const y = ax.yToPx(valueOf(d, i));
|
|
208
|
+
return { x, y, d, i };
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Reveal: clip horizontally based on progress.
|
|
212
|
+
e.clip(ax.px, ax.py, ax.pw * prog, ax.ph);
|
|
213
|
+
|
|
214
|
+
// Area fill (smooth + linear)
|
|
215
|
+
if (interp === 'smooth' || interp === 'linear') {
|
|
216
|
+
const cps = interp === 'smooth' ? M.bezierControlPoints(points) : null;
|
|
217
|
+
const grad = e.vGradient(`area-${color}`, ax.px, ax.py, ax.py + ax.ph, [
|
|
218
|
+
[0, M.colorAlpha(color, 0.25)],
|
|
219
|
+
[1, M.colorAlpha(color, 0)],
|
|
220
|
+
]);
|
|
221
|
+
if (cps) e.smoothArea(points, cps, ax.py + ax.ph, grad);
|
|
222
|
+
else {
|
|
223
|
+
const areaPts = points.concat([
|
|
224
|
+
{ x: points[points.length - 1].x, y: ax.py + ax.ph },
|
|
225
|
+
{ x: points[0].x, y: ax.py + ax.ph },
|
|
226
|
+
]);
|
|
227
|
+
e.poly(areaPts, { fill: grad });
|
|
228
|
+
}
|
|
229
|
+
if (cps) e.smooth(points, cps, { stroke: color, lineWidth: lineW });
|
|
230
|
+
else e.poly(points, { stroke: color, lineWidth: lineW, cap: 'round', join: 'round' });
|
|
231
|
+
} else if (interp === 'step' || interp.startsWith('step-')) {
|
|
232
|
+
const mode = interp === 'step' ? 'before' : interp.split('-')[1];
|
|
233
|
+
e.step(points, mode, { stroke: color, lineWidth: lineW });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
e.unclip();
|
|
237
|
+
|
|
238
|
+
// Points + hit regions (only when not decimated)
|
|
239
|
+
if (renderData === data && data.length <= 50) {
|
|
240
|
+
const pr = t.pointRadius;
|
|
241
|
+
e.circles(points, color, pr);
|
|
242
|
+
for (const p of points) {
|
|
243
|
+
this.hits.add({
|
|
244
|
+
_id: p.i, x: p.x - 8, y: p.y - 8, w: 16, h: 16,
|
|
245
|
+
tipX: p.x, tipY: p.y - pr,
|
|
246
|
+
tooltip: `${labelOf(p.d, p.i)}: ${valueOf(p.d, p.i)}`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
// Add invisible hit columns for decimated data.
|
|
251
|
+
const colW = ax.pw / renderData.length;
|
|
252
|
+
for (let i = 0; i < renderData.length; i++) {
|
|
253
|
+
const p = points[i];
|
|
254
|
+
this.hits.add({
|
|
255
|
+
_id: p.i, x: p.x - colW / 2, y: ax.py, w: colW, h: ax.ph,
|
|
256
|
+
tipX: p.x, tipY: p.y - 8,
|
|
257
|
+
tooltip: `${labelOf(p.d, p.i)}: ${valueOf(p.d, p.i)}`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (this._hover) {
|
|
263
|
+
const p = points.find(pp => pp.i === this._hover._id);
|
|
264
|
+
if (p) e.circle(p.x, p.y, t.pointRadius + 3, {
|
|
265
|
+
stroke: color, lineWidth: 2, fill: t.bg,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─────────────────────────────────────────────────────────
|
|
272
|
+
// 4. AREA (line with always-filled area, supports stacked)
|
|
273
|
+
// ─────────────────────────────────────────────────────────
|
|
274
|
+
export class AreaChart extends Chart {
|
|
275
|
+
_render() {
|
|
276
|
+
const series = this.options.series || [];
|
|
277
|
+
const cats = this.options.categories || [];
|
|
278
|
+
if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
|
|
279
|
+
const e = this.engine;
|
|
280
|
+
const t = this.theme;
|
|
281
|
+
const stacked = this.options.stacked === true;
|
|
282
|
+
|
|
283
|
+
// Compute y domain.
|
|
284
|
+
let yMax;
|
|
285
|
+
if (stacked) {
|
|
286
|
+
yMax = 0;
|
|
287
|
+
for (let i = 0; i < cats.length; i++) {
|
|
288
|
+
let sum = 0;
|
|
289
|
+
for (const s of series) sum += +s.values[i] || 0;
|
|
290
|
+
if (sum > yMax) yMax = sum;
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
yMax = -Infinity;
|
|
294
|
+
for (const s of series) for (const v of s.values) if (+v > yMax) yMax = +v;
|
|
295
|
+
}
|
|
296
|
+
const [yLo, yHi] = M.niceDomain(0, yMax, 5);
|
|
297
|
+
const ax = this._drawCartesian({
|
|
298
|
+
yMin: yLo, yMax: yHi,
|
|
299
|
+
xLabels: cats,
|
|
300
|
+
yTickFormat: this.options.yTickFormat,
|
|
301
|
+
});
|
|
302
|
+
const lineW = t.lineWidth;
|
|
303
|
+
const prog = this._anim.progress;
|
|
304
|
+
|
|
305
|
+
e.clip(ax.px, ax.py, ax.pw * prog, ax.ph);
|
|
306
|
+
|
|
307
|
+
if (stacked) {
|
|
308
|
+
// Build cumulative bands bottom-up.
|
|
309
|
+
const cum = new Array(cats.length).fill(0);
|
|
310
|
+
const seriesPts = [];
|
|
311
|
+
for (let s = 0; s < series.length; s++) {
|
|
312
|
+
const ser = series[s];
|
|
313
|
+
const ptsTop = [], ptsBot = [];
|
|
314
|
+
for (let i = 0; i < cats.length; i++) {
|
|
315
|
+
const v = +ser.values[i] || 0;
|
|
316
|
+
const x = ax.px + ((i + 0.5) / cats.length) * ax.pw;
|
|
317
|
+
const yBot = ax.yToPx(cum[i]);
|
|
318
|
+
const yTop = ax.yToPx(cum[i] + v);
|
|
319
|
+
ptsTop.push({ x, y: yTop });
|
|
320
|
+
ptsBot.push({ x, y: yBot });
|
|
321
|
+
cum[i] += v;
|
|
322
|
+
}
|
|
323
|
+
const color = this._colorOf(s, ser);
|
|
324
|
+
const cps = M.bezierControlPoints(ptsTop);
|
|
325
|
+
// Area: bezier top + linear bottom (reversed)
|
|
326
|
+
const ctx = e.ctx;
|
|
327
|
+
ctx.beginPath();
|
|
328
|
+
ctx.moveTo(ptsTop[0].x, ptsTop[0].y);
|
|
329
|
+
for (let i = 0; i < cps.length; i++) {
|
|
330
|
+
ctx.bezierCurveTo(cps[i].c1x, cps[i].c1y, cps[i].c2x, cps[i].c2y, ptsTop[i+1].x, ptsTop[i+1].y);
|
|
331
|
+
}
|
|
332
|
+
for (let i = ptsBot.length - 1; i >= 0; i--) ctx.lineTo(ptsBot[i].x, ptsBot[i].y);
|
|
333
|
+
ctx.closePath();
|
|
334
|
+
ctx.fillStyle = M.colorAlpha(color, 0.7);
|
|
335
|
+
ctx.fill();
|
|
336
|
+
ctx.strokeStyle = color;
|
|
337
|
+
ctx.lineWidth = lineW;
|
|
338
|
+
ctx.stroke();
|
|
339
|
+
seriesPts.push({ ser, color, ptsTop });
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
for (let s = 0; s < series.length; s++) {
|
|
343
|
+
const ser = series[s];
|
|
344
|
+
const pts = [];
|
|
345
|
+
for (let i = 0; i < cats.length; i++) {
|
|
346
|
+
pts.push({
|
|
347
|
+
x: ax.px + ((i + 0.5) / cats.length) * ax.pw,
|
|
348
|
+
y: ax.yToPx(+ser.values[i] || 0),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
const color = this._colorOf(s, ser);
|
|
352
|
+
const cps = M.bezierControlPoints(pts);
|
|
353
|
+
const grad = e.vGradient(`area-${s}-${color}`, ax.px, ax.py, ax.py + ax.ph, [
|
|
354
|
+
[0, M.colorAlpha(color, 0.4)],
|
|
355
|
+
[1, M.colorAlpha(color, 0)],
|
|
356
|
+
]);
|
|
357
|
+
e.smoothArea(pts, cps, ax.py + ax.ph, grad);
|
|
358
|
+
e.smooth(pts, cps, { stroke: color, lineWidth: lineW });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
e.unclip();
|
|
363
|
+
this._drawLegend(series.map((s, i) => ({
|
|
364
|
+
label: s.name || 'Series ' + (i + 1),
|
|
365
|
+
color: this._colorOf(i, s), idx: i, hidden: false,
|
|
366
|
+
})), ax.pw);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ─────────────────────────────────────────────────────────
|
|
371
|
+
// 5. STACKED BAR
|
|
372
|
+
// ─────────────────────────────────────────────────────────
|
|
373
|
+
export class StackedBarChart extends Chart {
|
|
374
|
+
_render() {
|
|
375
|
+
const series = this.options.series || [];
|
|
376
|
+
const cats = this.options.categories || [];
|
|
377
|
+
if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
|
|
378
|
+
const e = this.engine;
|
|
379
|
+
const t = this.theme;
|
|
380
|
+
|
|
381
|
+
const totals = cats.map((_, i) => series.reduce((a, s) => a + (+s.values[i] || 0), 0));
|
|
382
|
+
const [yLo, yHi] = M.niceDomain(0, Math.max(...totals), 5);
|
|
383
|
+
const ax = this._drawCartesian({
|
|
384
|
+
yMin: yLo, yMax: yHi,
|
|
385
|
+
xLabels: cats,
|
|
386
|
+
yTickFormat: this.options.yTickFormat,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const slot = ax.pw / cats.length;
|
|
390
|
+
const barW = Math.max(2, Math.min(50, slot - 10));
|
|
391
|
+
const prog = this._anim.progress;
|
|
392
|
+
const groups = new Map();
|
|
393
|
+
|
|
394
|
+
for (let i = 0; i < cats.length; i++) {
|
|
395
|
+
let cum = 0;
|
|
396
|
+
const x = ax.px + (i + 0.5) * slot - barW / 2;
|
|
397
|
+
for (let s = 0; s < series.length; s++) {
|
|
398
|
+
const v = +series[s].values[i] || 0;
|
|
399
|
+
if (v <= 0) continue;
|
|
400
|
+
const segH = ((v / yHi) * ax.ph) * prog;
|
|
401
|
+
const y = ax.yToPx(cum + v);
|
|
402
|
+
cum += v;
|
|
403
|
+
const color = this._colorOf(s, series[s]);
|
|
404
|
+
if (!groups.has(color)) groups.set(color, []);
|
|
405
|
+
const isTop = s === series.length - 1;
|
|
406
|
+
groups.get(color).push({
|
|
407
|
+
x, y, w: barW, h: segH,
|
|
408
|
+
radius: isTop ? t.barRadius : 0,
|
|
409
|
+
});
|
|
410
|
+
this.hits.add({
|
|
411
|
+
_id: `${i}-${s}`, x, y, w: barW, h: segH,
|
|
412
|
+
tipX: x + barW / 2, tipY: y,
|
|
413
|
+
tooltip: {
|
|
414
|
+
title: cats[i],
|
|
415
|
+
rows: [{
|
|
416
|
+
color, name: series[s].name || 'Series ' + (s + 1), value: v,
|
|
417
|
+
}],
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const [color, items] of groups) e.rects(items, color);
|
|
423
|
+
|
|
424
|
+
this._drawLegend(series.map((s, i) => ({
|
|
425
|
+
label: s.name || 'Series ' + (i + 1),
|
|
426
|
+
color: this._colorOf(i, s), idx: i, hidden: false,
|
|
427
|
+
})), ax.pw);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ─────────────────────────────────────────────────────────
|
|
432
|
+
// 6. GROUPED BAR (clustered)
|
|
433
|
+
// ─────────────────────────────────────────────────────────
|
|
434
|
+
export class GroupedBarChart extends Chart {
|
|
435
|
+
_render() {
|
|
436
|
+
const series = this.options.series || [];
|
|
437
|
+
const cats = this.options.categories || [];
|
|
438
|
+
if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
|
|
439
|
+
const e = this.engine;
|
|
440
|
+
const t = this.theme;
|
|
441
|
+
|
|
442
|
+
let yMax = -Infinity;
|
|
443
|
+
for (const s of series) for (const v of s.values) if (+v > yMax) yMax = +v;
|
|
444
|
+
const [yLo, yHi] = M.niceDomain(0, yMax, 5);
|
|
445
|
+
const ax = this._drawCartesian({
|
|
446
|
+
yMin: yLo, yMax: yHi,
|
|
447
|
+
xLabels: cats,
|
|
448
|
+
yTickFormat: this.options.yTickFormat,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const slot = ax.pw / cats.length;
|
|
452
|
+
const inner = slot * 0.8;
|
|
453
|
+
const barW = Math.max(2, inner / series.length);
|
|
454
|
+
const prog = this._anim.progress;
|
|
455
|
+
const groups = new Map();
|
|
456
|
+
|
|
457
|
+
for (let i = 0; i < cats.length; i++) {
|
|
458
|
+
const xCenter = ax.px + (i + 0.5) * slot;
|
|
459
|
+
const left = xCenter - inner / 2;
|
|
460
|
+
for (let s = 0; s < series.length; s++) {
|
|
461
|
+
const v = +series[s].values[i] || 0;
|
|
462
|
+
const x = left + s * barW;
|
|
463
|
+
const h = ((v / yHi) * ax.ph) * prog;
|
|
464
|
+
const y = ax.yToPx(v);
|
|
465
|
+
const color = this._colorOf(s, series[s]);
|
|
466
|
+
if (!groups.has(color)) groups.set(color, []);
|
|
467
|
+
groups.get(color).push({ x, y, w: barW - 1, h, radius: t.barRadius });
|
|
468
|
+
this.hits.add({
|
|
469
|
+
_id: `${i}-${s}`, x, y, w: barW - 1, h,
|
|
470
|
+
tipX: x + barW / 2, tipY: y,
|
|
471
|
+
tooltip: `${cats[i]} · ${series[s].name || s}: ${v}`,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
for (const [color, items] of groups) e.rects(items, color);
|
|
476
|
+
|
|
477
|
+
this._drawLegend(series.map((s, i) => ({
|
|
478
|
+
label: s.name || 'Series ' + (i + 1),
|
|
479
|
+
color: this._colorOf(i, s), idx: i, hidden: false,
|
|
480
|
+
})), ax.pw);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ─────────────────────────────────────────────────────────
|
|
485
|
+
// 7. MULTI-LINE (multiple series, shared x)
|
|
486
|
+
// ─────────────────────────────────────────────────────────
|
|
487
|
+
export class MultiLineChart extends Chart {
|
|
488
|
+
_render() {
|
|
489
|
+
const series = this.options.series || [];
|
|
490
|
+
const cats = this.options.categories || [];
|
|
491
|
+
if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
|
|
492
|
+
const e = this.engine;
|
|
493
|
+
const t = this.theme;
|
|
494
|
+
const interp = this.options.interpolation || 'smooth';
|
|
495
|
+
|
|
496
|
+
let yMax = -Infinity, yMin = Infinity;
|
|
497
|
+
for (const s of series) for (const v of s.values) {
|
|
498
|
+
if (+v > yMax) yMax = +v;
|
|
499
|
+
if (+v < yMin) yMin = +v;
|
|
500
|
+
}
|
|
501
|
+
const [yLo, yHi] = M.niceDomain(Math.min(0, yMin), yMax, 5);
|
|
502
|
+
const useTime = this.options.xScale === 'time';
|
|
503
|
+
let spec = {
|
|
504
|
+
yMin: yLo, yMax: yHi,
|
|
505
|
+
yTickFormat: this.options.yTickFormat,
|
|
506
|
+
xTickFormat: this.options.xTickFormat,
|
|
507
|
+
};
|
|
508
|
+
if (useTime) {
|
|
509
|
+
spec.xTime = { min: +cats[0], max: +cats[cats.length - 1] };
|
|
510
|
+
} else {
|
|
511
|
+
spec.xLabels = cats;
|
|
512
|
+
}
|
|
513
|
+
const ax = this._drawCartesian(spec);
|
|
514
|
+
const lineW = t.lineWidth;
|
|
515
|
+
const prog = this._anim.progress;
|
|
516
|
+
|
|
517
|
+
e.clip(ax.px, ax.py, ax.pw * prog, ax.ph);
|
|
518
|
+
const allPts = [];
|
|
519
|
+
for (let s = 0; s < series.length; s++) {
|
|
520
|
+
const ser = series[s];
|
|
521
|
+
if (ser.hidden) continue;
|
|
522
|
+
const color = this._colorOf(s, ser);
|
|
523
|
+
const pts = ser.values.map((v, i) => ({
|
|
524
|
+
x: useTime ? ax.xToPx(+cats[i]) : ax.px + ((i + 0.5) / cats.length) * ax.pw,
|
|
525
|
+
y: ax.yToPx(+v),
|
|
526
|
+
i, s, v,
|
|
527
|
+
}));
|
|
528
|
+
allPts.push({ pts, color, s, ser });
|
|
529
|
+
if (interp === 'smooth') {
|
|
530
|
+
const cps = M.bezierControlPoints(pts);
|
|
531
|
+
e.smooth(pts, cps, { stroke: color, lineWidth: lineW });
|
|
532
|
+
} else if (interp === 'linear') {
|
|
533
|
+
e.poly(pts, { stroke: color, lineWidth: lineW, cap: 'round', join: 'round' });
|
|
534
|
+
} else {
|
|
535
|
+
const mode = interp === 'step' ? 'before' : interp.split('-')[1];
|
|
536
|
+
e.step(pts, mode, { stroke: color, lineWidth: lineW });
|
|
537
|
+
}
|
|
538
|
+
if (ser.values.length <= 50) e.circles(pts, color, t.pointRadius);
|
|
539
|
+
}
|
|
540
|
+
e.unclip();
|
|
541
|
+
|
|
542
|
+
// Shared crosshair tooltip
|
|
543
|
+
if (this._cursor && allPts.length) {
|
|
544
|
+
const cx = this._cursor.x;
|
|
545
|
+
if (cx >= ax.px && cx <= ax.px + ax.pw) {
|
|
546
|
+
// Find nearest category
|
|
547
|
+
const i = useTime
|
|
548
|
+
? Math.round(((cx - ax.px) / ax.pw) * (cats.length - 1))
|
|
549
|
+
: Math.floor((cx - ax.px) / (ax.pw / cats.length));
|
|
550
|
+
const idx = Math.max(0, Math.min(cats.length - 1, i));
|
|
551
|
+
const xPx = useTime
|
|
552
|
+
? ax.xToPx(+cats[idx])
|
|
553
|
+
: ax.px + (idx + 0.5) * (ax.pw / cats.length);
|
|
554
|
+
// Crosshair line
|
|
555
|
+
e.poly([{x: xPx, y: ax.py}, {x: xPx, y: ax.py + ax.ph}], {
|
|
556
|
+
stroke: t.muted, lineWidth: 1, dash: [3, 3],
|
|
557
|
+
});
|
|
558
|
+
// Snap markers
|
|
559
|
+
const rows = [];
|
|
560
|
+
for (const sp of allPts) {
|
|
561
|
+
const p = sp.pts[idx];
|
|
562
|
+
if (!p) continue;
|
|
563
|
+
e.circle(p.x, p.y, t.pointRadius + 2, {
|
|
564
|
+
fill: t.bg, stroke: sp.color, lineWidth: 2,
|
|
565
|
+
});
|
|
566
|
+
rows.push({
|
|
567
|
+
color: sp.color,
|
|
568
|
+
name: sp.ser.name || 'Series ' + (sp.s + 1),
|
|
569
|
+
value: sp.ser.values[idx],
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
const title = useTime
|
|
573
|
+
? new Date(+cats[idx]).toISOString().slice(0, 19).replace('T', ' ')
|
|
574
|
+
: String(cats[idx]);
|
|
575
|
+
this.tooltip.show(xPx, ax.py + 8, { title, rows }, t);
|
|
576
|
+
this._sharedTooltip = true;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
this._drawLegend(series.map((s, i) => ({
|
|
581
|
+
label: s.name || 'Series ' + (i + 1),
|
|
582
|
+
color: this._colorOf(i, s), idx: i, hidden: !!s.hidden,
|
|
583
|
+
})), ax.pw);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ─────────────────────────────────────────────────────────
|
|
588
|
+
// 8. SCATTER
|
|
589
|
+
// ─────────────────────────────────────────────────────────
|
|
590
|
+
export class ScatterChart extends Chart {
|
|
591
|
+
_render() {
|
|
592
|
+
const data = this.options.data || [];
|
|
593
|
+
if (!data.length) { this.setEmpty('No data'); return; }
|
|
594
|
+
const e = this.engine;
|
|
595
|
+
const t = this.theme;
|
|
596
|
+
let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
|
|
597
|
+
for (const d of data) {
|
|
598
|
+
if (d.x < xMin) xMin = d.x; if (d.x > xMax) xMax = d.x;
|
|
599
|
+
if (d.y < yMin) yMin = d.y; if (d.y > yMax) yMax = d.y;
|
|
600
|
+
}
|
|
601
|
+
const [xLo, xHi] = M.niceDomain(xMin, xMax, 6);
|
|
602
|
+
const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
|
|
603
|
+
const ax = this._drawCartesian({
|
|
604
|
+
yMin: yLo, yMax: yHi,
|
|
605
|
+
xLinear: { min: xLo, max: xHi },
|
|
606
|
+
});
|
|
607
|
+
const prog = this._anim.progress;
|
|
608
|
+
const groups = new Map();
|
|
609
|
+
const defaultR = t.pointRadius + 1;
|
|
610
|
+
|
|
611
|
+
for (let i = 0; i < data.length; i++) {
|
|
612
|
+
const d = data[i];
|
|
613
|
+
const cx = ax.xToPx(d.x);
|
|
614
|
+
const cy = ax.yToPx(d.y);
|
|
615
|
+
const r = (d.r || defaultR) * prog;
|
|
616
|
+
const g = d.group != null ? d.group : 0;
|
|
617
|
+
const color = this._colorOf(g, { color: d.color });
|
|
618
|
+
const key = color + ':' + r.toFixed(1);
|
|
619
|
+
if (!groups.has(key)) groups.set(key, { color, r, items: [] });
|
|
620
|
+
groups.get(key).items.push({ x: cx, y: cy });
|
|
621
|
+
this.hits.add({
|
|
622
|
+
_id: i, x: cx - r - 2, y: cy - r - 2, w: r * 2 + 4, h: r * 2 + 4,
|
|
623
|
+
tipX: cx, tipY: cy - r,
|
|
624
|
+
tooltip: `(${d.x}, ${d.y})${d.label ? ` · ${d.label}` : ''}`,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
e.withAlpha(0.7, () => {
|
|
628
|
+
for (const { color, r, items } of groups.values()) e.circles(items, color, r);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ─────────────────────────────────────────────────────────
|
|
634
|
+
// 9. BUBBLE (scatter with explicit z radius)
|
|
635
|
+
// ─────────────────────────────────────────────────────────
|
|
636
|
+
export class BubbleChart extends Chart {
|
|
637
|
+
_render() {
|
|
638
|
+
const data = this.options.data || [];
|
|
639
|
+
if (!data.length) { this.setEmpty('No data'); return; }
|
|
640
|
+
const e = this.engine;
|
|
641
|
+
const t = this.theme;
|
|
642
|
+
let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity, zMax = 0;
|
|
643
|
+
for (const d of data) {
|
|
644
|
+
if (d.x < xMin) xMin = d.x; if (d.x > xMax) xMax = d.x;
|
|
645
|
+
if (d.y < yMin) yMin = d.y; if (d.y > yMax) yMax = d.y;
|
|
646
|
+
if ((d.z || 0) > zMax) zMax = d.z;
|
|
647
|
+
}
|
|
648
|
+
const [xLo, xHi] = M.niceDomain(xMin, xMax, 6);
|
|
649
|
+
const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
|
|
650
|
+
const ax = this._drawCartesian({
|
|
651
|
+
yMin: yLo, yMax: yHi,
|
|
652
|
+
xLinear: { min: xLo, max: xHi },
|
|
653
|
+
});
|
|
654
|
+
const prog = this._anim.progress;
|
|
655
|
+
const maxR = 32, minR = 4;
|
|
656
|
+
|
|
657
|
+
for (let i = 0; i < data.length; i++) {
|
|
658
|
+
const d = data[i];
|
|
659
|
+
const cx = ax.xToPx(d.x);
|
|
660
|
+
const cy = ax.yToPx(d.y);
|
|
661
|
+
const r = (minR + (Math.sqrt((d.z || 0) / Math.max(1, zMax))) * (maxR - minR)) * prog;
|
|
662
|
+
const color = d.color || this._colorOf(d.group != null ? d.group : i);
|
|
663
|
+
e.circle(cx, cy, r, {
|
|
664
|
+
fill: M.colorAlpha(color, 0.5),
|
|
665
|
+
stroke: color, lineWidth: 1.5,
|
|
666
|
+
});
|
|
667
|
+
this.hits.add({
|
|
668
|
+
_id: i, x: cx - r, y: cy - r, w: r * 2, h: r * 2,
|
|
669
|
+
tipX: cx, tipY: cy - r,
|
|
670
|
+
tooltip: `${d.label || ''} (${d.x}, ${d.y}) · z=${d.z}`,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ─────────────────────────────────────────────────────────
|
|
677
|
+
// 10. PIE / DONUT
|
|
678
|
+
// ─────────────────────────────────────────────────────────
|
|
679
|
+
export class PieChart extends Chart {
|
|
680
|
+
_render() {
|
|
681
|
+
const data = this.options.data || [];
|
|
682
|
+
if (!data.length) { this.setEmpty('No data'); return; }
|
|
683
|
+
const e = this.engine;
|
|
684
|
+
const t = this.theme;
|
|
685
|
+
const labelOf = this.options.label || (d => d.label);
|
|
686
|
+
const valueOf = this.options.value || (d => d.value);
|
|
687
|
+
const total = data.reduce((a, d, i) => a + valueOf(d, i), 0);
|
|
688
|
+
if (total <= 0) { this.setEmpty('No data'); return; }
|
|
689
|
+
|
|
690
|
+
const cx = e.w / 2, cy = e.h / 2 + 4;
|
|
691
|
+
const R = Math.min(e.w, e.h) / 2 - 24;
|
|
692
|
+
const innerR = this.options.innerRadius != null
|
|
693
|
+
? (typeof this.options.innerRadius === 'number'
|
|
694
|
+
? this.options.innerRadius
|
|
695
|
+
: R * 0.55)
|
|
696
|
+
: 0;
|
|
697
|
+
const prog = this._anim.progress;
|
|
698
|
+
let a0 = -Math.PI / 2;
|
|
699
|
+
for (let i = 0; i < data.length; i++) {
|
|
700
|
+
const v = valueOf(data[i], i);
|
|
701
|
+
const sweep = (v / total) * TAU * prog;
|
|
702
|
+
const a1 = a0 + sweep;
|
|
703
|
+
const color = this._colorOf(i, data[i]);
|
|
704
|
+
const isHover = this._hover && this._hover._id === i;
|
|
705
|
+
const offset = isHover ? 6 : 0;
|
|
706
|
+
const mid = (a0 + a1) / 2;
|
|
707
|
+
const ox = Math.cos(mid) * offset, oy = Math.sin(mid) * offset;
|
|
708
|
+
e.arc(cx + ox, cy + oy, innerR, R, a0, a1, { fill: color });
|
|
709
|
+
// Hit region: approximate with bounding box around sector midpoint
|
|
710
|
+
const midR = (innerR + R) / 2;
|
|
711
|
+
const hx = cx + Math.cos(mid) * midR;
|
|
712
|
+
const hy = cy + Math.sin(mid) * midR;
|
|
713
|
+
this.hits.add({
|
|
714
|
+
_id: i, x: hx - 12, y: hy - 12, w: 24, h: 24,
|
|
715
|
+
tipX: hx, tipY: hy - 8,
|
|
716
|
+
tooltip: `${labelOf(data[i], i)}: ${v} (${(v / total * 100).toFixed(1)}%)`,
|
|
717
|
+
});
|
|
718
|
+
a0 = a1;
|
|
719
|
+
}
|
|
720
|
+
// Center label for donut
|
|
721
|
+
if (innerR > 0 && this.options.centerLabel !== false) {
|
|
722
|
+
const center = this._hover
|
|
723
|
+
? `${(valueOf(data[this._hover._id], this._hover._id) / total * 100).toFixed(0)}%`
|
|
724
|
+
: (this.options.totalLabel || total.toLocaleString());
|
|
725
|
+
e.text(center, cx, cy, {
|
|
726
|
+
fill: t.text, font: `600 ${t.fontSize * 1.6}px ${t.font}`,
|
|
727
|
+
align: 'center', baseline: 'middle',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ─────────────────────────────────────────────────────────
|
|
734
|
+
// 11. RADAR
|
|
735
|
+
// ─────────────────────────────────────────────────────────
|
|
736
|
+
export class RadarChart extends Chart {
|
|
737
|
+
_render() {
|
|
738
|
+
const series = this.options.series || [];
|
|
739
|
+
const axes = this.options.axes || [];
|
|
740
|
+
if (!series.length || !axes.length) { this.setEmpty('No data'); return; }
|
|
741
|
+
const e = this.engine;
|
|
742
|
+
const t = this.theme;
|
|
743
|
+
const cx = e.w / 2;
|
|
744
|
+
const cy = e.h / 2 + 4;
|
|
745
|
+
const R = Math.min(e.w, e.h) / 2 - 32;
|
|
746
|
+
const N = axes.length;
|
|
747
|
+
const angle = i => -Math.PI / 2 + (i / N) * TAU;
|
|
748
|
+
|
|
749
|
+
let max = -Infinity;
|
|
750
|
+
for (const s of series) for (const v of s.values) if (+v > max) max = +v;
|
|
751
|
+
const yLo = 0;
|
|
752
|
+
const niceMax = M.niceDomain(0, max, 5)[1];
|
|
753
|
+
|
|
754
|
+
// Concentric rings
|
|
755
|
+
const rings = M.niceTicks(0, niceMax, 4).filter(v => v > 0);
|
|
756
|
+
e.ctx.strokeStyle = t.grid;
|
|
757
|
+
e.ctx.lineWidth = 1;
|
|
758
|
+
for (const v of rings) {
|
|
759
|
+
const r = (v / niceMax) * R;
|
|
760
|
+
e.ctx.beginPath();
|
|
761
|
+
for (let i = 0; i <= N; i++) {
|
|
762
|
+
const a = angle(i % N);
|
|
763
|
+
const x = cx + Math.cos(a) * r;
|
|
764
|
+
const y = cy + Math.sin(a) * r;
|
|
765
|
+
if (i === 0) e.ctx.moveTo(x, y); else e.ctx.lineTo(x, y);
|
|
766
|
+
}
|
|
767
|
+
e.ctx.stroke();
|
|
768
|
+
}
|
|
769
|
+
// Spokes
|
|
770
|
+
e.ctx.strokeStyle = t.grid;
|
|
771
|
+
for (let i = 0; i < N; i++) {
|
|
772
|
+
const a = angle(i);
|
|
773
|
+
e.ctx.beginPath();
|
|
774
|
+
e.ctx.moveTo(cx, cy);
|
|
775
|
+
e.ctx.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
|
|
776
|
+
e.ctx.stroke();
|
|
777
|
+
}
|
|
778
|
+
// Axis labels
|
|
779
|
+
const font = `${t.fontSize}px ${t.font}`;
|
|
780
|
+
for (let i = 0; i < N; i++) {
|
|
781
|
+
const a = angle(i);
|
|
782
|
+
const lx = cx + Math.cos(a) * (R + 14);
|
|
783
|
+
const ly = cy + Math.sin(a) * (R + 14);
|
|
784
|
+
e.text(axes[i], lx, ly, {
|
|
785
|
+
fill: t.muted, font,
|
|
786
|
+
align: Math.cos(a) > 0.3 ? 'left' : Math.cos(a) < -0.3 ? 'right' : 'center',
|
|
787
|
+
baseline: Math.sin(a) > 0.3 ? 'top' : Math.sin(a) < -0.3 ? 'bottom' : 'middle',
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
const prog = this._anim.progress;
|
|
791
|
+
for (let s = 0; s < series.length; s++) {
|
|
792
|
+
const ser = series[s];
|
|
793
|
+
const color = this._colorOf(s, ser);
|
|
794
|
+
const pts = [];
|
|
795
|
+
for (let i = 0; i < N; i++) {
|
|
796
|
+
const v = (+ser.values[i] || 0) * prog;
|
|
797
|
+
const r = (v / niceMax) * R;
|
|
798
|
+
const a = angle(i);
|
|
799
|
+
pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
|
|
800
|
+
}
|
|
801
|
+
e.poly(pts, {
|
|
802
|
+
fill: M.colorAlpha(color, 0.25),
|
|
803
|
+
stroke: color, lineWidth: 2, close: true,
|
|
804
|
+
});
|
|
805
|
+
e.circles(pts, color, t.pointRadius);
|
|
806
|
+
}
|
|
807
|
+
this._drawLegend(series.map((s, i) => ({
|
|
808
|
+
label: s.name || 'Series ' + (i + 1),
|
|
809
|
+
color: this._colorOf(i, s), idx: i, hidden: false,
|
|
810
|
+
})), e.w - 16);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ─────────────────────────────────────────────────────────
|
|
815
|
+
// 12. POLAR (radial bars)
|
|
816
|
+
// ─────────────────────────────────────────────────────────
|
|
817
|
+
export class PolarChart extends Chart {
|
|
818
|
+
_render() {
|
|
819
|
+
const data = this.options.data || [];
|
|
820
|
+
if (!data.length) { this.setEmpty('No data'); return; }
|
|
821
|
+
const e = this.engine;
|
|
822
|
+
const t = this.theme;
|
|
823
|
+
const labelOf = this.options.label || (d => d.label);
|
|
824
|
+
const valueOf = this.options.value || (d => d.value);
|
|
825
|
+
const N = data.length;
|
|
826
|
+
const cx = e.w / 2, cy = e.h / 2 + 4;
|
|
827
|
+
const R = Math.min(e.w, e.h) / 2 - 24;
|
|
828
|
+
const max = Math.max(...data.map((d, i) => valueOf(d, i)));
|
|
829
|
+
const prog = this._anim.progress;
|
|
830
|
+
|
|
831
|
+
for (let i = 0; i < N; i++) {
|
|
832
|
+
const v = valueOf(data[i], i);
|
|
833
|
+
const a0 = -Math.PI / 2 + (i / N) * TAU;
|
|
834
|
+
const a1 = a0 + (TAU / N) * 0.92;
|
|
835
|
+
const r = (v / max) * R * prog;
|
|
836
|
+
const color = this._colorOf(i, data[i]);
|
|
837
|
+
e.arc(cx, cy, 0, r, a0, a1, { fill: color });
|
|
838
|
+
// Label
|
|
839
|
+
const mid = (a0 + a1) / 2;
|
|
840
|
+
const lx = cx + Math.cos(mid) * (R + 12);
|
|
841
|
+
const ly = cy + Math.sin(mid) * (R + 12);
|
|
842
|
+
e.text(labelOf(data[i], i), lx, ly, {
|
|
843
|
+
fill: t.muted, font: `${t.fontSize}px ${t.font}`,
|
|
844
|
+
align: 'center', baseline: 'middle',
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ─────────────────────────────────────────────────────────
|
|
851
|
+
// 13. GAUGE
|
|
852
|
+
// ─────────────────────────────────────────────────────────
|
|
853
|
+
export class GaugeChart extends Chart {
|
|
854
|
+
setValue(v) {
|
|
855
|
+
this.options.value = v;
|
|
856
|
+
this._anim.start = performance.now();
|
|
857
|
+
this._anim.progress = 0;
|
|
858
|
+
this._dirty = true;
|
|
859
|
+
scheduler.schedule(this);
|
|
860
|
+
return this;
|
|
861
|
+
}
|
|
862
|
+
_render() {
|
|
863
|
+
const e = this.engine;
|
|
864
|
+
const t = this.theme;
|
|
865
|
+
const val = this.options.value || 0;
|
|
866
|
+
const max = this.options.max || 100;
|
|
867
|
+
const cx = e.w / 2;
|
|
868
|
+
const cy = e.h * 0.7;
|
|
869
|
+
const R = Math.min(e.w / 2, e.h * 0.7) - 12;
|
|
870
|
+
const r = R - 18;
|
|
871
|
+
const a0 = Math.PI * 1.1;
|
|
872
|
+
const a1 = Math.PI * 1.9;
|
|
873
|
+
const span = a1 - a0;
|
|
874
|
+
const prog = this._anim.progress;
|
|
875
|
+
const color = this.options.color || this.palette[0];
|
|
876
|
+
// Track
|
|
877
|
+
e.arc(cx, cy, r, R, a0, a1, { fill: t.grid });
|
|
878
|
+
// Filled
|
|
879
|
+
const aEnd = a0 + span * (val / max) * prog;
|
|
880
|
+
if (aEnd > a0) e.arc(cx, cy, r, R, a0, aEnd, { fill: color });
|
|
881
|
+
// Number
|
|
882
|
+
const num = Math.round(val);
|
|
883
|
+
const unit = this.options.unit || '';
|
|
884
|
+
e.text(`${num}${unit}`, cx, cy, {
|
|
885
|
+
fill: t.text, font: `700 ${R / 2.5}px ${t.font}`,
|
|
886
|
+
align: 'center', baseline: 'middle',
|
|
887
|
+
});
|
|
888
|
+
if (this.options.label) {
|
|
889
|
+
e.text(this.options.label, cx, cy + R / 2.5, {
|
|
890
|
+
fill: t.muted, font: `${t.fontSize}px ${t.font}`,
|
|
891
|
+
align: 'center', baseline: 'top',
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ─────────────────────────────────────────────────────────
|
|
898
|
+
// 14. HEATMAP
|
|
899
|
+
// ─────────────────────────────────────────────────────────
|
|
900
|
+
export class HeatmapChart extends Chart {
|
|
901
|
+
_axisPad() { return { l: 60, r: 16, t: 16, b: 40 }; }
|
|
902
|
+
_render() {
|
|
903
|
+
const data = this.options.data || []; // [{ x, y, value }]
|
|
904
|
+
const xLabels = this.options.xLabels || [];
|
|
905
|
+
const yLabels = this.options.yLabels || [];
|
|
906
|
+
if (!data.length || !xLabels.length || !yLabels.length) {
|
|
907
|
+
this.setEmpty('No data'); return;
|
|
908
|
+
}
|
|
909
|
+
const e = this.engine;
|
|
910
|
+
const t = this.theme;
|
|
911
|
+
const pad = this._axisPad();
|
|
912
|
+
const px = pad.l, py = pad.t;
|
|
913
|
+
const pw = e.w - pad.l - pad.r;
|
|
914
|
+
const ph = e.h - pad.t - pad.b;
|
|
915
|
+
const font = `${t.fontSize}px ${t.font}`;
|
|
916
|
+
|
|
917
|
+
let vMax = -Infinity, vMin = Infinity;
|
|
918
|
+
for (const d of data) {
|
|
919
|
+
if (d.value > vMax) vMax = d.value;
|
|
920
|
+
if (d.value < vMin) vMin = d.value;
|
|
921
|
+
}
|
|
922
|
+
const cellW = pw / xLabels.length;
|
|
923
|
+
const cellH = ph / yLabels.length;
|
|
924
|
+
const lo = this.options.colorLow || M.colorAlpha(this.palette[0], 0.1);
|
|
925
|
+
const hi = this.options.colorHigh || this.palette[0];
|
|
926
|
+
const gap = 1;
|
|
927
|
+
const prog = this._anim.progress;
|
|
928
|
+
|
|
929
|
+
for (const d of data) {
|
|
930
|
+
const xi = typeof d.x === 'number' ? d.x : xLabels.indexOf(d.x);
|
|
931
|
+
const yi = typeof d.y === 'number' ? d.y : yLabels.indexOf(d.y);
|
|
932
|
+
if (xi < 0 || yi < 0) continue;
|
|
933
|
+
const x = px + xi * cellW;
|
|
934
|
+
const y = py + yi * cellH;
|
|
935
|
+
const tNorm = vMax > vMin ? (d.value - vMin) / (vMax - vMin) : 0.5;
|
|
936
|
+
const fill = M.colorMix(lo, hi, tNorm * prog);
|
|
937
|
+
e.rect(x + gap, y + gap, cellW - gap * 2, cellH - gap * 2, { fill, radius: 2 });
|
|
938
|
+
this.hits.add({
|
|
939
|
+
_id: `${xi}-${yi}`, x, y, w: cellW, h: cellH,
|
|
940
|
+
tipX: x + cellW / 2, tipY: y,
|
|
941
|
+
tooltip: `${xLabels[xi]} · ${yLabels[yi]}: ${d.value}`,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
// X labels
|
|
945
|
+
for (let i = 0; i < xLabels.length; i++) {
|
|
946
|
+
e.text(xLabels[i], px + (i + 0.5) * cellW, py + ph + 4, {
|
|
947
|
+
fill: t.muted, font, align: 'center', baseline: 'top',
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
// Y labels
|
|
951
|
+
for (let i = 0; i < yLabels.length; i++) {
|
|
952
|
+
e.text(yLabels[i], px - 6, py + (i + 0.5) * cellH, {
|
|
953
|
+
fill: t.muted, font, align: 'right', baseline: 'middle',
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ─────────────────────────────────────────────────────────
|
|
960
|
+
// 15. BOXPLOT
|
|
961
|
+
// ─────────────────────────────────────────────────────────
|
|
962
|
+
export class BoxplotChart extends Chart {
|
|
963
|
+
_render() {
|
|
964
|
+
const data = this.options.data || []; // [{ label, q1, median, q3, min, max, outliers? }]
|
|
965
|
+
if (!data.length) { this.setEmpty('No data'); return; }
|
|
966
|
+
const e = this.engine;
|
|
967
|
+
const t = this.theme;
|
|
968
|
+
let yMax = -Infinity, yMin = Infinity;
|
|
969
|
+
for (const d of data) {
|
|
970
|
+
if (d.max > yMax) yMax = d.max; if (d.min < yMin) yMin = d.min;
|
|
971
|
+
if (d.outliers) for (const o of d.outliers) {
|
|
972
|
+
if (o > yMax) yMax = o; if (o < yMin) yMin = o;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
|
|
976
|
+
const ax = this._drawCartesian({
|
|
977
|
+
yMin: yLo, yMax: yHi,
|
|
978
|
+
xLabels: data.map(d => d.label),
|
|
979
|
+
});
|
|
980
|
+
const slot = ax.pw / data.length;
|
|
981
|
+
const boxW = Math.min(40, slot * 0.5);
|
|
982
|
+
|
|
983
|
+
for (let i = 0; i < data.length; i++) {
|
|
984
|
+
const d = data[i];
|
|
985
|
+
const cx = ax.px + (i + 0.5) * slot;
|
|
986
|
+
const yMin = ax.yToPx(d.min);
|
|
987
|
+
const yMax = ax.yToPx(d.max);
|
|
988
|
+
const yQ1 = ax.yToPx(d.q1);
|
|
989
|
+
const yQ3 = ax.yToPx(d.q3);
|
|
990
|
+
const yMed = ax.yToPx(d.median);
|
|
991
|
+
const color = this._colorOf(i, d);
|
|
992
|
+
// Whiskers
|
|
993
|
+
e.poly([{x: cx, y: yMax}, {x: cx, y: yMin}], { stroke: color, lineWidth: 1 });
|
|
994
|
+
e.poly([{x: cx - boxW / 3, y: yMax}, {x: cx + boxW / 3, y: yMax}], { stroke: color, lineWidth: 1 });
|
|
995
|
+
e.poly([{x: cx - boxW / 3, y: yMin}, {x: cx + boxW / 3, y: yMin}], { stroke: color, lineWidth: 1 });
|
|
996
|
+
// Box
|
|
997
|
+
e.rect(cx - boxW / 2, yQ3, boxW, yQ1 - yQ3, {
|
|
998
|
+
fill: M.colorAlpha(color, 0.4),
|
|
999
|
+
stroke: color, lineWidth: 1.5,
|
|
1000
|
+
});
|
|
1001
|
+
// Median line
|
|
1002
|
+
e.poly([{x: cx - boxW / 2, y: yMed}, {x: cx + boxW / 2, y: yMed}], {
|
|
1003
|
+
stroke: color, lineWidth: 2,
|
|
1004
|
+
});
|
|
1005
|
+
// Outliers
|
|
1006
|
+
if (d.outliers) {
|
|
1007
|
+
e.circles(d.outliers.map(v => ({ x: cx, y: ax.yToPx(v) })), color, 2);
|
|
1008
|
+
}
|
|
1009
|
+
this.hits.add({
|
|
1010
|
+
_id: i, x: cx - boxW / 2, y: yQ3, w: boxW, h: yQ1 - yQ3,
|
|
1011
|
+
tipX: cx, tipY: yQ3,
|
|
1012
|
+
tooltip: {
|
|
1013
|
+
title: d.label,
|
|
1014
|
+
rows: [
|
|
1015
|
+
{ name: 'min', value: d.min },
|
|
1016
|
+
{ name: 'Q1', value: d.q1 },
|
|
1017
|
+
{ name: 'med', value: d.median },
|
|
1018
|
+
{ name: 'Q3', value: d.q3 },
|
|
1019
|
+
{ name: 'max', value: d.max },
|
|
1020
|
+
],
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ─────────────────────────────────────────────────────────
|
|
1028
|
+
// 16. FUNNEL
|
|
1029
|
+
// ─────────────────────────────────────────────────────────
|
|
1030
|
+
export class FunnelChart extends Chart {
|
|
1031
|
+
_render() {
|
|
1032
|
+
const data = this.options.data || [];
|
|
1033
|
+
if (!data.length) { this.setEmpty('No data'); return; }
|
|
1034
|
+
const e = this.engine;
|
|
1035
|
+
const t = this.theme;
|
|
1036
|
+
const labelOf = this.options.label || (d => d.label);
|
|
1037
|
+
const valueOf = this.options.value || (d => d.value);
|
|
1038
|
+
const N = data.length;
|
|
1039
|
+
const max = Math.max(...data.map((d, i) => valueOf(d, i)));
|
|
1040
|
+
const pad = 24;
|
|
1041
|
+
const w = e.w - pad * 2;
|
|
1042
|
+
const segH = (e.h - pad * 2) / N;
|
|
1043
|
+
const cx = e.w / 2;
|
|
1044
|
+
const prog = this._anim.progress;
|
|
1045
|
+
const font = `${t.fontSize}px ${t.font}`;
|
|
1046
|
+
|
|
1047
|
+
for (let i = 0; i < N; i++) {
|
|
1048
|
+
const v = valueOf(data[i], i);
|
|
1049
|
+
const wTop = (v / max) * w * prog;
|
|
1050
|
+
const wBot = i + 1 < N
|
|
1051
|
+
? (valueOf(data[i + 1], i + 1) / max) * w * prog
|
|
1052
|
+
: wTop * 0.85;
|
|
1053
|
+
const yTop = pad + i * segH;
|
|
1054
|
+
const yBot = yTop + segH - 2;
|
|
1055
|
+
const color = this._colorOf(i, data[i]);
|
|
1056
|
+
const trap = [
|
|
1057
|
+
{ x: cx - wTop / 2, y: yTop },
|
|
1058
|
+
{ x: cx + wTop / 2, y: yTop },
|
|
1059
|
+
{ x: cx + wBot / 2, y: yBot },
|
|
1060
|
+
{ x: cx - wBot / 2, y: yBot },
|
|
1061
|
+
];
|
|
1062
|
+
e.poly(trap, { fill: color, close: true });
|
|
1063
|
+
// Label
|
|
1064
|
+
e.text(`${labelOf(data[i], i)} ${v}`, cx, yTop + segH / 2, {
|
|
1065
|
+
fill: '#fff', font: `600 ${t.fontSize}px ${t.font}`,
|
|
1066
|
+
align: 'center', baseline: 'middle',
|
|
1067
|
+
});
|
|
1068
|
+
this.hits.add({
|
|
1069
|
+
_id: i, x: cx - wTop / 2, y: yTop, w: wTop, h: segH,
|
|
1070
|
+
tipX: cx, tipY: yTop,
|
|
1071
|
+
tooltip: `${labelOf(data[i], i)}: ${v} (${(v / max * 100).toFixed(1)}%)`,
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ─────────────────────────────────────────────────────────
|
|
1078
|
+
// 17. TREEMAP
|
|
1079
|
+
// ─────────────────────────────────────────────────────────
|
|
1080
|
+
export class TreemapChart extends Chart {
|
|
1081
|
+
_render() {
|
|
1082
|
+
const data = this.options.data || [];
|
|
1083
|
+
if (!data.length) { this.setEmpty('No data'); return; }
|
|
1084
|
+
const e = this.engine;
|
|
1085
|
+
const t = this.theme;
|
|
1086
|
+
const labelOf = this.options.label || (d => d.label);
|
|
1087
|
+
const valueOf = this.options.value || (d => d.value);
|
|
1088
|
+
const items = data.map((d, i) => ({
|
|
1089
|
+
label: labelOf(d, i), value: valueOf(d, i), color: this._colorOf(i, d), data: d, _id: i,
|
|
1090
|
+
})).sort((a, b) => b.value - a.value);
|
|
1091
|
+
const total = items.reduce((a, x) => a + x.value, 0);
|
|
1092
|
+
const rect = { x: 4, y: 4, w: e.w - 8, h: e.h - 8 };
|
|
1093
|
+
const prog = this._anim.progress;
|
|
1094
|
+
squarify(items, total, rect, (it, r) => {
|
|
1095
|
+
const padIn = 2;
|
|
1096
|
+
const x = r.x + padIn, y = r.y + padIn;
|
|
1097
|
+
const w = (r.w - padIn * 2) * prog;
|
|
1098
|
+
const h = (r.h - padIn * 2) * prog;
|
|
1099
|
+
if (w < 1 || h < 1) return;
|
|
1100
|
+
e.rect(x, y, w, h, { fill: it.color, radius: 2 });
|
|
1101
|
+
// Text only if box is big enough.
|
|
1102
|
+
if (w > 60 && h > 30) {
|
|
1103
|
+
e.text(it.label, x + 6, y + 6, {
|
|
1104
|
+
fill: '#fff', font: `600 ${t.fontSize}px ${t.font}`,
|
|
1105
|
+
align: 'left', baseline: 'top',
|
|
1106
|
+
});
|
|
1107
|
+
e.text(String(it.value), x + 6, y + 6 + t.fontSize + 2, {
|
|
1108
|
+
fill: 'rgba(255,255,255,0.85)', font: `${t.fontSize}px ${t.font}`,
|
|
1109
|
+
align: 'left', baseline: 'top',
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
this.hits.add({
|
|
1113
|
+
_id: it._id, x, y, w, h,
|
|
1114
|
+
tipX: x + w / 2, tipY: y,
|
|
1115
|
+
tooltip: `${it.label}: ${it.value}`,
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Squarified treemap layout (Bruls et al.)
|
|
1122
|
+
function squarify(items, total, rect, draw) {
|
|
1123
|
+
if (!items.length) return;
|
|
1124
|
+
const area = rect.w * rect.h;
|
|
1125
|
+
let i = 0;
|
|
1126
|
+
while (i < items.length) {
|
|
1127
|
+
const remaining = items.slice(i);
|
|
1128
|
+
const remTotal = remaining.reduce((a, x) => a + x.value, 0);
|
|
1129
|
+
if (remTotal <= 0) break;
|
|
1130
|
+
const remArea = (remTotal / total) * area;
|
|
1131
|
+
// Place first row.
|
|
1132
|
+
const w = Math.min(rect.w, rect.h);
|
|
1133
|
+
let row = [], rowSum = 0;
|
|
1134
|
+
let bestRatio = Infinity;
|
|
1135
|
+
for (let j = 0; j < remaining.length; j++) {
|
|
1136
|
+
const next = remaining[j];
|
|
1137
|
+
const trialSum = rowSum + next.value;
|
|
1138
|
+
const trialAreaPx = (trialSum / remTotal) * remArea;
|
|
1139
|
+
const trialW = trialAreaPx / w;
|
|
1140
|
+
let worst = 0;
|
|
1141
|
+
for (const r of [...row, next]) {
|
|
1142
|
+
const a = (r.value / trialSum) * trialAreaPx;
|
|
1143
|
+
const ratio = Math.max(trialW / (a / trialW), (a / trialW) / trialW);
|
|
1144
|
+
if (ratio > worst) worst = ratio;
|
|
1145
|
+
}
|
|
1146
|
+
if (worst > bestRatio) break;
|
|
1147
|
+
bestRatio = worst;
|
|
1148
|
+
row.push(next);
|
|
1149
|
+
rowSum = trialSum;
|
|
1150
|
+
}
|
|
1151
|
+
if (!row.length) row = [remaining[0]], rowSum = remaining[0].value;
|
|
1152
|
+
const isHoriz = rect.w >= rect.h;
|
|
1153
|
+
const rowAreaPx = (rowSum / remTotal) * remArea;
|
|
1154
|
+
const stripT = rowAreaPx / w;
|
|
1155
|
+
let cursor = isHoriz ? rect.y : rect.x;
|
|
1156
|
+
for (const it of row) {
|
|
1157
|
+
const itLen = ((it.value / rowSum) * rowAreaPx) / stripT;
|
|
1158
|
+
const r = isHoriz
|
|
1159
|
+
? { x: rect.x, y: cursor, w: stripT, h: itLen }
|
|
1160
|
+
: { x: cursor, y: rect.y, w: itLen, h: stripT };
|
|
1161
|
+
// Re-orient: row strip is along the *short* side
|
|
1162
|
+
const r2 = isHoriz
|
|
1163
|
+
? { x: rect.x, y: cursor, w: stripT, h: itLen }
|
|
1164
|
+
: { x: cursor, y: rect.y, w: itLen, h: stripT };
|
|
1165
|
+
// Actually a simpler placement: put strip on the short edge
|
|
1166
|
+
const placedRect = isHoriz
|
|
1167
|
+
? { x: rect.x, y: cursor, w: stripT, h: itLen }
|
|
1168
|
+
: { x: cursor, y: rect.y, w: itLen, h: stripT };
|
|
1169
|
+
draw(it, placedRect);
|
|
1170
|
+
cursor += itLen;
|
|
1171
|
+
}
|
|
1172
|
+
if (isHoriz) {
|
|
1173
|
+
rect.x += stripT;
|
|
1174
|
+
rect.w -= stripT;
|
|
1175
|
+
} else {
|
|
1176
|
+
rect.y += stripT;
|
|
1177
|
+
rect.h -= stripT;
|
|
1178
|
+
}
|
|
1179
|
+
i += row.length;
|
|
1180
|
+
if (rect.w < 1 || rect.h < 1) break;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ─────────────────────────────────────────────────────────
|
|
1185
|
+
// 18. SPARKLINE (mini line, no axes)
|
|
1186
|
+
// ─────────────────────────────────────────────────────────
|
|
1187
|
+
export class Sparkline extends Chart {
|
|
1188
|
+
_render() {
|
|
1189
|
+
const data = this.options.data || [];
|
|
1190
|
+
if (!data.length) return;
|
|
1191
|
+
const e = this.engine;
|
|
1192
|
+
const t = this.theme;
|
|
1193
|
+
const valueOf = this.options.value || (v => +v);
|
|
1194
|
+
const values = data.map(valueOf);
|
|
1195
|
+
const yMin = Math.min(...values), yMax = Math.max(...values);
|
|
1196
|
+
const yRange = yMax - yMin || 1;
|
|
1197
|
+
const pad = 2;
|
|
1198
|
+
const w = e.w - pad * 2;
|
|
1199
|
+
const h = e.h - pad * 2;
|
|
1200
|
+
const points = values.map((v, i) => ({
|
|
1201
|
+
x: pad + (i / (values.length - 1 || 1)) * w,
|
|
1202
|
+
y: pad + h - ((v - yMin) / yRange) * h,
|
|
1203
|
+
}));
|
|
1204
|
+
const color = this.options.color || this.palette[0];
|
|
1205
|
+
const cps = M.bezierControlPoints(points);
|
|
1206
|
+
if (this.options.fill !== false) {
|
|
1207
|
+
const grad = e.vGradient(`spark-${color}`, 0, pad, pad + h, [
|
|
1208
|
+
[0, M.colorAlpha(color, 0.3)],
|
|
1209
|
+
[1, M.colorAlpha(color, 0)],
|
|
1210
|
+
]);
|
|
1211
|
+
e.smoothArea(points, cps, pad + h, grad);
|
|
1212
|
+
}
|
|
1213
|
+
e.smooth(points, cps, { stroke: color, lineWidth: t.lineWidth });
|
|
1214
|
+
if (this.options.lastDot !== false) {
|
|
1215
|
+
const last = points[points.length - 1];
|
|
1216
|
+
e.circle(last.x, last.y, t.pointRadius + 1, { fill: color });
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ─────────────────────────────────────────────────────────
|
|
1222
|
+
// 19. REALTIME (streaming line, ring buffer)
|
|
1223
|
+
// ─────────────────────────────────────────────────────────
|
|
1224
|
+
export class RealtimeChart extends Chart {
|
|
1225
|
+
constructor(container, options = {}) {
|
|
1226
|
+
super(container, options);
|
|
1227
|
+
this.options.windowSize = options.windowSize || 200;
|
|
1228
|
+
this._series = (options.series || []).map(s => ({
|
|
1229
|
+
name: s.name || 'Series', color: s.color, values: [], hidden: false,
|
|
1230
|
+
}));
|
|
1231
|
+
this._streaming = true;
|
|
1232
|
+
}
|
|
1233
|
+
push(values) {
|
|
1234
|
+
const arr = Array.isArray(values) ? values : [values];
|
|
1235
|
+
for (let i = 0; i < this._series.length; i++) {
|
|
1236
|
+
const s = this._series[i];
|
|
1237
|
+
s.values.push(arr[i] != null ? arr[i] : 0);
|
|
1238
|
+
if (s.values.length > this.options.windowSize) s.values.shift();
|
|
1239
|
+
}
|
|
1240
|
+
// Clear any auto-set empty state from a prior render.
|
|
1241
|
+
if (this._state === 'empty') this._state = null;
|
|
1242
|
+
this._dirty = true;
|
|
1243
|
+
scheduler.schedule(this);
|
|
1244
|
+
}
|
|
1245
|
+
clear() {
|
|
1246
|
+
for (const s of this._series) s.values.length = 0;
|
|
1247
|
+
this._dirty = true;
|
|
1248
|
+
scheduler.schedule(this);
|
|
1249
|
+
}
|
|
1250
|
+
_render() {
|
|
1251
|
+
const e = this.engine;
|
|
1252
|
+
const t = this.theme;
|
|
1253
|
+
const hasData = this._series.some(s => s.values.length > 0);
|
|
1254
|
+
|
|
1255
|
+
// No data yet: draw an empty axis grid and an inline hint, but do NOT
|
|
1256
|
+
// call setEmpty() — that would lock us into the overlay state and stop
|
|
1257
|
+
// future _render calls from running.
|
|
1258
|
+
if (!hasData) {
|
|
1259
|
+
const ax = this._drawCartesian({
|
|
1260
|
+
yMin: 0, yMax: 100,
|
|
1261
|
+
yTickFormat: this.options.yTickFormat,
|
|
1262
|
+
});
|
|
1263
|
+
e.text('streaming…', ax.px + ax.pw / 2, ax.py + ax.ph / 2, {
|
|
1264
|
+
fill: t.muted, font: `${t.fontSize}px ${t.font}`,
|
|
1265
|
+
align: 'center', baseline: 'middle',
|
|
1266
|
+
});
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
let yMin = Infinity, yMax = -Infinity;
|
|
1271
|
+
for (const s of this._series) for (const v of s.values) {
|
|
1272
|
+
if (v < yMin) yMin = v;
|
|
1273
|
+
if (v > yMax) yMax = v;
|
|
1274
|
+
}
|
|
1275
|
+
const [yLo, yHi] = M.niceDomain(Math.min(0, yMin), yMax, 5);
|
|
1276
|
+
const N = this.options.windowSize;
|
|
1277
|
+
const ax = this._drawCartesian({
|
|
1278
|
+
yMin: yLo, yMax: yHi,
|
|
1279
|
+
yTickFormat: this.options.yTickFormat,
|
|
1280
|
+
});
|
|
1281
|
+
for (let s = 0; s < this._series.length; s++) {
|
|
1282
|
+
const ser = this._series[s];
|
|
1283
|
+
if (ser.hidden) continue;
|
|
1284
|
+
const color = this._colorOf(s, ser);
|
|
1285
|
+
const pts = ser.values.map((v, i) => ({
|
|
1286
|
+
x: ax.px + (i / (N - 1)) * ax.pw,
|
|
1287
|
+
y: ax.yToPx(v),
|
|
1288
|
+
}));
|
|
1289
|
+
if (pts.length < 2) {
|
|
1290
|
+
if (pts.length === 1) e.circle(pts[0].x, pts[0].y, t.pointRadius + 2, { fill: color });
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
const cps = M.bezierControlPoints(pts);
|
|
1294
|
+
e.smooth(pts, cps, { stroke: color, lineWidth: t.lineWidth });
|
|
1295
|
+
// Pulse the latest point
|
|
1296
|
+
const last = pts[pts.length - 1];
|
|
1297
|
+
e.circle(last.x, last.y, t.pointRadius + 2, { fill: color });
|
|
1298
|
+
}
|
|
1299
|
+
this._drawLegend(this._series.map((s, i) => ({
|
|
1300
|
+
label: s.name, color: this._colorOf(i, s), idx: i, hidden: s.hidden,
|
|
1301
|
+
})), ax.pw);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ─────────────────────────────────────────────────────────
|
|
1306
|
+
// 20. WATERFALL
|
|
1307
|
+
// ─────────────────────────────────────────────────────────
|
|
1308
|
+
export class WaterfallChart extends Chart {
|
|
1309
|
+
_render() {
|
|
1310
|
+
const data = this.options.data || []; // [{label, value, isTotal?}]
|
|
1311
|
+
if (!data.length) { this.setEmpty('No data'); return; }
|
|
1312
|
+
const e = this.engine;
|
|
1313
|
+
const t = this.theme;
|
|
1314
|
+
let cum = 0;
|
|
1315
|
+
const segs = data.map(d => {
|
|
1316
|
+
if (d.isTotal) {
|
|
1317
|
+
const seg = { from: 0, to: d.value, value: d.value, isTotal: true, label: d.label };
|
|
1318
|
+
cum = d.value;
|
|
1319
|
+
return seg;
|
|
1320
|
+
} else {
|
|
1321
|
+
const seg = { from: cum, to: cum + d.value, value: d.value, label: d.label };
|
|
1322
|
+
cum += d.value;
|
|
1323
|
+
return seg;
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
const allVals = segs.flatMap(s => [s.from, s.to]);
|
|
1327
|
+
const [yLo, yHi] = M.niceDomain(Math.min(...allVals, 0), Math.max(...allVals), 5);
|
|
1328
|
+
const ax = this._drawCartesian({
|
|
1329
|
+
yMin: yLo, yMax: yHi,
|
|
1330
|
+
xLabels: data.map(d => d.label),
|
|
1331
|
+
});
|
|
1332
|
+
const slot = ax.pw / segs.length;
|
|
1333
|
+
const barW = Math.max(2, Math.min(60, slot - 12));
|
|
1334
|
+
const prog = this._anim.progress;
|
|
1335
|
+
const upColor = this.options.colorUp || '#10b981';
|
|
1336
|
+
const dnColor = this.options.colorDown || '#ef4444';
|
|
1337
|
+
const totalColor = this.options.colorTotal || '#6366f1';
|
|
1338
|
+
|
|
1339
|
+
for (let i = 0; i < segs.length; i++) {
|
|
1340
|
+
const s = segs[i];
|
|
1341
|
+
const x = ax.px + (i + 0.5) * slot - barW / 2;
|
|
1342
|
+
const y0 = ax.yToPx(s.from);
|
|
1343
|
+
const y1 = ax.yToPx(s.to);
|
|
1344
|
+
const top = Math.min(y0, y1);
|
|
1345
|
+
const fullH = Math.abs(y1 - y0);
|
|
1346
|
+
const h = fullH * prog;
|
|
1347
|
+
const animTop = s.to >= s.from ? y0 - h : y0;
|
|
1348
|
+
const color = s.isTotal ? totalColor : (s.value >= 0 ? upColor : dnColor);
|
|
1349
|
+
e.rect(x, animTop, barW, h, { fill: color, radius: t.barRadius });
|
|
1350
|
+
// Connecting line to next
|
|
1351
|
+
if (i < segs.length - 1 && !segs[i + 1].isTotal) {
|
|
1352
|
+
const nextX = ax.px + (i + 1.5) * slot - barW / 2;
|
|
1353
|
+
const yEnd = ax.yToPx(s.to);
|
|
1354
|
+
e.poly([{x: x + barW, y: yEnd}, {x: nextX, y: yEnd}], {
|
|
1355
|
+
stroke: t.muted, lineWidth: 1, dash: [3, 3],
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
this.hits.add({
|
|
1359
|
+
_id: i, x, y: animTop, w: barW, h,
|
|
1360
|
+
tipX: x + barW / 2, tipY: animTop,
|
|
1361
|
+
tooltip: `${s.label}: ${s.value > 0 && !s.isTotal ? '+' : ''}${s.value}`,
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|