@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.
@@ -0,0 +1,2597 @@
1
+ /* aeriz — ESM */
2
+ // math.js — Pure math: scales, ticks, color ops, smoothing, decimation
3
+ // No DOM, no canvas, no side effects.
4
+
5
+ const TAU = Math.PI * 2;
6
+
7
+ const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
8
+ const lerp = (a, b, t) => a + (b - a) * t;
9
+
10
+ function extent(arr, fn = (x) => x) {
11
+ let mn = Infinity, mx = -Infinity;
12
+ for (let i = 0; i < arr.length; i++) {
13
+ const v = fn(arr[i], i);
14
+ if (v < mn) mn = v;
15
+ if (v > mx) mx = v;
16
+ }
17
+ return [mn === Infinity ? 0 : mn, mx === -Infinity ? 1 : mx];
18
+ }
19
+
20
+ // "Nice" tick generator for linear axes. Returns ticks at human-readable
21
+ // values (1, 2, 5 × 10^k) covering the input domain.
22
+ function niceTicks(min, max, count = 5) {
23
+ if (min === max) { min -= 0.5; max += 0.5; }
24
+ const span = max - min;
25
+ const step0 = span / Math.max(1, count);
26
+ const mag = Math.pow(10, Math.floor(Math.log10(step0)));
27
+ const norm = step0 / mag;
28
+ let step;
29
+ if (norm < 1.5) step = mag;
30
+ else if (norm < 3) step = 2 * mag;
31
+ else if (norm < 7) step = 5 * mag;
32
+ else step = 10 * mag;
33
+ const lo = Math.ceil(min / step) * step;
34
+ const hi = Math.floor(max / step) * step;
35
+ const ticks = [];
36
+ for (let v = lo; v <= hi + step / 2; v += step) {
37
+ ticks.push(Math.round(v / step) * step);
38
+ }
39
+ return ticks;
40
+ }
41
+
42
+ function niceDomain(min, max, count = 5) {
43
+ if (min === max) { min -= 0.5; max += 0.5; }
44
+ const span = max - min;
45
+ const step0 = span / Math.max(1, count);
46
+ const mag = Math.pow(10, Math.floor(Math.log10(step0)));
47
+ const norm = step0 / mag;
48
+ let step;
49
+ if (norm < 1.5) step = mag;
50
+ else if (norm < 3) step = 2 * mag;
51
+ else if (norm < 7) step = 5 * mag;
52
+ else step = 10 * mag;
53
+ return [Math.floor(min / step) * step, Math.ceil(max / step) * step];
54
+ }
55
+
56
+ // Time axis: pick a nice unit (second, minute, hour, day, week, month) and
57
+ // generate ticks aligned to it. Returns timestamps in ms.
58
+ const T = { sec: 1e3, min: 6e4, hour: 36e5, day: 864e5, week: 6.048e8, month: 2.628e9 };
59
+ function timeNiceTicks(minMs, maxMs, count = 6) {
60
+ if (minMs === maxMs) { minMs -= 1000; maxMs += 1000; }
61
+ const span = maxMs - minMs;
62
+ const target = span / count;
63
+ const units = [
64
+ [T.sec, 1], [T.sec, 5], [T.sec, 15], [T.sec, 30],
65
+ [T.min, 1], [T.min, 5], [T.min, 15], [T.min, 30],
66
+ [T.hour, 1], [T.hour, 3], [T.hour, 6], [T.hour, 12],
67
+ [T.day, 1], [T.day, 2], [T.week, 1],
68
+ [T.month, 1], [T.month, 3], [T.month, 6], [T.month, 12],
69
+ ];
70
+ let step = T.sec;
71
+ for (const [u, n] of units) if (u * n <= target * 1.5) step = u * n;
72
+ const lo = Math.ceil(minMs / step) * step;
73
+ const ticks = [];
74
+ for (let v = lo; v <= maxMs; v += step) ticks.push(v);
75
+ return ticks;
76
+ }
77
+
78
+ function timeFormat(step) {
79
+ if (step < T.min) return (t) => new Date(t).toISOString().slice(11, 19);
80
+ if (step < T.hour) return (t) => new Date(t).toISOString().slice(11, 16);
81
+ if (step < T.day) return (t) => new Date(t).toISOString().slice(11, 16);
82
+ if (step < T.month) return (t) => new Date(t).toISOString().slice(5, 10);
83
+ return (t) => new Date(t).toISOString().slice(0, 7);
84
+ }
85
+
86
+ // Min-max decimation: when N points exceed pixel count, compress to ~2 per
87
+ // pixel column preserving local min and max. Used for line charts with
88
+ // huge datasets.
89
+ function minMaxDecimate(data, targetCount, fn = (d) => d.value) {
90
+ if (data.length <= targetCount) return data;
91
+ const bucket = data.length / targetCount;
92
+ const out = [];
93
+ for (let i = 0; i < targetCount; i++) {
94
+ const lo = Math.floor(i * bucket);
95
+ const hi = Math.min(data.length, Math.floor((i + 1) * bucket));
96
+ let mnI = lo, mxI = lo;
97
+ let mn = fn(data[lo], lo), mx = mn;
98
+ for (let j = lo + 1; j < hi; j++) {
99
+ const v = fn(data[j], j);
100
+ if (v < mn) { mn = v; mnI = j; }
101
+ if (v > mx) { mx = v; mxI = j; }
102
+ }
103
+ if (mnI < mxI) { out.push(data[mnI]); out.push(data[mxI]); }
104
+ else { out.push(data[mxI]); out.push(data[mnI]); }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ function shouldDecimate(n, pxWidth) {
110
+ return n > pxWidth * 2;
111
+ }
112
+
113
+ // Smooth path control points (centripetal Catmull-Rom-ish). Returns array
114
+ // of {c1x,c1y,c2x,c2y} to use with bezierCurveTo.
115
+ function bezierControlPoints(pts, tension = 1 / 6) {
116
+ const n = pts.length;
117
+ if (n < 2) return [];
118
+ const cps = [];
119
+ for (let i = 0; i < n - 1; i++) {
120
+ const p0 = pts[i - 1] || pts[i];
121
+ const p1 = pts[i];
122
+ const p2 = pts[i + 1];
123
+ const p3 = pts[i + 2] || p2;
124
+ cps.push({
125
+ c1x: p1.x + (p2.x - p0.x) * tension,
126
+ c1y: p1.y + (p2.y - p0.y) * tension,
127
+ c2x: p2.x - (p3.x - p1.x) * tension,
128
+ c2y: p2.y - (p3.y - p1.y) * tension,
129
+ });
130
+ }
131
+ return cps;
132
+ }
133
+
134
+ // Spring physics step. Used for animated transitions.
135
+ function springStep(v, tgt, vel, cfg = {}) {
136
+ const stiffness = cfg.stiffness || 170;
137
+ const damping = cfg.damping || 26;
138
+ const dt = 1 / 60;
139
+ const F = -stiffness * (v - tgt);
140
+ const D = -damping * vel;
141
+ const a = F + D;
142
+ vel = vel + a * dt;
143
+ v = v + vel * dt;
144
+ const done = Math.abs(vel) < 0.05 && Math.abs(v - tgt) < 0.05;
145
+ if (done) { v = tgt; vel = 0; }
146
+ return { value: v, velocity: vel, done };
147
+ }
148
+
149
+ // Color helpers: hex/rgb/rgba parsing, alpha mixing, lightening.
150
+ function colorParse(c) {
151
+ if (!c) return [0, 0, 0, 1];
152
+ if (c[0] === '#') {
153
+ const h = c.slice(1);
154
+ if (h.length === 3) return [parseInt(h[0]+h[0],16), parseInt(h[1]+h[1],16), parseInt(h[2]+h[2],16), 1];
155
+ if (h.length === 6) return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16), 1];
156
+ if (h.length === 8) return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16), parseInt(h.slice(6,8),16)/255];
157
+ }
158
+ const m = c.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\s*\)/);
159
+ if (m) return [+m[1], +m[2], +m[3], m[4] !== undefined ? +m[4] : 1];
160
+ return [0, 0, 0, 1];
161
+ }
162
+
163
+ function colorAlpha(c, a) {
164
+ const [r, g, b] = colorParse(c);
165
+ return `rgba(${r|0},${g|0},${b|0},${a})`;
166
+ }
167
+
168
+ function colorMix(a, b, t) {
169
+ const [r1, g1, b1, a1] = colorParse(a);
170
+ const [r2, g2, b2, a2] = colorParse(b);
171
+ return `rgba(${(r1+(r2-r1)*t)|0},${(g1+(g2-g1)*t)|0},${(b1+(b2-b1)*t)|0},${a1+(a2-a1)*t})`;
172
+ }
173
+
174
+ // Default qualitative palette (8 colors, distinct hues, balanced lightness).
175
+ // Lead color: #64c8ff (rgba(100,200,255,0.85) brand accent).
176
+ const PALETTE = [
177
+ '#64c8ff', '#10b981', '#f59e0b', '#ef4444',
178
+ '#8b5cf6', '#ec4899', '#84cc16', '#6366f1',
179
+ ];
180
+
181
+
182
+ const M = {
183
+ TAU, clamp, lerp, extent, niceTicks, niceDomain,
184
+ timeNiceTicks, timeFormat, minMaxDecimate, shouldDecimate,
185
+ bezierControlPoints, springStep, colorParse, colorAlpha, colorMix, PALETTE,
186
+ };
187
+ const math = M;
188
+ // engine.js — Canvas 2D rendering primitives.
189
+ // All public coordinates are CSS pixels. DPR is handled internally.
190
+
191
+
192
+ class Engine {
193
+ constructor(canvas) {
194
+ this.canvas = canvas;
195
+ this.ctx = canvas.getContext('2d');
196
+ this.w = 0;
197
+ this.h = 0;
198
+ this.dpr = 1;
199
+ this._gradCache = new Map();
200
+ this._gradCacheMax = 32;
201
+ this._snap = true;
202
+ }
203
+
204
+ // Resize the backing store to (w × h) CSS pixels at the current DPR.
205
+ // The internal coordinate system is reset so all subsequent draw calls
206
+ // use CSS pixels.
207
+ resize(w, h) {
208
+ const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
209
+ this.canvas.width = Math.round(w * dpr);
210
+ this.canvas.height = Math.round(h * dpr);
211
+ this.canvas.style.width = w + 'px';
212
+ this.canvas.style.height = h + 'px';
213
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
214
+ this.w = w;
215
+ this.h = h;
216
+ this.dpr = dpr;
217
+ this._gradCache.clear();
218
+ }
219
+
220
+ clear(color) {
221
+ if (color) {
222
+ this.ctx.fillStyle = color;
223
+ this.ctx.fillRect(0, 0, this.w, this.h);
224
+ } else {
225
+ this.ctx.clearRect(0, 0, this.w, this.h);
226
+ }
227
+ }
228
+
229
+ // ─── Primitives ─────────────────────────────────────────
230
+ // All draw methods take CSS-pixel coordinates and apply pixel snapping
231
+ // for crisp 1px lines. Snap is intentionally simple: x.5 alignment for
232
+ // odd-width strokes, integer alignment for fills.
233
+
234
+ rect(x, y, w, h, opts = {}) {
235
+ // Guard against negative/zero dims that animations can momentarily produce.
236
+ if (!(w > 0) || !(h > 0)) return;
237
+ const ctx = this.ctx;
238
+ if (opts.radius && opts.radius > 0) {
239
+ const r = Math.max(0, Math.min(opts.radius, w / 2, h / 2));
240
+ ctx.beginPath();
241
+ ctx.moveTo(x + r, y);
242
+ ctx.arcTo(x + w, y, x + w, y + h, r);
243
+ ctx.arcTo(x + w, y + h, x, y + h, r);
244
+ ctx.arcTo(x, y + h, x, y, r);
245
+ ctx.arcTo(x, y, x + w, y, r);
246
+ ctx.closePath();
247
+ } else {
248
+ ctx.beginPath();
249
+ ctx.rect(x, y, w, h);
250
+ }
251
+ if (opts.fill) { ctx.fillStyle = opts.fill; ctx.fill(); }
252
+ if (opts.stroke) {
253
+ ctx.strokeStyle = opts.stroke;
254
+ ctx.lineWidth = opts.lineWidth || 1;
255
+ ctx.stroke();
256
+ }
257
+ }
258
+
259
+ // Batch many rectangles of the same color in a single fill.
260
+ rects(items, fill) {
261
+ const ctx = this.ctx;
262
+ ctx.fillStyle = fill;
263
+ ctx.beginPath();
264
+ for (const r of items) {
265
+ // Skip degenerate items that would crash arcTo / rect.
266
+ if (!(r.w > 0) || !(r.h > 0)) continue;
267
+ if (r.radius && r.radius > 0) {
268
+ const rad = Math.max(0, Math.min(r.radius, r.w / 2, r.h / 2));
269
+ ctx.moveTo(r.x + rad, r.y);
270
+ ctx.arcTo(r.x + r.w, r.y, r.x + r.w, r.y + r.h, rad);
271
+ ctx.arcTo(r.x + r.w, r.y + r.h, r.x, r.y + r.h, rad);
272
+ ctx.arcTo(r.x, r.y + r.h, r.x, r.y, rad);
273
+ ctx.arcTo(r.x, r.y, r.x + r.w, r.y, rad);
274
+ ctx.closePath();
275
+ } else {
276
+ ctx.rect(r.x, r.y, r.w, r.h);
277
+ }
278
+ }
279
+ ctx.fill();
280
+ }
281
+
282
+ circle(x, y, r, opts = {}) {
283
+ // arc() throws on negative radii; bail silently instead.
284
+ if (!(r > 0)) return;
285
+ const ctx = this.ctx;
286
+ ctx.beginPath();
287
+ ctx.arc(x, y, r, 0, TAU);
288
+ if (opts.fill) { ctx.fillStyle = opts.fill; ctx.fill(); }
289
+ if (opts.stroke) {
290
+ ctx.strokeStyle = opts.stroke;
291
+ ctx.lineWidth = opts.lineWidth || 1;
292
+ ctx.stroke();
293
+ }
294
+ }
295
+
296
+ // Batch many circles of the same color and radius.
297
+ circles(items, fill, fixedRadius) {
298
+ const ctx = this.ctx;
299
+ ctx.fillStyle = fill;
300
+ ctx.beginPath();
301
+ for (const c of items) {
302
+ const r = fixedRadius != null ? fixedRadius : c.r;
303
+ if (!(r > 0)) continue;
304
+ ctx.moveTo(c.x + r, c.y);
305
+ ctx.arc(c.x, c.y, r, 0, TAU);
306
+ }
307
+ ctx.fill();
308
+ }
309
+
310
+ // Path of straight segments, optionally closed and filled.
311
+ poly(points, opts = {}) {
312
+ if (points.length < 2) return;
313
+ const ctx = this.ctx;
314
+ const snap = this._snap && opts.lineWidth === 1;
315
+ const adj = snap ? 0.5 : 0;
316
+ ctx.beginPath();
317
+ ctx.moveTo(points[0].x + adj, points[0].y + adj);
318
+ for (let i = 1; i < points.length; i++) {
319
+ ctx.lineTo(points[i].x + adj, points[i].y + adj);
320
+ }
321
+ if (opts.close) ctx.closePath();
322
+ if (opts.fill) { ctx.fillStyle = opts.fill; ctx.fill(); }
323
+ if (opts.stroke) {
324
+ ctx.strokeStyle = opts.stroke;
325
+ ctx.lineWidth = opts.lineWidth || 1.5;
326
+ ctx.lineCap = opts.cap || 'butt';
327
+ ctx.lineJoin = opts.join || 'miter';
328
+ if (opts.dash) ctx.setLineDash(opts.dash);
329
+ ctx.stroke();
330
+ if (opts.dash) ctx.setLineDash([]);
331
+ }
332
+ }
333
+
334
+ // Smooth path drawn through `points` using bezier control points `cps`.
335
+ smooth(points, cps, opts = {}) {
336
+ if (points.length < 2) return;
337
+ const ctx = this.ctx;
338
+ ctx.beginPath();
339
+ ctx.moveTo(points[0].x, points[0].y);
340
+ for (let i = 0; i < cps.length; i++) {
341
+ const c = cps[i];
342
+ const p = points[i + 1];
343
+ ctx.bezierCurveTo(c.c1x, c.c1y, c.c2x, c.c2y, p.x, p.y);
344
+ }
345
+ if (opts.fill) { ctx.fillStyle = opts.fill; ctx.fill(); }
346
+ if (opts.stroke) {
347
+ ctx.strokeStyle = opts.stroke;
348
+ ctx.lineWidth = opts.lineWidth || 2;
349
+ ctx.lineCap = opts.cap || 'round';
350
+ ctx.lineJoin = opts.join || 'round';
351
+ ctx.stroke();
352
+ }
353
+ }
354
+
355
+ // Smooth-filled area from `points` down to baseline `y0`.
356
+ smoothArea(points, cps, y0, fill) {
357
+ if (points.length < 2) return;
358
+ const ctx = this.ctx;
359
+ ctx.beginPath();
360
+ ctx.moveTo(points[0].x, y0);
361
+ ctx.lineTo(points[0].x, points[0].y);
362
+ for (let i = 0; i < cps.length; i++) {
363
+ const c = cps[i];
364
+ const p = points[i + 1];
365
+ ctx.bezierCurveTo(c.c1x, c.c1y, c.c2x, c.c2y, p.x, p.y);
366
+ }
367
+ ctx.lineTo(points[points.length - 1].x, y0);
368
+ ctx.closePath();
369
+ ctx.fillStyle = fill;
370
+ ctx.fill();
371
+ }
372
+
373
+ // Stepped path (3 modes).
374
+ step(points, mode, opts = {}) {
375
+ if (points.length < 2) return;
376
+ const ctx = this.ctx;
377
+ ctx.beginPath();
378
+ ctx.moveTo(points[0].x, points[0].y);
379
+ for (let i = 1; i < points.length; i++) {
380
+ const a = points[i - 1], b = points[i];
381
+ if (mode === 'after') {
382
+ ctx.lineTo(b.x, a.y);
383
+ ctx.lineTo(b.x, b.y);
384
+ } else if (mode === 'middle') {
385
+ const mx = (a.x + b.x) / 2;
386
+ ctx.lineTo(mx, a.y);
387
+ ctx.lineTo(mx, b.y);
388
+ ctx.lineTo(b.x, b.y);
389
+ } else {
390
+ ctx.lineTo(a.x, b.y);
391
+ ctx.lineTo(b.x, b.y);
392
+ }
393
+ }
394
+ if (opts.stroke) {
395
+ ctx.strokeStyle = opts.stroke;
396
+ ctx.lineWidth = opts.lineWidth || 1.5;
397
+ ctx.stroke();
398
+ }
399
+ }
400
+
401
+ text(str, x, y, opts = {}) {
402
+ const ctx = this.ctx;
403
+ ctx.font = opts.font || '11px ui-monospace, monospace';
404
+ ctx.fillStyle = opts.fill || '#000';
405
+ ctx.textAlign = opts.align || 'left';
406
+ ctx.textBaseline = opts.baseline || 'alphabetic';
407
+ if (opts.rotate) {
408
+ ctx.save();
409
+ ctx.translate(x, y);
410
+ ctx.rotate(opts.rotate);
411
+ ctx.fillText(str, 0, 0);
412
+ ctx.restore();
413
+ } else {
414
+ ctx.fillText(str, x, y);
415
+ }
416
+ }
417
+
418
+ measure(str, font) {
419
+ this.ctx.font = font || '11px ui-monospace, monospace';
420
+ return this.ctx.measureText(str).width;
421
+ }
422
+
423
+ // Arc segment for donut/polar charts.
424
+ arc(cx, cy, rIn, rOut, a0, a1, opts = {}) {
425
+ // Negative radii crash ctx.arc; clamp at 0 and bail when both collapse.
426
+ const ri = Math.max(0, rIn);
427
+ const ro = Math.max(0, rOut);
428
+ if (ro <= 0) return;
429
+ const ctx = this.ctx;
430
+ ctx.beginPath();
431
+ ctx.arc(cx, cy, ro, a0, a1);
432
+ ctx.arc(cx, cy, ri, a1, a0, true);
433
+ ctx.closePath();
434
+ if (opts.fill) { ctx.fillStyle = opts.fill; ctx.fill(); }
435
+ if (opts.stroke) {
436
+ ctx.strokeStyle = opts.stroke;
437
+ ctx.lineWidth = opts.lineWidth || 1;
438
+ ctx.stroke();
439
+ }
440
+ }
441
+
442
+ // ─── Gradient cache ─────────────────────────────────────
443
+ // Vertical gradient between two colors. Cached by key.
444
+ vGradient(key, x0, y0, y1, stops) {
445
+ if (this._gradCache.has(key)) return this._gradCache.get(key);
446
+ const g = this.ctx.createLinearGradient(x0, y0, x0, y1);
447
+ for (const [pos, color] of stops) g.addColorStop(pos, color);
448
+ if (this._gradCache.size >= this._gradCacheMax) {
449
+ const first = this._gradCache.keys().next().value;
450
+ this._gradCache.delete(first);
451
+ }
452
+ this._gradCache.set(key, g);
453
+ return g;
454
+ }
455
+
456
+ // ─── Clip region for plot area ──────────────────────────
457
+ clip(x, y, w, h) {
458
+ const ctx = this.ctx;
459
+ ctx.save();
460
+ ctx.beginPath();
461
+ ctx.rect(x, y, w, h);
462
+ ctx.clip();
463
+ }
464
+ unclip() { this.ctx.restore(); }
465
+
466
+ withAlpha(a, fn) {
467
+ const ctx = this.ctx;
468
+ const prev = ctx.globalAlpha;
469
+ ctx.globalAlpha = prev * a;
470
+ fn();
471
+ ctx.globalAlpha = prev;
472
+ }
473
+ }
474
+
475
+ // base.js — Chart base class. Handles:
476
+ // • Lifecycle (mount, resize, destroy, animation loop)
477
+ // • Theme resolution from CSS variables + reactive invalidation
478
+ // • Hit testing (pixel-perfect, no DPR confusion)
479
+ // • Tooltip (DOM, GPU-composited)
480
+ // • Axes (linear, log, time, category)
481
+ // • State overlays (loading, empty, error)
482
+
483
+
484
+
485
+ // ─── Global animation scheduler ─────────────────────────────
486
+ const charts = new Set();
487
+ let rafId = null;
488
+ let lastTime = 0;
489
+ let fps = 60;
490
+ let frames = 0;
491
+ let fpsT = 0;
492
+
493
+ function tick(now) {
494
+ if (now - fpsT > 500) {
495
+ fps = Math.round((1000 * frames) / (now - fpsT));
496
+ frames = 0;
497
+ fpsT = now;
498
+ }
499
+ frames++;
500
+ let active = false;
501
+ for (const c of charts) {
502
+ try {
503
+ const stillActive = c._tick(now);
504
+ if (stillActive) active = true;
505
+ } catch (e) { console.error(e); }
506
+ }
507
+ rafId = (charts.size > 0 && active) ? requestAnimationFrame(tick) : null;
508
+ }
509
+
510
+ function schedule(chart) {
511
+ charts.add(chart);
512
+ if (rafId === null) {
513
+ fpsT = lastTime = performance.now();
514
+ rafId = requestAnimationFrame(tick);
515
+ }
516
+ }
517
+
518
+ const scheduler = { schedule, fps: () => fps, count: () => charts.size };
519
+ function getFPS() { return fps; }
520
+ function getActiveCount() { return charts.size; }
521
+
522
+ // ─── Theme resolution ───────────────────────────────────────
523
+ const TOKENS = {
524
+ bg: ['--aeriz-bg', '#ffffff'],
525
+ surface: ['--aeriz-surface', 'transparent'],
526
+ text: ['--aeriz-text', '#222'],
527
+ muted: ['--aeriz-muted', '#888'],
528
+ grid: ['--aeriz-grid', 'rgba(0,0,0,0.08)'],
529
+ axis: ['--aeriz-axis', 'rgba(0,0,0,0.3)'],
530
+ accent: ['--aeriz-accent', '#64c8ff'],
531
+ font: ['--aeriz-font', 'ui-monospace, SFMono-Regular, Menlo, monospace'],
532
+ fontSize: ['--aeriz-font-size', '11', 'number'],
533
+ lineWidth: ['--aeriz-line-width', '2', 'number'],
534
+ pointRadius: ['--aeriz-point-radius', '3', 'number'],
535
+ barRadius: ['--aeriz-bar-radius', '3', 'number'],
536
+ tooltipBg: ['--aeriz-tooltip-bg', 'rgba(20,20,28,0.95)'],
537
+ tooltipFg: ['--aeriz-tooltip-fg', '#fff'],
538
+ };
539
+
540
+ function resolveTheme(el) {
541
+ const cs = getComputedStyle(el);
542
+ const out = {};
543
+ for (const [k, [varName, fallback, kind]] of Object.entries(TOKENS)) {
544
+ const raw = (cs.getPropertyValue(varName) || '').trim();
545
+ const v = raw || fallback;
546
+ out[k] = kind === 'number' ? parseFloat(v) : v;
547
+ }
548
+ return out;
549
+ }
550
+
551
+ // ─── Color palette resolution ───────────────────────────────
552
+ function readPalette(el) {
553
+ const cs = getComputedStyle(el);
554
+ const out = [];
555
+ for (let i = 1; i <= 12; i++) {
556
+ const v = (cs.getPropertyValue(`--aeriz-color-${i}`) || '').trim();
557
+ if (v) out.push(v);
558
+ }
559
+ return out.length ? out : M.PALETTE;
560
+ }
561
+
562
+ // ─── Tooltip ────────────────────────────────────────────────
563
+ class Tooltip {
564
+ constructor(parent) {
565
+ this.parent = parent;
566
+ this.el = document.createElement('div');
567
+ Object.assign(this.el.style, {
568
+ position: 'absolute',
569
+ pointerEvents: 'none',
570
+ padding: '6px 10px',
571
+ borderRadius: '4px',
572
+ fontSize: '11px',
573
+ fontFamily: 'inherit',
574
+ lineHeight: '1.5',
575
+ whiteSpace: 'nowrap',
576
+ opacity: '0',
577
+ transform: 'translate(-50%, calc(-100% - 8px))',
578
+ transition: 'opacity 0.1s',
579
+ zIndex: '20',
580
+ boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
581
+ });
582
+ parent.appendChild(this.el);
583
+ }
584
+ show(x, y, content, theme) {
585
+ this.el.style.background = theme.tooltipBg;
586
+ this.el.style.color = theme.tooltipFg;
587
+ if (typeof content === 'string') {
588
+ this.el.textContent = content;
589
+ } else {
590
+ this.el.innerHTML = '';
591
+ if (content.title) {
592
+ const t = document.createElement('div');
593
+ t.style.cssText = 'opacity:0.7;font-size:10px;margin-bottom:4px';
594
+ t.textContent = content.title;
595
+ this.el.appendChild(t);
596
+ }
597
+ for (const r of content.rows) {
598
+ const row = document.createElement('div');
599
+ row.style.cssText = 'display:flex;align-items:center;gap:6px;line-height:1.4';
600
+ if (r.color) {
601
+ const sw = document.createElement('span');
602
+ sw.style.cssText = `width:8px;height:8px;border-radius:2px;background:${r.color};display:inline-block`;
603
+ row.appendChild(sw);
604
+ }
605
+ const nm = document.createElement('span');
606
+ nm.style.cssText = 'opacity:0.8;flex:1';
607
+ nm.textContent = r.name || '';
608
+ row.appendChild(nm);
609
+ const val = document.createElement('span');
610
+ val.style.cssText = 'font-weight:700;margin-left:12px';
611
+ val.textContent = r.value == null ? '—' : String(r.value);
612
+ row.appendChild(val);
613
+ this.el.appendChild(row);
614
+ }
615
+ }
616
+ this.el.style.left = x + 'px';
617
+ this.el.style.top = y + 'px';
618
+ this.el.style.opacity = '1';
619
+ }
620
+ hide() { this.el.style.opacity = '0'; }
621
+ destroy() { this.el.remove(); }
622
+ }
623
+
624
+ // ─── State overlays (loading / empty / error) ───────────────
625
+ class Overlay {
626
+ constructor(parent) {
627
+ this.parent = parent;
628
+ this.el = document.createElement('div');
629
+ Object.assign(this.el.style, {
630
+ position: 'absolute',
631
+ inset: '0',
632
+ display: 'none',
633
+ alignItems: 'center',
634
+ justifyContent: 'center',
635
+ flexDirection: 'column',
636
+ gap: '8px',
637
+ fontSize: '12px',
638
+ fontFamily: 'inherit',
639
+ pointerEvents: 'none',
640
+ zIndex: '15',
641
+ });
642
+ parent.appendChild(this.el);
643
+ }
644
+ show(kind, msg, theme) {
645
+ const el = this.el;
646
+ el.style.display = 'flex';
647
+ el.style.background = M.colorAlpha(theme.bg, 0.85);
648
+ el.style.color = theme.text;
649
+ el.innerHTML = '';
650
+ const icon = document.createElement('div');
651
+ icon.style.fontSize = '20px';
652
+ icon.style.opacity = '0.6';
653
+ if (kind === 'loading') {
654
+ icon.innerHTML = `<svg width="22" height="22" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="${theme.accent}" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="14 50"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/></circle></svg>`;
655
+ } else if (kind === 'empty') {
656
+ icon.textContent = '◌';
657
+ } else if (kind === 'error') {
658
+ icon.textContent = '!';
659
+ icon.style.color = '#ef4444';
660
+ }
661
+ el.appendChild(icon);
662
+ if (msg) {
663
+ const t = document.createElement('div');
664
+ t.style.opacity = '0.85';
665
+ t.textContent = msg;
666
+ el.appendChild(t);
667
+ }
668
+ }
669
+ hide() { this.el.style.display = 'none'; }
670
+ destroy() { this.el.remove(); }
671
+ }
672
+
673
+ // ─── Hit index ──────────────────────────────────────────────
674
+ // Canvas-pixel rectangles with arbitrary metadata. Used for hover/click.
675
+ class HitIndex {
676
+ constructor() { this.regions = []; }
677
+ clear() { this.regions.length = 0; }
678
+ add(r) { this.regions.push(r); }
679
+ // Find the topmost (last-added) region containing (x, y).
680
+ find(x, y) {
681
+ for (let i = this.regions.length - 1; i >= 0; i--) {
682
+ const r = this.regions[i];
683
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return r;
684
+ }
685
+ return null;
686
+ }
687
+ }
688
+
689
+ // ─── Chart base ─────────────────────────────────────────────
690
+ class Chart {
691
+ constructor(container, options = {}) {
692
+ if (!container) throw new Error('Chart: container is required');
693
+ this.container = typeof container === 'string'
694
+ ? document.querySelector(container)
695
+ : container;
696
+ this.options = options;
697
+
698
+ // Mark container for external CSS targeting.
699
+ this.container.classList.add('aeriz');
700
+ if (getComputedStyle(this.container).position === 'static') {
701
+ this.container.style.position = 'relative';
702
+ }
703
+ if (!this.container.style.height && this.container.offsetHeight === 0) {
704
+ this.container.style.height = '320px';
705
+ }
706
+
707
+ // Canvas fills the *content area* (inside container's padding/border).
708
+ // We size the canvas from clientWidth/clientHeight (CSS content box).
709
+ this.canvas = document.createElement('canvas');
710
+ this.canvas.style.cssText = 'display:block;width:100%;height:100%;touch-action:none';
711
+ this.container.appendChild(this.canvas);
712
+
713
+ this.engine = new Engine(this.canvas);
714
+ this.tooltip = new Tooltip(this.container);
715
+ this.overlay = new Overlay(this.container);
716
+ this.hits = new HitIndex();
717
+
718
+ this.theme = resolveTheme(this.container);
719
+ this.palette = options.palette || readPalette(this.container);
720
+ this._cursor = null;
721
+ this._hover = null;
722
+ this._dirty = true;
723
+ this._destroyed = false;
724
+ this._mounted = false;
725
+ this._anim = { progress: 0, start: 0, duration: 600 };
726
+ this._state = null; // 'loading' | 'empty' | 'error' | null
727
+
728
+ this._setupEvents();
729
+ this._observeResize();
730
+ this._resize();
731
+
732
+ schedule(this);
733
+ }
734
+
735
+ // ─── Public API ──────────────────────────────────────────
736
+ // Manually re-resolve theme + palette and trigger a redraw. Use this
737
+ // after changing CSS variables on :root or the container.
738
+ invalidate() {
739
+ this.theme = resolveTheme(this.container);
740
+ this.palette = this.options.palette || readPalette(this.container);
741
+ this.engine._gradCache.clear();
742
+ this._dirty = true;
743
+ schedule(this);
744
+ }
745
+
746
+ setData(data) {
747
+ this.options.data = data;
748
+ // Clear auto-set empty state so the new data actually renders.
749
+ if (this._state === 'empty') this._state = null;
750
+ this._anim.start = performance.now();
751
+ this._anim.progress = 0;
752
+ this._dirty = true;
753
+ schedule(this);
754
+ return this;
755
+ }
756
+
757
+ setLoading(msg) { this._state = 'loading'; this._stateMsg = msg || ''; this._dirty = true; schedule(this); return this; }
758
+ setEmpty(msg) { this._state = 'empty'; this._stateMsg = msg || 'No data'; this._dirty = true; schedule(this); return this; }
759
+ setError(msg) { this._state = 'error'; this._stateMsg = msg || 'Error'; this._dirty = true; schedule(this); return this; }
760
+ clearState() { this._state = null; this._dirty = true; schedule(this); return this; }
761
+
762
+ destroy() {
763
+ this._destroyed = true;
764
+ charts.delete(this);
765
+ this._ro && this._ro.disconnect();
766
+ this._removeEvents();
767
+ this.tooltip.destroy();
768
+ this.overlay.destroy();
769
+ this.canvas.remove();
770
+ this.container.classList.remove('aeriz');
771
+ }
772
+
773
+ // ─── Internals ───────────────────────────────────────────
774
+ _resize() {
775
+ // Use container.clientWidth/Height — these are the *content area*
776
+ // (excludes padding/border), which is exactly where the canvas
777
+ // (a child filling 100%) sits. This guarantees mouse events at
778
+ // canvas-relative coordinates correspond to draw coordinates.
779
+ const w = this.container.clientWidth;
780
+ const h = this.container.clientHeight;
781
+ if (w === this.engine.w && h === this.engine.h) return;
782
+ this.engine.resize(Math.max(50, w), Math.max(50, h));
783
+ this.theme = resolveTheme(this.container);
784
+ this._dirty = true;
785
+ }
786
+
787
+ _observeResize() {
788
+ if (typeof ResizeObserver === 'undefined') return;
789
+ this._ro = new ResizeObserver(() => {
790
+ this._resize();
791
+ schedule(this);
792
+ });
793
+ this._ro.observe(this.container);
794
+ }
795
+
796
+ _setupEvents() {
797
+ const c = this.canvas;
798
+ this._handlers = {
799
+ move: (e) => this._onMove(e),
800
+ leave: () => this._onLeave(),
801
+ click: (e) => this._onClick(e),
802
+ };
803
+ c.addEventListener('mousemove', this._handlers.move);
804
+ c.addEventListener('mouseleave', this._handlers.leave);
805
+ c.addEventListener('click', this._handlers.click);
806
+ }
807
+ _removeEvents() {
808
+ const c = this.canvas;
809
+ c.removeEventListener('mousemove', this._handlers.move);
810
+ c.removeEventListener('mouseleave', this._handlers.leave);
811
+ c.removeEventListener('click', this._handlers.click);
812
+ }
813
+
814
+ // Convert a mouse event to canvas-CSS-pixel coordinates.
815
+ _ptr(e) {
816
+ const r = this.canvas.getBoundingClientRect();
817
+ return { x: e.clientX - r.left, y: e.clientY - r.top };
818
+ }
819
+
820
+ _onMove(e) {
821
+ const p = this._ptr(e);
822
+ this._cursor = p;
823
+ const hit = this.hits.find(p.x, p.y);
824
+ const changed = (hit?._id ?? null) !== (this._hover?._id ?? null);
825
+ this._hover = hit;
826
+ if (changed) {
827
+ if (hit && hit.tooltip) {
828
+ this.tooltip.show(
829
+ hit.tipX != null ? hit.tipX : hit.x + hit.w / 2,
830
+ hit.tipY != null ? hit.tipY : hit.y,
831
+ hit.tooltip,
832
+ this.theme,
833
+ );
834
+ } else if (!hit && !this._sharedTooltip) {
835
+ this.tooltip.hide();
836
+ }
837
+ this._dirty = true;
838
+ schedule(this);
839
+ }
840
+ // Subclass hook for shared/follow tooltips
841
+ if (typeof this._onMoveX === 'function') this._onMoveX(p);
842
+ }
843
+
844
+ _onLeave() {
845
+ this._cursor = null;
846
+ this._hover = null;
847
+ this.tooltip.hide();
848
+ this._dirty = true;
849
+ schedule(this);
850
+ }
851
+
852
+ _onClick(e) {
853
+ const p = this._ptr(e);
854
+ const hit = this.hits.find(p.x, p.y);
855
+ if (this.options.onClick) this.options.onClick(hit, p, this);
856
+ }
857
+
858
+ // Per-frame: advance animation, draw if dirty, return whether more frames
859
+ // are needed.
860
+ _tick(now) {
861
+ if (this._destroyed) return false;
862
+ let active = false;
863
+ if (this._anim.progress < 1) {
864
+ if (!this._anim.start) this._anim.start = now;
865
+ this._anim.progress = Math.min(1, (now - this._anim.start) / this._anim.duration);
866
+ this._dirty = true;
867
+ if (this._anim.progress < 1) active = true;
868
+ }
869
+ if (this._dirty) {
870
+ this._draw();
871
+ this._dirty = false;
872
+ }
873
+ return active;
874
+ }
875
+
876
+ _draw() {
877
+ const e = this.engine;
878
+ e.clear();
879
+
880
+ // Background fill if surface is set.
881
+ if (this.theme.surface && this.theme.surface !== 'transparent') {
882
+ e.rect(0, 0, e.w, e.h, { fill: this.theme.surface });
883
+ }
884
+
885
+ // State overlay paths
886
+ if (this._state) {
887
+ this.overlay.show(this._state, this._stateMsg, this.theme);
888
+ this.hits.clear();
889
+ return;
890
+ } else {
891
+ this.overlay.hide();
892
+ }
893
+
894
+ // Subclass renders here.
895
+ this.hits.clear();
896
+ if (typeof this._render === 'function') this._render();
897
+ }
898
+
899
+ // ─── Helpers shared by subclasses ────────────────────────
900
+
901
+ // Compute padding for axes. Subclasses may override / extend.
902
+ _axisPad(opts = {}) {
903
+ const t = this.theme;
904
+ return {
905
+ l: opts.l != null ? opts.l : 50,
906
+ r: opts.r != null ? opts.r : 16,
907
+ t: opts.t != null ? opts.t : 16,
908
+ b: opts.b != null ? opts.b : 32,
909
+ };
910
+ }
911
+
912
+ // Draw cartesian axes + grid. Returns the inner plot rect.
913
+ _drawCartesian(spec) {
914
+ const e = this.engine;
915
+ const t = this.theme;
916
+ const pad = this._axisPad(spec.pad);
917
+ const px = pad.l, py = pad.t;
918
+ const pw = e.w - pad.l - pad.r;
919
+ const ph = e.h - pad.t - pad.b;
920
+
921
+ const yMin = spec.yMin != null ? spec.yMin : 0;
922
+ const yMax = spec.yMax;
923
+ const yScale = spec.yScale || 'linear';
924
+ const font = `${t.fontSize}px ${t.font}`;
925
+
926
+ // Y ticks
927
+ let yTicks, yToPx;
928
+ if (yScale === 'log') {
929
+ const lo = Math.max(1e-10, spec.yMin || 1);
930
+ const hi = yMax;
931
+ const lLo = Math.log10(lo), lHi = Math.log10(hi);
932
+ yToPx = v => py + ph * (1 - (Math.log10(Math.max(lo, v)) - lLo) / (lHi - lLo));
933
+ yTicks = [];
934
+ for (let p = Math.floor(lLo); p <= Math.ceil(lHi); p++) {
935
+ const v = Math.pow(10, p);
936
+ if (v >= lo && v <= hi) yTicks.push(v);
937
+ }
938
+ } else {
939
+ yTicks = M.niceTicks(yMin, yMax, 5);
940
+ yToPx = v => py + ph * (1 - (v - yMin) / (yMax - yMin));
941
+ }
942
+
943
+ // Grid lines
944
+ e.ctx.strokeStyle = t.grid;
945
+ e.ctx.lineWidth = 1;
946
+ e.ctx.beginPath();
947
+ for (const v of yTicks) {
948
+ const y = Math.round(yToPx(v)) + 0.5;
949
+ e.ctx.moveTo(px, y);
950
+ e.ctx.lineTo(px + pw, y);
951
+ }
952
+ e.ctx.stroke();
953
+
954
+ // Y labels
955
+ const yFmt = spec.yTickFormat || (v => yScale === 'log' && v >= 1000 ? v.toExponential(0) : String(Math.round(v * 100) / 100));
956
+ for (const v of yTicks) {
957
+ e.text(yFmt(v), px - 6, yToPx(v), {
958
+ fill: t.muted, font, align: 'right', baseline: 'middle',
959
+ });
960
+ }
961
+
962
+ // X axis. Subclasses pass either `xLabels: string[]` (categorical) or
963
+ // `xTime: {min, max}` (time scale) or `xLinear: {min, max}`.
964
+ let xToPx, xTicks, xFmt;
965
+ if (spec.xTime) {
966
+ const { min, max } = spec.xTime;
967
+ xToPx = v => px + pw * (v - min) / (max - min || 1);
968
+ xTicks = M.timeNiceTicks(min, max, 6);
969
+ const step = xTicks.length > 1 ? xTicks[1] - xTicks[0] : (max - min);
970
+ xFmt = spec.xTickFormat || M.timeFormat(step);
971
+ } else if (spec.xLinear) {
972
+ const { min, max } = spec.xLinear;
973
+ xToPx = v => px + pw * (v - min) / (max - min || 1);
974
+ xTicks = M.niceTicks(min, max, 6);
975
+ xFmt = spec.xTickFormat || (v => String(v));
976
+ } else if (spec.xLabels) {
977
+ const n = spec.xLabels.length;
978
+ xToPx = i => px + (i + 0.5) * (pw / n);
979
+ xTicks = spec.xLabels.map((_, i) => i);
980
+ xFmt = spec.xTickFormat || (i => String(spec.xLabels[i]));
981
+ }
982
+ if (xToPx) {
983
+ // X label collision avoidance.
984
+ let lastEnd = -Infinity;
985
+ for (const v of xTicks) {
986
+ const x = xToPx(v);
987
+ const txt = xFmt(v);
988
+ const w = e.measure(txt, font);
989
+ if (x - w / 2 < lastEnd + 4) continue;
990
+ lastEnd = x + w / 2;
991
+ e.text(txt, x, py + ph + 6, {
992
+ fill: t.muted, font, align: 'center', baseline: 'top',
993
+ });
994
+ }
995
+ }
996
+
997
+ // Axis line
998
+ e.poly([{x: px, y: py + ph}, {x: px + pw, y: py + ph}], {
999
+ stroke: t.axis, lineWidth: 1,
1000
+ });
1001
+
1002
+ return { px, py, pw, ph, yToPx, xToPx, yTicks, xTicks, yMin, yMax };
1003
+ }
1004
+
1005
+ // Draw a legend at the top. Returns consumed height for PAD.t adjustment.
1006
+ _drawLegend(items, pw) {
1007
+ const e = this.engine;
1008
+ const t = this.theme;
1009
+ const font = `${t.fontSize}px ${t.font}`;
1010
+ const swatch = 10, gap = 16, rowH = 18;
1011
+ let x = 0, y = 0, maxW = pw;
1012
+ for (const it of items) {
1013
+ const tw = e.measure(it.label, font);
1014
+ const w = swatch + 6 + tw;
1015
+ if (x + w > maxW && x > 0) { x = 0; y += rowH; }
1016
+ e.rect(x, y + 4, swatch, swatch, { fill: it.hidden ? t.muted : it.color, radius: 2 });
1017
+ e.text(it.label, x + swatch + 4, y + swatch / 2 + 4, {
1018
+ fill: it.hidden ? t.muted : t.text, font, align: 'left', baseline: 'middle',
1019
+ });
1020
+ // Hit region for legend toggle
1021
+ this.hits.add({
1022
+ _id: 'legend-' + it.idx, x, y, w: swatch + 4 + tw, h: rowH,
1023
+ legendIdx: it.idx,
1024
+ });
1025
+ x += w + gap;
1026
+ }
1027
+ return y + rowH + 6;
1028
+ }
1029
+
1030
+ // Get color for series index, respecting per-series overrides.
1031
+ _colorOf(idx, item) {
1032
+ if (item && item.color) return item.color;
1033
+ return this.palette[idx % this.palette.length];
1034
+ }
1035
+ }
1036
+
1037
+ // charts.js — Concrete chart implementations.
1038
+ // Each subclass implements _render(). All share Chart's lifecycle, hit
1039
+ // testing, tooltip, theme, and animation infrastructure.
1040
+
1041
+
1042
+
1043
+
1044
+ // ─────────────────────────────────────────────────────────
1045
+ // 1. BAR (vertical)
1046
+ // ─────────────────────────────────────────────────────────
1047
+ class BarChart extends Chart {
1048
+ _render() {
1049
+ const data = this.options.data || [];
1050
+ if (data.length === 0) { this.setEmpty('No data'); return; }
1051
+ const e = this.engine;
1052
+ const t = this.theme;
1053
+ const labelOf = this.options.label || (d => d.label);
1054
+ const valueOf = this.options.value || (d => d.value);
1055
+
1056
+ const values = data.map((d, i) => valueOf(d, i));
1057
+ const yMin = Math.min(0, ...values);
1058
+ const yMax = Math.max(...values);
1059
+ const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
1060
+
1061
+ const ax = this._drawCartesian({
1062
+ yMin: yLo, yMax: yHi,
1063
+ xLabels: data.map(labelOf),
1064
+ yTickFormat: this.options.yTickFormat,
1065
+ xTickFormat: this.options.xTickFormat,
1066
+ });
1067
+
1068
+ const slot = ax.pw / data.length;
1069
+ const barW = Math.max(2, Math.min(60, slot - 8));
1070
+ const prog = this._anim.progress;
1071
+ const radius = t.barRadius;
1072
+
1073
+ // Group by color for batching.
1074
+ const groups = new Map();
1075
+ const fmt = this.options.dataLabels;
1076
+
1077
+ for (let i = 0; i < data.length; i++) {
1078
+ const v = values[i];
1079
+ const x = ax.px + (i + 0.5) * slot - barW / 2;
1080
+ const y0 = ax.yToPx(0);
1081
+ const y1 = ax.yToPx(v);
1082
+ const top = Math.min(y0, y1);
1083
+ const h = Math.abs(y1 - y0) * prog;
1084
+ const animTop = v >= 0 ? y0 - h : y0;
1085
+ const color = this._colorOf(i, data[i]);
1086
+ if (!groups.has(color)) groups.set(color, []);
1087
+ groups.get(color).push({ x, y: animTop, w: barW, h, radius });
1088
+
1089
+ this.hits.add({
1090
+ _id: i, x, y: Math.min(animTop, animTop + h), w: barW,
1091
+ h: Math.abs(h),
1092
+ tipX: x + barW / 2, tipY: animTop,
1093
+ tooltip: `${labelOf(data[i], i)}: ${v}`,
1094
+ });
1095
+
1096
+ if (fmt && prog > 0.95) {
1097
+ const txt = typeof fmt === 'function' ? fmt(data[i], i) : String(v);
1098
+ e.text(txt, x + barW / 2, animTop - 4, {
1099
+ fill: t.text, font: `${t.fontSize}px ${t.font}`,
1100
+ align: 'center', baseline: 'bottom',
1101
+ });
1102
+ }
1103
+ }
1104
+
1105
+ for (const [color, items] of groups) e.rects(items, color);
1106
+
1107
+ if (this._hover) {
1108
+ const r = this._hover;
1109
+ e.rect(r.x, r.y, r.w, r.h, { stroke: t.text, lineWidth: 1.5 });
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ // ─────────────────────────────────────────────────────────
1115
+ // 2. HORIZONTAL BAR
1116
+ // ─────────────────────────────────────────────────────────
1117
+ class HBarChart extends Chart {
1118
+ _axisPad() {
1119
+ return { l: 80, r: 16, t: 16, b: 32 };
1120
+ }
1121
+ _render() {
1122
+ const data = this.options.data || [];
1123
+ if (data.length === 0) { this.setEmpty('No data'); return; }
1124
+ const e = this.engine;
1125
+ const t = this.theme;
1126
+ const labelOf = this.options.label || (d => d.label);
1127
+ const valueOf = this.options.value || (d => d.value);
1128
+ const values = data.map((d, i) => valueOf(d, i));
1129
+ const xMin = Math.min(0, ...values);
1130
+ const xMax = Math.max(...values);
1131
+ const [xLo, xHi] = M.niceDomain(xMin, xMax, 5);
1132
+
1133
+ const pad = this._axisPad();
1134
+ const px = pad.l, py = pad.t;
1135
+ const pw = e.w - pad.l - pad.r;
1136
+ const ph = e.h - pad.t - pad.b;
1137
+ const font = `${t.fontSize}px ${t.font}`;
1138
+
1139
+ // X grid + labels
1140
+ const xTicks = M.niceTicks(xLo, xHi, 5);
1141
+ const xToPx = v => px + pw * (v - xLo) / (xHi - xLo);
1142
+ e.ctx.strokeStyle = t.grid;
1143
+ e.ctx.lineWidth = 1;
1144
+ e.ctx.beginPath();
1145
+ for (const v of xTicks) {
1146
+ const x = Math.round(xToPx(v)) + 0.5;
1147
+ e.ctx.moveTo(x, py); e.ctx.lineTo(x, py + ph);
1148
+ }
1149
+ e.ctx.stroke();
1150
+ for (const v of xTicks) {
1151
+ e.text(String(v), xToPx(v), py + ph + 6, {
1152
+ fill: t.muted, font, align: 'center', baseline: 'top',
1153
+ });
1154
+ }
1155
+ e.poly([{x: px, y: py + ph}, {x: px + pw, y: py + ph}], { stroke: t.axis, lineWidth: 1 });
1156
+
1157
+ const slot = ph / data.length;
1158
+ const barH = Math.max(2, Math.min(40, slot - 6));
1159
+ const prog = this._anim.progress;
1160
+ const groups = new Map();
1161
+
1162
+ for (let i = 0; i < data.length; i++) {
1163
+ const v = values[i];
1164
+ const yMid = py + (i + 0.5) * slot;
1165
+ const y = yMid - barH / 2;
1166
+ const x0 = xToPx(0);
1167
+ const x1 = xToPx(v);
1168
+ const start = Math.min(x0, x1);
1169
+ const w = Math.abs(x1 - x0) * prog;
1170
+ const animLeft = v >= 0 ? x0 : x0 - w;
1171
+ const color = this._colorOf(i, data[i]);
1172
+ if (!groups.has(color)) groups.set(color, []);
1173
+ groups.get(color).push({ x: animLeft, y, w, h: barH, radius: t.barRadius });
1174
+
1175
+ this.hits.add({
1176
+ _id: i, x: animLeft, y, w, h: barH,
1177
+ tipX: animLeft + w / 2, tipY: y,
1178
+ tooltip: `${labelOf(data[i], i)}: ${v}`,
1179
+ });
1180
+ // Y label
1181
+ e.text(labelOf(data[i], i), px - 6, yMid, {
1182
+ fill: t.muted, font, align: 'right', baseline: 'middle',
1183
+ });
1184
+ }
1185
+
1186
+ for (const [color, items] of groups) e.rects(items, color);
1187
+ if (this._hover) {
1188
+ const r = this._hover;
1189
+ e.rect(r.x, r.y, r.w, r.h, { stroke: t.text, lineWidth: 1.5 });
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ // ─────────────────────────────────────────────────────────
1195
+ // 3. LINE
1196
+ // ─────────────────────────────────────────────────────────
1197
+ class LineChart extends Chart {
1198
+ _render() {
1199
+ const data = this.options.data || [];
1200
+ if (data.length === 0) { this.setEmpty('No data'); return; }
1201
+ const e = this.engine;
1202
+ const t = this.theme;
1203
+ const labelOf = this.options.label || (d => d.label);
1204
+ const valueOf = this.options.value || (d => d.value);
1205
+ const values = data.map((d, i) => valueOf(d, i));
1206
+ const yMin = Math.min(0, ...values);
1207
+ const yMax = Math.max(...values);
1208
+ const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
1209
+ const useTime = this.options.xScale === 'time';
1210
+ let spec = {
1211
+ yMin: yLo, yMax: yHi,
1212
+ yScale: this.options.yScale,
1213
+ yTickFormat: this.options.yTickFormat,
1214
+ xTickFormat: this.options.xTickFormat,
1215
+ };
1216
+ if (useTime) {
1217
+ const xs = data.map((d, i) => +labelOf(d, i));
1218
+ spec.xTime = { min: xs[0], max: xs[xs.length - 1] };
1219
+ } else {
1220
+ spec.xLabels = data.map(labelOf);
1221
+ }
1222
+ const ax = this._drawCartesian(spec);
1223
+
1224
+ const color = this.options.color || this.palette[0];
1225
+ const interp = this.options.interpolation || 'smooth';
1226
+ const lineW = t.lineWidth;
1227
+ const prog = this._anim.progress;
1228
+
1229
+ // Decimation
1230
+ let renderData = data;
1231
+ if (M.shouldDecimate(data.length, ax.pw)) {
1232
+ renderData = M.minMaxDecimate(data, Math.floor(ax.pw), valueOf);
1233
+ }
1234
+
1235
+ const points = renderData.map((d, i) => {
1236
+ const xv = useTime ? +labelOf(d, i) : i;
1237
+ const idx = useTime ? null : i;
1238
+ // For category x scale, map by index in renderData.
1239
+ const x = useTime
1240
+ ? ax.xToPx(xv)
1241
+ : ax.px + ((idx + 0.5) / renderData.length) * ax.pw;
1242
+ const y = ax.yToPx(valueOf(d, i));
1243
+ return { x, y, d, i };
1244
+ });
1245
+
1246
+ // Reveal: clip horizontally based on progress.
1247
+ e.clip(ax.px, ax.py, ax.pw * prog, ax.ph);
1248
+
1249
+ // Area fill (smooth + linear)
1250
+ if (interp === 'smooth' || interp === 'linear') {
1251
+ const cps = interp === 'smooth' ? M.bezierControlPoints(points) : null;
1252
+ const grad = e.vGradient(`area-${color}`, ax.px, ax.py, ax.py + ax.ph, [
1253
+ [0, M.colorAlpha(color, 0.25)],
1254
+ [1, M.colorAlpha(color, 0)],
1255
+ ]);
1256
+ if (cps) e.smoothArea(points, cps, ax.py + ax.ph, grad);
1257
+ else {
1258
+ const areaPts = points.concat([
1259
+ { x: points[points.length - 1].x, y: ax.py + ax.ph },
1260
+ { x: points[0].x, y: ax.py + ax.ph },
1261
+ ]);
1262
+ e.poly(areaPts, { fill: grad });
1263
+ }
1264
+ if (cps) e.smooth(points, cps, { stroke: color, lineWidth: lineW });
1265
+ else e.poly(points, { stroke: color, lineWidth: lineW, cap: 'round', join: 'round' });
1266
+ } else if (interp === 'step' || interp.startsWith('step-')) {
1267
+ const mode = interp === 'step' ? 'before' : interp.split('-')[1];
1268
+ e.step(points, mode, { stroke: color, lineWidth: lineW });
1269
+ }
1270
+
1271
+ e.unclip();
1272
+
1273
+ // Points + hit regions (only when not decimated)
1274
+ if (renderData === data && data.length <= 50) {
1275
+ const pr = t.pointRadius;
1276
+ e.circles(points, color, pr);
1277
+ for (const p of points) {
1278
+ this.hits.add({
1279
+ _id: p.i, x: p.x - 8, y: p.y - 8, w: 16, h: 16,
1280
+ tipX: p.x, tipY: p.y - pr,
1281
+ tooltip: `${labelOf(p.d, p.i)}: ${valueOf(p.d, p.i)}`,
1282
+ });
1283
+ }
1284
+ } else {
1285
+ // Add invisible hit columns for decimated data.
1286
+ const colW = ax.pw / renderData.length;
1287
+ for (let i = 0; i < renderData.length; i++) {
1288
+ const p = points[i];
1289
+ this.hits.add({
1290
+ _id: p.i, x: p.x - colW / 2, y: ax.py, w: colW, h: ax.ph,
1291
+ tipX: p.x, tipY: p.y - 8,
1292
+ tooltip: `${labelOf(p.d, p.i)}: ${valueOf(p.d, p.i)}`,
1293
+ });
1294
+ }
1295
+ }
1296
+
1297
+ if (this._hover) {
1298
+ const p = points.find(pp => pp.i === this._hover._id);
1299
+ if (p) e.circle(p.x, p.y, t.pointRadius + 3, {
1300
+ stroke: color, lineWidth: 2, fill: t.bg,
1301
+ });
1302
+ }
1303
+ }
1304
+ }
1305
+
1306
+ // ─────────────────────────────────────────────────────────
1307
+ // 4. AREA (line with always-filled area, supports stacked)
1308
+ // ─────────────────────────────────────────────────────────
1309
+ class AreaChart extends Chart {
1310
+ _render() {
1311
+ const series = this.options.series || [];
1312
+ const cats = this.options.categories || [];
1313
+ if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
1314
+ const e = this.engine;
1315
+ const t = this.theme;
1316
+ const stacked = this.options.stacked === true;
1317
+
1318
+ // Compute y domain.
1319
+ let yMax;
1320
+ if (stacked) {
1321
+ yMax = 0;
1322
+ for (let i = 0; i < cats.length; i++) {
1323
+ let sum = 0;
1324
+ for (const s of series) sum += +s.values[i] || 0;
1325
+ if (sum > yMax) yMax = sum;
1326
+ }
1327
+ } else {
1328
+ yMax = -Infinity;
1329
+ for (const s of series) for (const v of s.values) if (+v > yMax) yMax = +v;
1330
+ }
1331
+ const [yLo, yHi] = M.niceDomain(0, yMax, 5);
1332
+ const ax = this._drawCartesian({
1333
+ yMin: yLo, yMax: yHi,
1334
+ xLabels: cats,
1335
+ yTickFormat: this.options.yTickFormat,
1336
+ });
1337
+ const lineW = t.lineWidth;
1338
+ const prog = this._anim.progress;
1339
+
1340
+ e.clip(ax.px, ax.py, ax.pw * prog, ax.ph);
1341
+
1342
+ if (stacked) {
1343
+ // Build cumulative bands bottom-up.
1344
+ const cum = new Array(cats.length).fill(0);
1345
+ const seriesPts = [];
1346
+ for (let s = 0; s < series.length; s++) {
1347
+ const ser = series[s];
1348
+ const ptsTop = [], ptsBot = [];
1349
+ for (let i = 0; i < cats.length; i++) {
1350
+ const v = +ser.values[i] || 0;
1351
+ const x = ax.px + ((i + 0.5) / cats.length) * ax.pw;
1352
+ const yBot = ax.yToPx(cum[i]);
1353
+ const yTop = ax.yToPx(cum[i] + v);
1354
+ ptsTop.push({ x, y: yTop });
1355
+ ptsBot.push({ x, y: yBot });
1356
+ cum[i] += v;
1357
+ }
1358
+ const color = this._colorOf(s, ser);
1359
+ const cps = M.bezierControlPoints(ptsTop);
1360
+ // Area: bezier top + linear bottom (reversed)
1361
+ const ctx = e.ctx;
1362
+ ctx.beginPath();
1363
+ ctx.moveTo(ptsTop[0].x, ptsTop[0].y);
1364
+ for (let i = 0; i < cps.length; i++) {
1365
+ ctx.bezierCurveTo(cps[i].c1x, cps[i].c1y, cps[i].c2x, cps[i].c2y, ptsTop[i+1].x, ptsTop[i+1].y);
1366
+ }
1367
+ for (let i = ptsBot.length - 1; i >= 0; i--) ctx.lineTo(ptsBot[i].x, ptsBot[i].y);
1368
+ ctx.closePath();
1369
+ ctx.fillStyle = M.colorAlpha(color, 0.7);
1370
+ ctx.fill();
1371
+ ctx.strokeStyle = color;
1372
+ ctx.lineWidth = lineW;
1373
+ ctx.stroke();
1374
+ seriesPts.push({ ser, color, ptsTop });
1375
+ }
1376
+ } else {
1377
+ for (let s = 0; s < series.length; s++) {
1378
+ const ser = series[s];
1379
+ const pts = [];
1380
+ for (let i = 0; i < cats.length; i++) {
1381
+ pts.push({
1382
+ x: ax.px + ((i + 0.5) / cats.length) * ax.pw,
1383
+ y: ax.yToPx(+ser.values[i] || 0),
1384
+ });
1385
+ }
1386
+ const color = this._colorOf(s, ser);
1387
+ const cps = M.bezierControlPoints(pts);
1388
+ const grad = e.vGradient(`area-${s}-${color}`, ax.px, ax.py, ax.py + ax.ph, [
1389
+ [0, M.colorAlpha(color, 0.4)],
1390
+ [1, M.colorAlpha(color, 0)],
1391
+ ]);
1392
+ e.smoothArea(pts, cps, ax.py + ax.ph, grad);
1393
+ e.smooth(pts, cps, { stroke: color, lineWidth: lineW });
1394
+ }
1395
+ }
1396
+
1397
+ e.unclip();
1398
+ this._drawLegend(series.map((s, i) => ({
1399
+ label: s.name || 'Series ' + (i + 1),
1400
+ color: this._colorOf(i, s), idx: i, hidden: false,
1401
+ })), ax.pw);
1402
+ }
1403
+ }
1404
+
1405
+ // ─────────────────────────────────────────────────────────
1406
+ // 5. STACKED BAR
1407
+ // ─────────────────────────────────────────────────────────
1408
+ class StackedBarChart extends Chart {
1409
+ _render() {
1410
+ const series = this.options.series || [];
1411
+ const cats = this.options.categories || [];
1412
+ if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
1413
+ const e = this.engine;
1414
+ const t = this.theme;
1415
+
1416
+ const totals = cats.map((_, i) => series.reduce((a, s) => a + (+s.values[i] || 0), 0));
1417
+ const [yLo, yHi] = M.niceDomain(0, Math.max(...totals), 5);
1418
+ const ax = this._drawCartesian({
1419
+ yMin: yLo, yMax: yHi,
1420
+ xLabels: cats,
1421
+ yTickFormat: this.options.yTickFormat,
1422
+ });
1423
+
1424
+ const slot = ax.pw / cats.length;
1425
+ const barW = Math.max(2, Math.min(50, slot - 10));
1426
+ const prog = this._anim.progress;
1427
+ const groups = new Map();
1428
+
1429
+ for (let i = 0; i < cats.length; i++) {
1430
+ let cum = 0;
1431
+ const x = ax.px + (i + 0.5) * slot - barW / 2;
1432
+ for (let s = 0; s < series.length; s++) {
1433
+ const v = +series[s].values[i] || 0;
1434
+ if (v <= 0) continue;
1435
+ const segH = ((v / yHi) * ax.ph) * prog;
1436
+ const y = ax.yToPx(cum + v);
1437
+ cum += v;
1438
+ const color = this._colorOf(s, series[s]);
1439
+ if (!groups.has(color)) groups.set(color, []);
1440
+ const isTop = s === series.length - 1;
1441
+ groups.get(color).push({
1442
+ x, y, w: barW, h: segH,
1443
+ radius: isTop ? t.barRadius : 0,
1444
+ });
1445
+ this.hits.add({
1446
+ _id: `${i}-${s}`, x, y, w: barW, h: segH,
1447
+ tipX: x + barW / 2, tipY: y,
1448
+ tooltip: {
1449
+ title: cats[i],
1450
+ rows: [{
1451
+ color, name: series[s].name || 'Series ' + (s + 1), value: v,
1452
+ }],
1453
+ },
1454
+ });
1455
+ }
1456
+ }
1457
+ for (const [color, items] of groups) e.rects(items, color);
1458
+
1459
+ this._drawLegend(series.map((s, i) => ({
1460
+ label: s.name || 'Series ' + (i + 1),
1461
+ color: this._colorOf(i, s), idx: i, hidden: false,
1462
+ })), ax.pw);
1463
+ }
1464
+ }
1465
+
1466
+ // ─────────────────────────────────────────────────────────
1467
+ // 6. GROUPED BAR (clustered)
1468
+ // ─────────────────────────────────────────────────────────
1469
+ class GroupedBarChart extends Chart {
1470
+ _render() {
1471
+ const series = this.options.series || [];
1472
+ const cats = this.options.categories || [];
1473
+ if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
1474
+ const e = this.engine;
1475
+ const t = this.theme;
1476
+
1477
+ let yMax = -Infinity;
1478
+ for (const s of series) for (const v of s.values) if (+v > yMax) yMax = +v;
1479
+ const [yLo, yHi] = M.niceDomain(0, yMax, 5);
1480
+ const ax = this._drawCartesian({
1481
+ yMin: yLo, yMax: yHi,
1482
+ xLabels: cats,
1483
+ yTickFormat: this.options.yTickFormat,
1484
+ });
1485
+
1486
+ const slot = ax.pw / cats.length;
1487
+ const inner = slot * 0.8;
1488
+ const barW = Math.max(2, inner / series.length);
1489
+ const prog = this._anim.progress;
1490
+ const groups = new Map();
1491
+
1492
+ for (let i = 0; i < cats.length; i++) {
1493
+ const xCenter = ax.px + (i + 0.5) * slot;
1494
+ const left = xCenter - inner / 2;
1495
+ for (let s = 0; s < series.length; s++) {
1496
+ const v = +series[s].values[i] || 0;
1497
+ const x = left + s * barW;
1498
+ const h = ((v / yHi) * ax.ph) * prog;
1499
+ const y = ax.yToPx(v);
1500
+ const color = this._colorOf(s, series[s]);
1501
+ if (!groups.has(color)) groups.set(color, []);
1502
+ groups.get(color).push({ x, y, w: barW - 1, h, radius: t.barRadius });
1503
+ this.hits.add({
1504
+ _id: `${i}-${s}`, x, y, w: barW - 1, h,
1505
+ tipX: x + barW / 2, tipY: y,
1506
+ tooltip: `${cats[i]} · ${series[s].name || s}: ${v}`,
1507
+ });
1508
+ }
1509
+ }
1510
+ for (const [color, items] of groups) e.rects(items, color);
1511
+
1512
+ this._drawLegend(series.map((s, i) => ({
1513
+ label: s.name || 'Series ' + (i + 1),
1514
+ color: this._colorOf(i, s), idx: i, hidden: false,
1515
+ })), ax.pw);
1516
+ }
1517
+ }
1518
+
1519
+ // ─────────────────────────────────────────────────────────
1520
+ // 7. MULTI-LINE (multiple series, shared x)
1521
+ // ─────────────────────────────────────────────────────────
1522
+ class MultiLineChart extends Chart {
1523
+ _render() {
1524
+ const series = this.options.series || [];
1525
+ const cats = this.options.categories || [];
1526
+ if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
1527
+ const e = this.engine;
1528
+ const t = this.theme;
1529
+ const interp = this.options.interpolation || 'smooth';
1530
+
1531
+ let yMax = -Infinity, yMin = Infinity;
1532
+ for (const s of series) for (const v of s.values) {
1533
+ if (+v > yMax) yMax = +v;
1534
+ if (+v < yMin) yMin = +v;
1535
+ }
1536
+ const [yLo, yHi] = M.niceDomain(Math.min(0, yMin), yMax, 5);
1537
+ const useTime = this.options.xScale === 'time';
1538
+ let spec = {
1539
+ yMin: yLo, yMax: yHi,
1540
+ yTickFormat: this.options.yTickFormat,
1541
+ xTickFormat: this.options.xTickFormat,
1542
+ };
1543
+ if (useTime) {
1544
+ spec.xTime = { min: +cats[0], max: +cats[cats.length - 1] };
1545
+ } else {
1546
+ spec.xLabels = cats;
1547
+ }
1548
+ const ax = this._drawCartesian(spec);
1549
+ const lineW = t.lineWidth;
1550
+ const prog = this._anim.progress;
1551
+
1552
+ e.clip(ax.px, ax.py, ax.pw * prog, ax.ph);
1553
+ const allPts = [];
1554
+ for (let s = 0; s < series.length; s++) {
1555
+ const ser = series[s];
1556
+ if (ser.hidden) continue;
1557
+ const color = this._colorOf(s, ser);
1558
+ const pts = ser.values.map((v, i) => ({
1559
+ x: useTime ? ax.xToPx(+cats[i]) : ax.px + ((i + 0.5) / cats.length) * ax.pw,
1560
+ y: ax.yToPx(+v),
1561
+ i, s, v,
1562
+ }));
1563
+ allPts.push({ pts, color, s, ser });
1564
+ if (interp === 'smooth') {
1565
+ const cps = M.bezierControlPoints(pts);
1566
+ e.smooth(pts, cps, { stroke: color, lineWidth: lineW });
1567
+ } else if (interp === 'linear') {
1568
+ e.poly(pts, { stroke: color, lineWidth: lineW, cap: 'round', join: 'round' });
1569
+ } else {
1570
+ const mode = interp === 'step' ? 'before' : interp.split('-')[1];
1571
+ e.step(pts, mode, { stroke: color, lineWidth: lineW });
1572
+ }
1573
+ if (ser.values.length <= 50) e.circles(pts, color, t.pointRadius);
1574
+ }
1575
+ e.unclip();
1576
+
1577
+ // Shared crosshair tooltip
1578
+ if (this._cursor && allPts.length) {
1579
+ const cx = this._cursor.x;
1580
+ if (cx >= ax.px && cx <= ax.px + ax.pw) {
1581
+ // Find nearest category
1582
+ const i = useTime
1583
+ ? Math.round(((cx - ax.px) / ax.pw) * (cats.length - 1))
1584
+ : Math.floor((cx - ax.px) / (ax.pw / cats.length));
1585
+ const idx = Math.max(0, Math.min(cats.length - 1, i));
1586
+ const xPx = useTime
1587
+ ? ax.xToPx(+cats[idx])
1588
+ : ax.px + (idx + 0.5) * (ax.pw / cats.length);
1589
+ // Crosshair line
1590
+ e.poly([{x: xPx, y: ax.py}, {x: xPx, y: ax.py + ax.ph}], {
1591
+ stroke: t.muted, lineWidth: 1, dash: [3, 3],
1592
+ });
1593
+ // Snap markers
1594
+ const rows = [];
1595
+ for (const sp of allPts) {
1596
+ const p = sp.pts[idx];
1597
+ if (!p) continue;
1598
+ e.circle(p.x, p.y, t.pointRadius + 2, {
1599
+ fill: t.bg, stroke: sp.color, lineWidth: 2,
1600
+ });
1601
+ rows.push({
1602
+ color: sp.color,
1603
+ name: sp.ser.name || 'Series ' + (sp.s + 1),
1604
+ value: sp.ser.values[idx],
1605
+ });
1606
+ }
1607
+ const title = useTime
1608
+ ? new Date(+cats[idx]).toISOString().slice(0, 19).replace('T', ' ')
1609
+ : String(cats[idx]);
1610
+ this.tooltip.show(xPx, ax.py + 8, { title, rows }, t);
1611
+ this._sharedTooltip = true;
1612
+ }
1613
+ }
1614
+
1615
+ this._drawLegend(series.map((s, i) => ({
1616
+ label: s.name || 'Series ' + (i + 1),
1617
+ color: this._colorOf(i, s), idx: i, hidden: !!s.hidden,
1618
+ })), ax.pw);
1619
+ }
1620
+ }
1621
+
1622
+ // ─────────────────────────────────────────────────────────
1623
+ // 8. SCATTER
1624
+ // ─────────────────────────────────────────────────────────
1625
+ class ScatterChart extends Chart {
1626
+ _render() {
1627
+ const data = this.options.data || [];
1628
+ if (!data.length) { this.setEmpty('No data'); return; }
1629
+ const e = this.engine;
1630
+ const t = this.theme;
1631
+ let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
1632
+ for (const d of data) {
1633
+ if (d.x < xMin) xMin = d.x; if (d.x > xMax) xMax = d.x;
1634
+ if (d.y < yMin) yMin = d.y; if (d.y > yMax) yMax = d.y;
1635
+ }
1636
+ const [xLo, xHi] = M.niceDomain(xMin, xMax, 6);
1637
+ const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
1638
+ const ax = this._drawCartesian({
1639
+ yMin: yLo, yMax: yHi,
1640
+ xLinear: { min: xLo, max: xHi },
1641
+ });
1642
+ const prog = this._anim.progress;
1643
+ const groups = new Map();
1644
+ const defaultR = t.pointRadius + 1;
1645
+
1646
+ for (let i = 0; i < data.length; i++) {
1647
+ const d = data[i];
1648
+ const cx = ax.xToPx(d.x);
1649
+ const cy = ax.yToPx(d.y);
1650
+ const r = (d.r || defaultR) * prog;
1651
+ const g = d.group != null ? d.group : 0;
1652
+ const color = this._colorOf(g, { color: d.color });
1653
+ const key = color + ':' + r.toFixed(1);
1654
+ if (!groups.has(key)) groups.set(key, { color, r, items: [] });
1655
+ groups.get(key).items.push({ x: cx, y: cy });
1656
+ this.hits.add({
1657
+ _id: i, x: cx - r - 2, y: cy - r - 2, w: r * 2 + 4, h: r * 2 + 4,
1658
+ tipX: cx, tipY: cy - r,
1659
+ tooltip: `(${d.x}, ${d.y})${d.label ? ` · ${d.label}` : ''}`,
1660
+ });
1661
+ }
1662
+ e.withAlpha(0.7, () => {
1663
+ for (const { color, r, items } of groups.values()) e.circles(items, color, r);
1664
+ });
1665
+ }
1666
+ }
1667
+
1668
+ // ─────────────────────────────────────────────────────────
1669
+ // 9. BUBBLE (scatter with explicit z radius)
1670
+ // ─────────────────────────────────────────────────────────
1671
+ class BubbleChart extends Chart {
1672
+ _render() {
1673
+ const data = this.options.data || [];
1674
+ if (!data.length) { this.setEmpty('No data'); return; }
1675
+ const e = this.engine;
1676
+ const t = this.theme;
1677
+ let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity, zMax = 0;
1678
+ for (const d of data) {
1679
+ if (d.x < xMin) xMin = d.x; if (d.x > xMax) xMax = d.x;
1680
+ if (d.y < yMin) yMin = d.y; if (d.y > yMax) yMax = d.y;
1681
+ if ((d.z || 0) > zMax) zMax = d.z;
1682
+ }
1683
+ const [xLo, xHi] = M.niceDomain(xMin, xMax, 6);
1684
+ const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
1685
+ const ax = this._drawCartesian({
1686
+ yMin: yLo, yMax: yHi,
1687
+ xLinear: { min: xLo, max: xHi },
1688
+ });
1689
+ const prog = this._anim.progress;
1690
+ const maxR = 32, minR = 4;
1691
+
1692
+ for (let i = 0; i < data.length; i++) {
1693
+ const d = data[i];
1694
+ const cx = ax.xToPx(d.x);
1695
+ const cy = ax.yToPx(d.y);
1696
+ const r = (minR + (Math.sqrt((d.z || 0) / Math.max(1, zMax))) * (maxR - minR)) * prog;
1697
+ const color = d.color || this._colorOf(d.group != null ? d.group : i);
1698
+ e.circle(cx, cy, r, {
1699
+ fill: M.colorAlpha(color, 0.5),
1700
+ stroke: color, lineWidth: 1.5,
1701
+ });
1702
+ this.hits.add({
1703
+ _id: i, x: cx - r, y: cy - r, w: r * 2, h: r * 2,
1704
+ tipX: cx, tipY: cy - r,
1705
+ tooltip: `${d.label || ''} (${d.x}, ${d.y}) · z=${d.z}`,
1706
+ });
1707
+ }
1708
+ }
1709
+ }
1710
+
1711
+ // ─────────────────────────────────────────────────────────
1712
+ // 10. PIE / DONUT
1713
+ // ─────────────────────────────────────────────────────────
1714
+ class PieChart extends Chart {
1715
+ _render() {
1716
+ const data = this.options.data || [];
1717
+ if (!data.length) { this.setEmpty('No data'); return; }
1718
+ const e = this.engine;
1719
+ const t = this.theme;
1720
+ const labelOf = this.options.label || (d => d.label);
1721
+ const valueOf = this.options.value || (d => d.value);
1722
+ const total = data.reduce((a, d, i) => a + valueOf(d, i), 0);
1723
+ if (total <= 0) { this.setEmpty('No data'); return; }
1724
+
1725
+ const cx = e.w / 2, cy = e.h / 2 + 4;
1726
+ const R = Math.min(e.w, e.h) / 2 - 24;
1727
+ const innerR = this.options.innerRadius != null
1728
+ ? (typeof this.options.innerRadius === 'number'
1729
+ ? this.options.innerRadius
1730
+ : R * 0.55)
1731
+ : 0;
1732
+ const prog = this._anim.progress;
1733
+ let a0 = -Math.PI / 2;
1734
+ for (let i = 0; i < data.length; i++) {
1735
+ const v = valueOf(data[i], i);
1736
+ const sweep = (v / total) * TAU * prog;
1737
+ const a1 = a0 + sweep;
1738
+ const color = this._colorOf(i, data[i]);
1739
+ const isHover = this._hover && this._hover._id === i;
1740
+ const offset = isHover ? 6 : 0;
1741
+ const mid = (a0 + a1) / 2;
1742
+ const ox = Math.cos(mid) * offset, oy = Math.sin(mid) * offset;
1743
+ e.arc(cx + ox, cy + oy, innerR, R, a0, a1, { fill: color });
1744
+ // Hit region: approximate with bounding box around sector midpoint
1745
+ const midR = (innerR + R) / 2;
1746
+ const hx = cx + Math.cos(mid) * midR;
1747
+ const hy = cy + Math.sin(mid) * midR;
1748
+ this.hits.add({
1749
+ _id: i, x: hx - 12, y: hy - 12, w: 24, h: 24,
1750
+ tipX: hx, tipY: hy - 8,
1751
+ tooltip: `${labelOf(data[i], i)}: ${v} (${(v / total * 100).toFixed(1)}%)`,
1752
+ });
1753
+ a0 = a1;
1754
+ }
1755
+ // Center label for donut
1756
+ if (innerR > 0 && this.options.centerLabel !== false) {
1757
+ const center = this._hover
1758
+ ? `${(valueOf(data[this._hover._id], this._hover._id) / total * 100).toFixed(0)}%`
1759
+ : (this.options.totalLabel || total.toLocaleString());
1760
+ e.text(center, cx, cy, {
1761
+ fill: t.text, font: `600 ${t.fontSize * 1.6}px ${t.font}`,
1762
+ align: 'center', baseline: 'middle',
1763
+ });
1764
+ }
1765
+ }
1766
+ }
1767
+
1768
+ // ─────────────────────────────────────────────────────────
1769
+ // 11. RADAR
1770
+ // ─────────────────────────────────────────────────────────
1771
+ class RadarChart extends Chart {
1772
+ _render() {
1773
+ const series = this.options.series || [];
1774
+ const axes = this.options.axes || [];
1775
+ if (!series.length || !axes.length) { this.setEmpty('No data'); return; }
1776
+ const e = this.engine;
1777
+ const t = this.theme;
1778
+ const cx = e.w / 2;
1779
+ const cy = e.h / 2 + 4;
1780
+ const R = Math.min(e.w, e.h) / 2 - 32;
1781
+ const N = axes.length;
1782
+ const angle = i => -Math.PI / 2 + (i / N) * TAU;
1783
+
1784
+ let max = -Infinity;
1785
+ for (const s of series) for (const v of s.values) if (+v > max) max = +v;
1786
+ const yLo = 0;
1787
+ const niceMax = M.niceDomain(0, max, 5)[1];
1788
+
1789
+ // Concentric rings
1790
+ const rings = M.niceTicks(0, niceMax, 4).filter(v => v > 0);
1791
+ e.ctx.strokeStyle = t.grid;
1792
+ e.ctx.lineWidth = 1;
1793
+ for (const v of rings) {
1794
+ const r = (v / niceMax) * R;
1795
+ e.ctx.beginPath();
1796
+ for (let i = 0; i <= N; i++) {
1797
+ const a = angle(i % N);
1798
+ const x = cx + Math.cos(a) * r;
1799
+ const y = cy + Math.sin(a) * r;
1800
+ if (i === 0) e.ctx.moveTo(x, y); else e.ctx.lineTo(x, y);
1801
+ }
1802
+ e.ctx.stroke();
1803
+ }
1804
+ // Spokes
1805
+ e.ctx.strokeStyle = t.grid;
1806
+ for (let i = 0; i < N; i++) {
1807
+ const a = angle(i);
1808
+ e.ctx.beginPath();
1809
+ e.ctx.moveTo(cx, cy);
1810
+ e.ctx.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
1811
+ e.ctx.stroke();
1812
+ }
1813
+ // Axis labels
1814
+ const font = `${t.fontSize}px ${t.font}`;
1815
+ for (let i = 0; i < N; i++) {
1816
+ const a = angle(i);
1817
+ const lx = cx + Math.cos(a) * (R + 14);
1818
+ const ly = cy + Math.sin(a) * (R + 14);
1819
+ e.text(axes[i], lx, ly, {
1820
+ fill: t.muted, font,
1821
+ align: Math.cos(a) > 0.3 ? 'left' : Math.cos(a) < -0.3 ? 'right' : 'center',
1822
+ baseline: Math.sin(a) > 0.3 ? 'top' : Math.sin(a) < -0.3 ? 'bottom' : 'middle',
1823
+ });
1824
+ }
1825
+ const prog = this._anim.progress;
1826
+ for (let s = 0; s < series.length; s++) {
1827
+ const ser = series[s];
1828
+ const color = this._colorOf(s, ser);
1829
+ const pts = [];
1830
+ for (let i = 0; i < N; i++) {
1831
+ const v = (+ser.values[i] || 0) * prog;
1832
+ const r = (v / niceMax) * R;
1833
+ const a = angle(i);
1834
+ pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
1835
+ }
1836
+ e.poly(pts, {
1837
+ fill: M.colorAlpha(color, 0.25),
1838
+ stroke: color, lineWidth: 2, close: true,
1839
+ });
1840
+ e.circles(pts, color, t.pointRadius);
1841
+ }
1842
+ this._drawLegend(series.map((s, i) => ({
1843
+ label: s.name || 'Series ' + (i + 1),
1844
+ color: this._colorOf(i, s), idx: i, hidden: false,
1845
+ })), e.w - 16);
1846
+ }
1847
+ }
1848
+
1849
+ // ─────────────────────────────────────────────────────────
1850
+ // 12. POLAR (radial bars)
1851
+ // ─────────────────────────────────────────────────────────
1852
+ class PolarChart extends Chart {
1853
+ _render() {
1854
+ const data = this.options.data || [];
1855
+ if (!data.length) { this.setEmpty('No data'); return; }
1856
+ const e = this.engine;
1857
+ const t = this.theme;
1858
+ const labelOf = this.options.label || (d => d.label);
1859
+ const valueOf = this.options.value || (d => d.value);
1860
+ const N = data.length;
1861
+ const cx = e.w / 2, cy = e.h / 2 + 4;
1862
+ const R = Math.min(e.w, e.h) / 2 - 24;
1863
+ const max = Math.max(...data.map((d, i) => valueOf(d, i)));
1864
+ const prog = this._anim.progress;
1865
+
1866
+ for (let i = 0; i < N; i++) {
1867
+ const v = valueOf(data[i], i);
1868
+ const a0 = -Math.PI / 2 + (i / N) * TAU;
1869
+ const a1 = a0 + (TAU / N) * 0.92;
1870
+ const r = (v / max) * R * prog;
1871
+ const color = this._colorOf(i, data[i]);
1872
+ e.arc(cx, cy, 0, r, a0, a1, { fill: color });
1873
+ // Label
1874
+ const mid = (a0 + a1) / 2;
1875
+ const lx = cx + Math.cos(mid) * (R + 12);
1876
+ const ly = cy + Math.sin(mid) * (R + 12);
1877
+ e.text(labelOf(data[i], i), lx, ly, {
1878
+ fill: t.muted, font: `${t.fontSize}px ${t.font}`,
1879
+ align: 'center', baseline: 'middle',
1880
+ });
1881
+ }
1882
+ }
1883
+ }
1884
+
1885
+ // ─────────────────────────────────────────────────────────
1886
+ // 13. GAUGE
1887
+ // ─────────────────────────────────────────────────────────
1888
+ class GaugeChart extends Chart {
1889
+ setValue(v) {
1890
+ this.options.value = v;
1891
+ this._anim.start = performance.now();
1892
+ this._anim.progress = 0;
1893
+ this._dirty = true;
1894
+ scheduler.schedule(this);
1895
+ return this;
1896
+ }
1897
+ _render() {
1898
+ const e = this.engine;
1899
+ const t = this.theme;
1900
+ const val = this.options.value || 0;
1901
+ const max = this.options.max || 100;
1902
+ const cx = e.w / 2;
1903
+ const cy = e.h * 0.7;
1904
+ const R = Math.min(e.w / 2, e.h * 0.7) - 12;
1905
+ const r = R - 18;
1906
+ const a0 = Math.PI * 1.1;
1907
+ const a1 = Math.PI * 1.9;
1908
+ const span = a1 - a0;
1909
+ const prog = this._anim.progress;
1910
+ const color = this.options.color || this.palette[0];
1911
+ // Track
1912
+ e.arc(cx, cy, r, R, a0, a1, { fill: t.grid });
1913
+ // Filled
1914
+ const aEnd = a0 + span * (val / max) * prog;
1915
+ if (aEnd > a0) e.arc(cx, cy, r, R, a0, aEnd, { fill: color });
1916
+ // Number
1917
+ const num = Math.round(val);
1918
+ const unit = this.options.unit || '';
1919
+ e.text(`${num}${unit}`, cx, cy, {
1920
+ fill: t.text, font: `700 ${R / 2.5}px ${t.font}`,
1921
+ align: 'center', baseline: 'middle',
1922
+ });
1923
+ if (this.options.label) {
1924
+ e.text(this.options.label, cx, cy + R / 2.5, {
1925
+ fill: t.muted, font: `${t.fontSize}px ${t.font}`,
1926
+ align: 'center', baseline: 'top',
1927
+ });
1928
+ }
1929
+ }
1930
+ }
1931
+
1932
+ // ─────────────────────────────────────────────────────────
1933
+ // 14. HEATMAP
1934
+ // ─────────────────────────────────────────────────────────
1935
+ class HeatmapChart extends Chart {
1936
+ _axisPad() { return { l: 60, r: 16, t: 16, b: 40 }; }
1937
+ _render() {
1938
+ const data = this.options.data || []; // [{ x, y, value }]
1939
+ const xLabels = this.options.xLabels || [];
1940
+ const yLabels = this.options.yLabels || [];
1941
+ if (!data.length || !xLabels.length || !yLabels.length) {
1942
+ this.setEmpty('No data'); return;
1943
+ }
1944
+ const e = this.engine;
1945
+ const t = this.theme;
1946
+ const pad = this._axisPad();
1947
+ const px = pad.l, py = pad.t;
1948
+ const pw = e.w - pad.l - pad.r;
1949
+ const ph = e.h - pad.t - pad.b;
1950
+ const font = `${t.fontSize}px ${t.font}`;
1951
+
1952
+ let vMax = -Infinity, vMin = Infinity;
1953
+ for (const d of data) {
1954
+ if (d.value > vMax) vMax = d.value;
1955
+ if (d.value < vMin) vMin = d.value;
1956
+ }
1957
+ const cellW = pw / xLabels.length;
1958
+ const cellH = ph / yLabels.length;
1959
+ const lo = this.options.colorLow || M.colorAlpha(this.palette[0], 0.1);
1960
+ const hi = this.options.colorHigh || this.palette[0];
1961
+ const gap = 1;
1962
+ const prog = this._anim.progress;
1963
+
1964
+ for (const d of data) {
1965
+ const xi = typeof d.x === 'number' ? d.x : xLabels.indexOf(d.x);
1966
+ const yi = typeof d.y === 'number' ? d.y : yLabels.indexOf(d.y);
1967
+ if (xi < 0 || yi < 0) continue;
1968
+ const x = px + xi * cellW;
1969
+ const y = py + yi * cellH;
1970
+ const tNorm = vMax > vMin ? (d.value - vMin) / (vMax - vMin) : 0.5;
1971
+ const fill = M.colorMix(lo, hi, tNorm * prog);
1972
+ e.rect(x + gap, y + gap, cellW - gap * 2, cellH - gap * 2, { fill, radius: 2 });
1973
+ this.hits.add({
1974
+ _id: `${xi}-${yi}`, x, y, w: cellW, h: cellH,
1975
+ tipX: x + cellW / 2, tipY: y,
1976
+ tooltip: `${xLabels[xi]} · ${yLabels[yi]}: ${d.value}`,
1977
+ });
1978
+ }
1979
+ // X labels
1980
+ for (let i = 0; i < xLabels.length; i++) {
1981
+ e.text(xLabels[i], px + (i + 0.5) * cellW, py + ph + 4, {
1982
+ fill: t.muted, font, align: 'center', baseline: 'top',
1983
+ });
1984
+ }
1985
+ // Y labels
1986
+ for (let i = 0; i < yLabels.length; i++) {
1987
+ e.text(yLabels[i], px - 6, py + (i + 0.5) * cellH, {
1988
+ fill: t.muted, font, align: 'right', baseline: 'middle',
1989
+ });
1990
+ }
1991
+ }
1992
+ }
1993
+
1994
+ // ─────────────────────────────────────────────────────────
1995
+ // 15. BOXPLOT
1996
+ // ─────────────────────────────────────────────────────────
1997
+ class BoxplotChart extends Chart {
1998
+ _render() {
1999
+ const data = this.options.data || []; // [{ label, q1, median, q3, min, max, outliers? }]
2000
+ if (!data.length) { this.setEmpty('No data'); return; }
2001
+ const e = this.engine;
2002
+ const t = this.theme;
2003
+ let yMax = -Infinity, yMin = Infinity;
2004
+ for (const d of data) {
2005
+ if (d.max > yMax) yMax = d.max; if (d.min < yMin) yMin = d.min;
2006
+ if (d.outliers) for (const o of d.outliers) {
2007
+ if (o > yMax) yMax = o; if (o < yMin) yMin = o;
2008
+ }
2009
+ }
2010
+ const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
2011
+ const ax = this._drawCartesian({
2012
+ yMin: yLo, yMax: yHi,
2013
+ xLabels: data.map(d => d.label),
2014
+ });
2015
+ const slot = ax.pw / data.length;
2016
+ const boxW = Math.min(40, slot * 0.5);
2017
+
2018
+ for (let i = 0; i < data.length; i++) {
2019
+ const d = data[i];
2020
+ const cx = ax.px + (i + 0.5) * slot;
2021
+ const yMin = ax.yToPx(d.min);
2022
+ const yMax = ax.yToPx(d.max);
2023
+ const yQ1 = ax.yToPx(d.q1);
2024
+ const yQ3 = ax.yToPx(d.q3);
2025
+ const yMed = ax.yToPx(d.median);
2026
+ const color = this._colorOf(i, d);
2027
+ // Whiskers
2028
+ e.poly([{x: cx, y: yMax}, {x: cx, y: yMin}], { stroke: color, lineWidth: 1 });
2029
+ e.poly([{x: cx - boxW / 3, y: yMax}, {x: cx + boxW / 3, y: yMax}], { stroke: color, lineWidth: 1 });
2030
+ e.poly([{x: cx - boxW / 3, y: yMin}, {x: cx + boxW / 3, y: yMin}], { stroke: color, lineWidth: 1 });
2031
+ // Box
2032
+ e.rect(cx - boxW / 2, yQ3, boxW, yQ1 - yQ3, {
2033
+ fill: M.colorAlpha(color, 0.4),
2034
+ stroke: color, lineWidth: 1.5,
2035
+ });
2036
+ // Median line
2037
+ e.poly([{x: cx - boxW / 2, y: yMed}, {x: cx + boxW / 2, y: yMed}], {
2038
+ stroke: color, lineWidth: 2,
2039
+ });
2040
+ // Outliers
2041
+ if (d.outliers) {
2042
+ e.circles(d.outliers.map(v => ({ x: cx, y: ax.yToPx(v) })), color, 2);
2043
+ }
2044
+ this.hits.add({
2045
+ _id: i, x: cx - boxW / 2, y: yQ3, w: boxW, h: yQ1 - yQ3,
2046
+ tipX: cx, tipY: yQ3,
2047
+ tooltip: {
2048
+ title: d.label,
2049
+ rows: [
2050
+ { name: 'min', value: d.min },
2051
+ { name: 'Q1', value: d.q1 },
2052
+ { name: 'med', value: d.median },
2053
+ { name: 'Q3', value: d.q3 },
2054
+ { name: 'max', value: d.max },
2055
+ ],
2056
+ },
2057
+ });
2058
+ }
2059
+ }
2060
+ }
2061
+
2062
+ // ─────────────────────────────────────────────────────────
2063
+ // 16. FUNNEL
2064
+ // ─────────────────────────────────────────────────────────
2065
+ class FunnelChart extends Chart {
2066
+ _render() {
2067
+ const data = this.options.data || [];
2068
+ if (!data.length) { this.setEmpty('No data'); return; }
2069
+ const e = this.engine;
2070
+ const t = this.theme;
2071
+ const labelOf = this.options.label || (d => d.label);
2072
+ const valueOf = this.options.value || (d => d.value);
2073
+ const N = data.length;
2074
+ const max = Math.max(...data.map((d, i) => valueOf(d, i)));
2075
+ const pad = 24;
2076
+ const w = e.w - pad * 2;
2077
+ const segH = (e.h - pad * 2) / N;
2078
+ const cx = e.w / 2;
2079
+ const prog = this._anim.progress;
2080
+ const font = `${t.fontSize}px ${t.font}`;
2081
+
2082
+ for (let i = 0; i < N; i++) {
2083
+ const v = valueOf(data[i], i);
2084
+ const wTop = (v / max) * w * prog;
2085
+ const wBot = i + 1 < N
2086
+ ? (valueOf(data[i + 1], i + 1) / max) * w * prog
2087
+ : wTop * 0.85;
2088
+ const yTop = pad + i * segH;
2089
+ const yBot = yTop + segH - 2;
2090
+ const color = this._colorOf(i, data[i]);
2091
+ const trap = [
2092
+ { x: cx - wTop / 2, y: yTop },
2093
+ { x: cx + wTop / 2, y: yTop },
2094
+ { x: cx + wBot / 2, y: yBot },
2095
+ { x: cx - wBot / 2, y: yBot },
2096
+ ];
2097
+ e.poly(trap, { fill: color, close: true });
2098
+ // Label
2099
+ e.text(`${labelOf(data[i], i)} ${v}`, cx, yTop + segH / 2, {
2100
+ fill: '#fff', font: `600 ${t.fontSize}px ${t.font}`,
2101
+ align: 'center', baseline: 'middle',
2102
+ });
2103
+ this.hits.add({
2104
+ _id: i, x: cx - wTop / 2, y: yTop, w: wTop, h: segH,
2105
+ tipX: cx, tipY: yTop,
2106
+ tooltip: `${labelOf(data[i], i)}: ${v} (${(v / max * 100).toFixed(1)}%)`,
2107
+ });
2108
+ }
2109
+ }
2110
+ }
2111
+
2112
+ // ─────────────────────────────────────────────────────────
2113
+ // 17. TREEMAP
2114
+ // ─────────────────────────────────────────────────────────
2115
+ class TreemapChart extends Chart {
2116
+ _render() {
2117
+ const data = this.options.data || [];
2118
+ if (!data.length) { this.setEmpty('No data'); return; }
2119
+ const e = this.engine;
2120
+ const t = this.theme;
2121
+ const labelOf = this.options.label || (d => d.label);
2122
+ const valueOf = this.options.value || (d => d.value);
2123
+ const items = data.map((d, i) => ({
2124
+ label: labelOf(d, i), value: valueOf(d, i), color: this._colorOf(i, d), data: d, _id: i,
2125
+ })).sort((a, b) => b.value - a.value);
2126
+ const total = items.reduce((a, x) => a + x.value, 0);
2127
+ const rect = { x: 4, y: 4, w: e.w - 8, h: e.h - 8 };
2128
+ const prog = this._anim.progress;
2129
+ squarify(items, total, rect, (it, r) => {
2130
+ const padIn = 2;
2131
+ const x = r.x + padIn, y = r.y + padIn;
2132
+ const w = (r.w - padIn * 2) * prog;
2133
+ const h = (r.h - padIn * 2) * prog;
2134
+ if (w < 1 || h < 1) return;
2135
+ e.rect(x, y, w, h, { fill: it.color, radius: 2 });
2136
+ // Text only if box is big enough.
2137
+ if (w > 60 && h > 30) {
2138
+ e.text(it.label, x + 6, y + 6, {
2139
+ fill: '#fff', font: `600 ${t.fontSize}px ${t.font}`,
2140
+ align: 'left', baseline: 'top',
2141
+ });
2142
+ e.text(String(it.value), x + 6, y + 6 + t.fontSize + 2, {
2143
+ fill: 'rgba(255,255,255,0.85)', font: `${t.fontSize}px ${t.font}`,
2144
+ align: 'left', baseline: 'top',
2145
+ });
2146
+ }
2147
+ this.hits.add({
2148
+ _id: it._id, x, y, w, h,
2149
+ tipX: x + w / 2, tipY: y,
2150
+ tooltip: `${it.label}: ${it.value}`,
2151
+ });
2152
+ });
2153
+ }
2154
+ }
2155
+
2156
+ // Squarified treemap layout (Bruls et al.)
2157
+ function squarify(items, total, rect, draw) {
2158
+ if (!items.length) return;
2159
+ const area = rect.w * rect.h;
2160
+ let i = 0;
2161
+ while (i < items.length) {
2162
+ const remaining = items.slice(i);
2163
+ const remTotal = remaining.reduce((a, x) => a + x.value, 0);
2164
+ if (remTotal <= 0) break;
2165
+ const remArea = (remTotal / total) * area;
2166
+ // Place first row.
2167
+ const w = Math.min(rect.w, rect.h);
2168
+ let row = [], rowSum = 0;
2169
+ let bestRatio = Infinity;
2170
+ for (let j = 0; j < remaining.length; j++) {
2171
+ const next = remaining[j];
2172
+ const trialSum = rowSum + next.value;
2173
+ const trialAreaPx = (trialSum / remTotal) * remArea;
2174
+ const trialW = trialAreaPx / w;
2175
+ let worst = 0;
2176
+ for (const r of [...row, next]) {
2177
+ const a = (r.value / trialSum) * trialAreaPx;
2178
+ const ratio = Math.max(trialW / (a / trialW), (a / trialW) / trialW);
2179
+ if (ratio > worst) worst = ratio;
2180
+ }
2181
+ if (worst > bestRatio) break;
2182
+ bestRatio = worst;
2183
+ row.push(next);
2184
+ rowSum = trialSum;
2185
+ }
2186
+ if (!row.length) row = [remaining[0]], rowSum = remaining[0].value;
2187
+ const isHoriz = rect.w >= rect.h;
2188
+ const rowAreaPx = (rowSum / remTotal) * remArea;
2189
+ const stripT = rowAreaPx / w;
2190
+ let cursor = isHoriz ? rect.y : rect.x;
2191
+ for (const it of row) {
2192
+ const itLen = ((it.value / rowSum) * rowAreaPx) / stripT;
2193
+ const r = isHoriz
2194
+ ? { x: rect.x, y: cursor, w: stripT, h: itLen }
2195
+ : { x: cursor, y: rect.y, w: itLen, h: stripT };
2196
+ // Re-orient: row strip is along the *short* side
2197
+ const r2 = isHoriz
2198
+ ? { x: rect.x, y: cursor, w: stripT, h: itLen }
2199
+ : { x: cursor, y: rect.y, w: itLen, h: stripT };
2200
+ // Actually a simpler placement: put strip on the short edge
2201
+ const placedRect = isHoriz
2202
+ ? { x: rect.x, y: cursor, w: stripT, h: itLen }
2203
+ : { x: cursor, y: rect.y, w: itLen, h: stripT };
2204
+ draw(it, placedRect);
2205
+ cursor += itLen;
2206
+ }
2207
+ if (isHoriz) {
2208
+ rect.x += stripT;
2209
+ rect.w -= stripT;
2210
+ } else {
2211
+ rect.y += stripT;
2212
+ rect.h -= stripT;
2213
+ }
2214
+ i += row.length;
2215
+ if (rect.w < 1 || rect.h < 1) break;
2216
+ }
2217
+ }
2218
+
2219
+ // ─────────────────────────────────────────────────────────
2220
+ // 18. SPARKLINE (mini line, no axes)
2221
+ // ─────────────────────────────────────────────────────────
2222
+ class Sparkline extends Chart {
2223
+ _render() {
2224
+ const data = this.options.data || [];
2225
+ if (!data.length) return;
2226
+ const e = this.engine;
2227
+ const t = this.theme;
2228
+ const valueOf = this.options.value || (v => +v);
2229
+ const values = data.map(valueOf);
2230
+ const yMin = Math.min(...values), yMax = Math.max(...values);
2231
+ const yRange = yMax - yMin || 1;
2232
+ const pad = 2;
2233
+ const w = e.w - pad * 2;
2234
+ const h = e.h - pad * 2;
2235
+ const points = values.map((v, i) => ({
2236
+ x: pad + (i / (values.length - 1 || 1)) * w,
2237
+ y: pad + h - ((v - yMin) / yRange) * h,
2238
+ }));
2239
+ const color = this.options.color || this.palette[0];
2240
+ const cps = M.bezierControlPoints(points);
2241
+ if (this.options.fill !== false) {
2242
+ const grad = e.vGradient(`spark-${color}`, 0, pad, pad + h, [
2243
+ [0, M.colorAlpha(color, 0.3)],
2244
+ [1, M.colorAlpha(color, 0)],
2245
+ ]);
2246
+ e.smoothArea(points, cps, pad + h, grad);
2247
+ }
2248
+ e.smooth(points, cps, { stroke: color, lineWidth: t.lineWidth });
2249
+ if (this.options.lastDot !== false) {
2250
+ const last = points[points.length - 1];
2251
+ e.circle(last.x, last.y, t.pointRadius + 1, { fill: color });
2252
+ }
2253
+ }
2254
+ }
2255
+
2256
+ // ─────────────────────────────────────────────────────────
2257
+ // 19. REALTIME (streaming line, ring buffer)
2258
+ // ─────────────────────────────────────────────────────────
2259
+ class RealtimeChart extends Chart {
2260
+ constructor(container, options = {}) {
2261
+ super(container, options);
2262
+ this.options.windowSize = options.windowSize || 200;
2263
+ this._series = (options.series || []).map(s => ({
2264
+ name: s.name || 'Series', color: s.color, values: [], hidden: false,
2265
+ }));
2266
+ this._streaming = true;
2267
+ }
2268
+ push(values) {
2269
+ const arr = Array.isArray(values) ? values : [values];
2270
+ for (let i = 0; i < this._series.length; i++) {
2271
+ const s = this._series[i];
2272
+ s.values.push(arr[i] != null ? arr[i] : 0);
2273
+ if (s.values.length > this.options.windowSize) s.values.shift();
2274
+ }
2275
+ // Clear any auto-set empty state from a prior render.
2276
+ if (this._state === 'empty') this._state = null;
2277
+ this._dirty = true;
2278
+ scheduler.schedule(this);
2279
+ }
2280
+ clear() {
2281
+ for (const s of this._series) s.values.length = 0;
2282
+ this._dirty = true;
2283
+ scheduler.schedule(this);
2284
+ }
2285
+ _render() {
2286
+ const e = this.engine;
2287
+ const t = this.theme;
2288
+ const hasData = this._series.some(s => s.values.length > 0);
2289
+
2290
+ // No data yet: draw an empty axis grid and an inline hint, but do NOT
2291
+ // call setEmpty() — that would lock us into the overlay state and stop
2292
+ // future _render calls from running.
2293
+ if (!hasData) {
2294
+ const ax = this._drawCartesian({
2295
+ yMin: 0, yMax: 100,
2296
+ yTickFormat: this.options.yTickFormat,
2297
+ });
2298
+ e.text('streaming…', ax.px + ax.pw / 2, ax.py + ax.ph / 2, {
2299
+ fill: t.muted, font: `${t.fontSize}px ${t.font}`,
2300
+ align: 'center', baseline: 'middle',
2301
+ });
2302
+ return;
2303
+ }
2304
+
2305
+ let yMin = Infinity, yMax = -Infinity;
2306
+ for (const s of this._series) for (const v of s.values) {
2307
+ if (v < yMin) yMin = v;
2308
+ if (v > yMax) yMax = v;
2309
+ }
2310
+ const [yLo, yHi] = M.niceDomain(Math.min(0, yMin), yMax, 5);
2311
+ const N = this.options.windowSize;
2312
+ const ax = this._drawCartesian({
2313
+ yMin: yLo, yMax: yHi,
2314
+ yTickFormat: this.options.yTickFormat,
2315
+ });
2316
+ for (let s = 0; s < this._series.length; s++) {
2317
+ const ser = this._series[s];
2318
+ if (ser.hidden) continue;
2319
+ const color = this._colorOf(s, ser);
2320
+ const pts = ser.values.map((v, i) => ({
2321
+ x: ax.px + (i / (N - 1)) * ax.pw,
2322
+ y: ax.yToPx(v),
2323
+ }));
2324
+ if (pts.length < 2) {
2325
+ if (pts.length === 1) e.circle(pts[0].x, pts[0].y, t.pointRadius + 2, { fill: color });
2326
+ continue;
2327
+ }
2328
+ const cps = M.bezierControlPoints(pts);
2329
+ e.smooth(pts, cps, { stroke: color, lineWidth: t.lineWidth });
2330
+ // Pulse the latest point
2331
+ const last = pts[pts.length - 1];
2332
+ e.circle(last.x, last.y, t.pointRadius + 2, { fill: color });
2333
+ }
2334
+ this._drawLegend(this._series.map((s, i) => ({
2335
+ label: s.name, color: this._colorOf(i, s), idx: i, hidden: s.hidden,
2336
+ })), ax.pw);
2337
+ }
2338
+ }
2339
+
2340
+ // ─────────────────────────────────────────────────────────
2341
+ // 20. WATERFALL
2342
+ // ─────────────────────────────────────────────────────────
2343
+ class WaterfallChart extends Chart {
2344
+ _render() {
2345
+ const data = this.options.data || []; // [{label, value, isTotal?}]
2346
+ if (!data.length) { this.setEmpty('No data'); return; }
2347
+ const e = this.engine;
2348
+ const t = this.theme;
2349
+ let cum = 0;
2350
+ const segs = data.map(d => {
2351
+ if (d.isTotal) {
2352
+ const seg = { from: 0, to: d.value, value: d.value, isTotal: true, label: d.label };
2353
+ cum = d.value;
2354
+ return seg;
2355
+ } else {
2356
+ const seg = { from: cum, to: cum + d.value, value: d.value, label: d.label };
2357
+ cum += d.value;
2358
+ return seg;
2359
+ }
2360
+ });
2361
+ const allVals = segs.flatMap(s => [s.from, s.to]);
2362
+ const [yLo, yHi] = M.niceDomain(Math.min(...allVals, 0), Math.max(...allVals), 5);
2363
+ const ax = this._drawCartesian({
2364
+ yMin: yLo, yMax: yHi,
2365
+ xLabels: data.map(d => d.label),
2366
+ });
2367
+ const slot = ax.pw / segs.length;
2368
+ const barW = Math.max(2, Math.min(60, slot - 12));
2369
+ const prog = this._anim.progress;
2370
+ const upColor = this.options.colorUp || '#10b981';
2371
+ const dnColor = this.options.colorDown || '#ef4444';
2372
+ const totalColor = this.options.colorTotal || '#6366f1';
2373
+
2374
+ for (let i = 0; i < segs.length; i++) {
2375
+ const s = segs[i];
2376
+ const x = ax.px + (i + 0.5) * slot - barW / 2;
2377
+ const y0 = ax.yToPx(s.from);
2378
+ const y1 = ax.yToPx(s.to);
2379
+ const top = Math.min(y0, y1);
2380
+ const fullH = Math.abs(y1 - y0);
2381
+ const h = fullH * prog;
2382
+ const animTop = s.to >= s.from ? y0 - h : y0;
2383
+ const color = s.isTotal ? totalColor : (s.value >= 0 ? upColor : dnColor);
2384
+ e.rect(x, animTop, barW, h, { fill: color, radius: t.barRadius });
2385
+ // Connecting line to next
2386
+ if (i < segs.length - 1 && !segs[i + 1].isTotal) {
2387
+ const nextX = ax.px + (i + 1.5) * slot - barW / 2;
2388
+ const yEnd = ax.yToPx(s.to);
2389
+ e.poly([{x: x + barW, y: yEnd}, {x: nextX, y: yEnd}], {
2390
+ stroke: t.muted, lineWidth: 1, dash: [3, 3],
2391
+ });
2392
+ }
2393
+ this.hits.add({
2394
+ _id: i, x, y: animTop, w: barW, h,
2395
+ tipX: x + barW / 2, tipY: animTop,
2396
+ tooltip: `${s.label}: ${s.value > 0 && !s.isTotal ? '+' : ''}${s.value}`,
2397
+ });
2398
+ }
2399
+ }
2400
+ }
2401
+
2402
+ // composite.js — Composite (mixed) chart. Stack any combination of bars,
2403
+ // lines, areas, scatter on a shared cartesian axis with optional dual y axes.
2404
+ //
2405
+ // Layer config: { type: 'bar'|'line'|'area'|'scatter', name, values, axis: 'left'|'right', color?, ... }
2406
+
2407
+
2408
+
2409
+ class CompositeChart extends Chart {
2410
+ _render() {
2411
+ const layers = this.options.layers || [];
2412
+ const cats = this.options.categories || [];
2413
+ if (!layers.length || !cats.length) { this.setEmpty('No data'); return; }
2414
+ const e = this.engine;
2415
+ const t = this.theme;
2416
+
2417
+ // Compute domain per axis.
2418
+ const leftLayers = layers.filter(l => l.axis !== 'right');
2419
+ const rightLayers = layers.filter(l => l.axis === 'right');
2420
+ const domain = (ll) => {
2421
+ let mn = Infinity, mx = -Infinity;
2422
+ for (const l of ll) {
2423
+ if (l.type === 'scatter') {
2424
+ for (const p of l.values) {
2425
+ if (p.y < mn) mn = p.y; if (p.y > mx) mx = p.y;
2426
+ }
2427
+ } else {
2428
+ for (const v of l.values) {
2429
+ if (+v < mn) mn = +v; if (+v > mx) mx = +v;
2430
+ }
2431
+ }
2432
+ }
2433
+ if (mn === Infinity) return [0, 1];
2434
+ return M.niceDomain(Math.min(0, mn), mx, 5);
2435
+ };
2436
+ const [yLLo, yLHi] = domain(leftLayers);
2437
+ const [yRLo, yRHi] = rightLayers.length ? domain(rightLayers) : [0, 1];
2438
+
2439
+ // Padding accommodates right axis when present.
2440
+ const pad = { l: 50, r: rightLayers.length ? 50 : 16, t: 30, b: 32 };
2441
+ const px = pad.l, py = pad.t;
2442
+ const pw = e.w - pad.l - pad.r;
2443
+ const ph = e.h - pad.t - pad.b;
2444
+ const font = `${t.fontSize}px ${t.font}`;
2445
+
2446
+ // Y axes
2447
+ const yLPx = v => py + ph * (1 - (v - yLLo) / (yLHi - yLLo));
2448
+ const yRPx = v => py + ph * (1 - (v - yRLo) / (yRHi - yRLo));
2449
+ const yLTicks = M.niceTicks(yLLo, yLHi, 5);
2450
+ const yRTicks = rightLayers.length ? M.niceTicks(yRLo, yRHi, 5) : [];
2451
+
2452
+ // Grid
2453
+ e.ctx.strokeStyle = t.grid;
2454
+ e.ctx.lineWidth = 1;
2455
+ e.ctx.beginPath();
2456
+ for (const v of yLTicks) {
2457
+ const y = Math.round(yLPx(v)) + 0.5;
2458
+ e.ctx.moveTo(px, y); e.ctx.lineTo(px + pw, y);
2459
+ }
2460
+ e.ctx.stroke();
2461
+
2462
+ // Y labels
2463
+ const yLFmt = this.options.yLeftTickFormat || (v => String(v));
2464
+ const yRFmt = this.options.yRightTickFormat || (v => String(v));
2465
+ for (const v of yLTicks) {
2466
+ e.text(yLFmt(v), px - 6, yLPx(v), {
2467
+ fill: t.muted, font, align: 'right', baseline: 'middle',
2468
+ });
2469
+ }
2470
+ for (const v of yRTicks) {
2471
+ e.text(yRFmt(v), px + pw + 6, yRPx(v), {
2472
+ fill: t.muted, font, align: 'left', baseline: 'middle',
2473
+ });
2474
+ }
2475
+
2476
+ // X axis (categorical or time)
2477
+ const useTime = this.options.xScale === 'time';
2478
+ const xToPx = useTime
2479
+ ? (i) => px + ((+cats[i] - +cats[0]) / (+cats[cats.length - 1] - +cats[0] || 1)) * pw
2480
+ : (i) => px + ((i + 0.5) / cats.length) * pw;
2481
+
2482
+ // X labels (collision-aware)
2483
+ let lastEnd = -Infinity;
2484
+ for (let i = 0; i < cats.length; i++) {
2485
+ const x = xToPx(i);
2486
+ const txt = useTime
2487
+ ? new Date(+cats[i]).toISOString().slice(0, 10)
2488
+ : String(cats[i]);
2489
+ const w = e.measure(txt, font);
2490
+ if (x - w / 2 < lastEnd + 4) continue;
2491
+ lastEnd = x + w / 2;
2492
+ e.text(txt, x, py + ph + 6, {
2493
+ fill: t.muted, font, align: 'center', baseline: 'top',
2494
+ });
2495
+ }
2496
+ e.poly([{x: px, y: py + ph}, {x: px + pw, y: py + ph}], { stroke: t.axis, lineWidth: 1 });
2497
+
2498
+ const prog = this._anim.progress;
2499
+ const lineW = t.lineWidth;
2500
+
2501
+ // Render layers in order (back-to-front).
2502
+ // Bars first (so lines/scatter can sit on top), grouped in clusters.
2503
+ const barLayers = layers.filter(l => l.type === 'bar');
2504
+ const slot = pw / cats.length;
2505
+ const innerSlot = slot * 0.7;
2506
+ const barW = barLayers.length ? Math.max(2, innerSlot / barLayers.length) : 0;
2507
+
2508
+ barLayers.forEach((layer, bi) => {
2509
+ const yPx = layer.axis === 'right' ? yRPx : yLPx;
2510
+ const yBase = yPx(0);
2511
+ const color = layer.color || this._colorOf(layers.indexOf(layer), layer);
2512
+ const items = [];
2513
+ for (let i = 0; i < cats.length; i++) {
2514
+ const v = +layer.values[i] || 0;
2515
+ const xC = xToPx(i);
2516
+ const x = xC - innerSlot / 2 + bi * barW;
2517
+ const y0 = yBase;
2518
+ const y1 = yPx(v);
2519
+ const top = Math.min(y0, y1);
2520
+ const h = Math.abs(y1 - y0) * prog;
2521
+ const animTop = v >= 0 ? y0 - h : y0;
2522
+ items.push({ x, y: animTop, w: barW - 1, h, radius: t.barRadius });
2523
+ this.hits.add({
2524
+ _id: `${layer.name || bi}-${i}`,
2525
+ x, y: animTop, w: barW - 1, h,
2526
+ tipX: x + barW / 2, tipY: animTop,
2527
+ tooltip: `${layer.name || ''} · ${cats[i]}: ${v}`,
2528
+ });
2529
+ }
2530
+ e.rects(items, color);
2531
+ });
2532
+
2533
+ // Areas
2534
+ e.clip(px, py, pw * prog, ph);
2535
+ layers.filter(l => l.type === 'area').forEach((layer) => {
2536
+ const yPx = layer.axis === 'right' ? yRPx : yLPx;
2537
+ const color = layer.color || this._colorOf(layers.indexOf(layer), layer);
2538
+ const pts = layer.values.map((v, i) => ({ x: xToPx(i), y: yPx(+v) }));
2539
+ const cps = M.bezierControlPoints(pts);
2540
+ const grad = e.vGradient(`area-${layer.name}-${color}`, px, py, py + ph, [
2541
+ [0, M.colorAlpha(color, 0.4)],
2542
+ [1, M.colorAlpha(color, 0)],
2543
+ ]);
2544
+ e.smoothArea(pts, cps, py + ph, grad);
2545
+ e.smooth(pts, cps, { stroke: color, lineWidth: lineW });
2546
+ });
2547
+
2548
+ // Lines
2549
+ layers.filter(l => l.type === 'line').forEach((layer) => {
2550
+ const yPx = layer.axis === 'right' ? yRPx : yLPx;
2551
+ const color = layer.color || this._colorOf(layers.indexOf(layer), layer);
2552
+ const pts = layer.values.map((v, i) => ({ x: xToPx(i), y: yPx(+v), i, v }));
2553
+ const cps = M.bezierControlPoints(pts);
2554
+ e.smooth(pts, cps, { stroke: color, lineWidth: lineW });
2555
+ if (pts.length <= 50) e.circles(pts, color, t.pointRadius);
2556
+ // Hits per point
2557
+ for (const p of pts) {
2558
+ this.hits.add({
2559
+ _id: `${layer.name || 'line'}-${p.i}`,
2560
+ x: p.x - 6, y: p.y - 6, w: 12, h: 12,
2561
+ tipX: p.x, tipY: p.y - t.pointRadius,
2562
+ tooltip: `${layer.name || ''} · ${cats[p.i]}: ${p.v}`,
2563
+ });
2564
+ }
2565
+ });
2566
+ e.unclip();
2567
+
2568
+ // Scatter
2569
+ layers.filter(l => l.type === 'scatter').forEach((layer) => {
2570
+ const yPx = layer.axis === 'right' ? yRPx : yLPx;
2571
+ const color = layer.color || this._colorOf(layers.indexOf(layer), layer);
2572
+ const items = layer.values.map((p) => ({
2573
+ x: xToPx(typeof p.x === 'number' ? p.x : cats.indexOf(p.x)),
2574
+ y: yPx(p.y),
2575
+ }));
2576
+ e.withAlpha(0.7, () => e.circles(items, color, layer.radius || t.pointRadius + 1));
2577
+ });
2578
+
2579
+ // Legend
2580
+ this._drawLegend(layers.map((l, i) => ({
2581
+ label: (l.name || l.type) + (l.axis === 'right' ? ' (R)' : ''),
2582
+ color: l.color || this._colorOf(i, l),
2583
+ idx: i,
2584
+ hidden: false,
2585
+ })), pw);
2586
+ }
2587
+ }
2588
+
2589
+
2590
+ export {
2591
+ Chart, Engine, getFPS, getActiveCount, scheduler, math, PALETTE,
2592
+ BarChart, HBarChart, LineChart, AreaChart,
2593
+ StackedBarChart, GroupedBarChart, MultiLineChart,
2594
+ ScatterChart, BubbleChart, PieChart, RadarChart, PolarChart,
2595
+ GaugeChart, HeatmapChart, BoxplotChart, FunnelChart, TreemapChart,
2596
+ Sparkline, RealtimeChart, WaterfallChart, CompositeChart,
2597
+ };