@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/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
+ }