@aeriz/chart 1.0.1 → 2.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.
Files changed (73) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +64 -431
  3. package/dist/aeriz.cjs +2781 -2338
  4. package/dist/aeriz.esm.js +1859 -1459
  5. package/dist/aeriz.esm.min.js +278 -1
  6. package/dist/aeriz.js +2781 -2338
  7. package/dist/aeriz.min.cjs +282 -1
  8. package/dist/aeriz.min.js +282 -1
  9. package/dist/esm/base.js +84 -0
  10. package/dist/esm/cartesian.js +13 -0
  11. package/dist/esm/charts/area.js +9 -0
  12. package/dist/esm/charts/bar.js +4 -0
  13. package/dist/esm/charts/boxplot.js +4 -0
  14. package/dist/esm/charts/bubble.js +3 -0
  15. package/dist/esm/charts/composite.js +11 -0
  16. package/dist/esm/charts/funnel.js +3 -0
  17. package/dist/esm/charts/gauge.js +5 -0
  18. package/dist/esm/charts/grouped-bar.js +5 -0
  19. package/dist/esm/charts/hbar.js +6 -0
  20. package/dist/esm/charts/heatmap.js +7 -0
  21. package/dist/esm/charts/line.js +9 -0
  22. package/dist/esm/charts/multi-line.js +8 -0
  23. package/dist/esm/charts/pie.js +6 -0
  24. package/dist/esm/charts/polar.js +3 -0
  25. package/dist/esm/charts/radar.js +10 -0
  26. package/dist/esm/charts/realtime.js +14 -0
  27. package/dist/esm/charts/scatter.js +6 -0
  28. package/dist/esm/charts/sparkline.js +5 -0
  29. package/dist/esm/charts/stacked-bar.js +7 -0
  30. package/dist/esm/charts/treemap.js +10 -0
  31. package/dist/esm/charts/waterfall.js +5 -0
  32. package/dist/esm/engine.js +33 -0
  33. package/dist/esm/index.js +2 -0
  34. package/dist/esm/jquery.js +5 -0
  35. package/dist/esm/math.js +32 -0
  36. package/dist/esm/react.js +4 -0
  37. package/dist/esm/shallow.js +5 -0
  38. package/dist/esm/svelte.js +4 -0
  39. package/dist/esm/vue.js +4 -0
  40. package/dist/index.d.ts +323 -0
  41. package/dist/jquery.d.ts +21 -0
  42. package/dist/react.d.ts +59 -0
  43. package/dist/svelte.d.ts +56 -0
  44. package/dist/types/area.d.ts +1 -0
  45. package/dist/types/bar.d.ts +1 -0
  46. package/dist/types/boxplot.d.ts +1 -0
  47. package/dist/types/bubble.d.ts +1 -0
  48. package/dist/types/composite.d.ts +1 -0
  49. package/dist/types/funnel.d.ts +1 -0
  50. package/dist/types/gauge.d.ts +1 -0
  51. package/dist/types/grouped-bar.d.ts +1 -0
  52. package/dist/types/hbar.d.ts +1 -0
  53. package/dist/types/heatmap.d.ts +1 -0
  54. package/dist/types/line.d.ts +1 -0
  55. package/dist/types/multi-line.d.ts +1 -0
  56. package/dist/types/pie.d.ts +1 -0
  57. package/dist/types/polar.d.ts +1 -0
  58. package/dist/types/radar.d.ts +1 -0
  59. package/dist/types/realtime.d.ts +1 -0
  60. package/dist/types/scatter.d.ts +1 -0
  61. package/dist/types/sparkline.d.ts +1 -0
  62. package/dist/types/stacked-bar.d.ts +1 -0
  63. package/dist/types/treemap.d.ts +1 -0
  64. package/dist/types/waterfall.d.ts +1 -0
  65. package/dist/vue.d.ts +63 -0
  66. package/package.json +60 -29
  67. package/src/base.js +0 -562
  68. package/src/charts.js +0 -1365
  69. package/src/composite.js +0 -187
  70. package/src/engine.js +0 -287
  71. package/src/index.d.ts +0 -517
  72. package/src/index.js +0 -18
  73. package/src/math.js +0 -178
package/dist/aeriz.esm.js CHANGED
@@ -1,5 +1,6 @@
1
- /* aeriz — ESM */
2
- // math.js — Pure math: scales, ticks, color ops, smoothing, decimation
1
+ /*! @aeriz/chart v2.0.0 | Apache-2.0 | https://aeriz.dev/chart/ */
2
+ // ── src/math.js
3
+ // math.js — Pure math: scales, ticks, color ops, smoothing, decimation, formatting.
3
4
  // No DOM, no canvas, no side effects.
4
5
 
5
6
  const TAU = Math.PI * 2;
@@ -30,11 +31,13 @@ function niceTicks(min, max, count = 5) {
30
31
  else if (norm < 3) step = 2 * mag;
31
32
  else if (norm < 7) step = 5 * mag;
32
33
  else step = 10 * mag;
33
- const lo = Math.ceil(min / step) * step;
34
- const hi = Math.floor(max / step) * step;
34
+ // 1e-9 tolerance: 0.3 / 0.1 is 2.9999999999999996, which must count as 3.
35
+ const lo = Math.ceil(min / step - 1e-9) * step;
36
+ const hi = Math.floor(max / step + 1e-9) * step;
35
37
  const ticks = [];
36
- for (let v = lo; v <= hi + step / 2; v += step) {
37
- ticks.push(Math.round(v / step) * step);
38
+ // Index-based loop + toPrecision: no float drift (0.30000000000000004 → 0.3).
39
+ for (let i = 0, v = lo; v <= hi + step / 2; v = lo + ++i * step) {
40
+ ticks.push(+v.toPrecision(12));
38
41
  }
39
42
  return ticks;
40
43
  }
@@ -50,7 +53,7 @@ function niceDomain(min, max, count = 5) {
50
53
  else if (norm < 3) step = 2 * mag;
51
54
  else if (norm < 7) step = 5 * mag;
52
55
  else step = 10 * mag;
53
- return [Math.floor(min / step) * step, Math.ceil(max / step) * step];
56
+ return [+(Math.floor(min / step + 1e-9) * step).toPrecision(12), +(Math.ceil(max / step - 1e-9) * step).toPrecision(12)];
54
57
  }
55
58
 
56
59
  // Time axis: pick a nice unit (second, minute, hour, day, week, month) and
@@ -106,6 +109,28 @@ function minMaxDecimate(data, targetCount, fn = (d) => d.value) {
106
109
  return out;
107
110
  }
108
111
 
112
+ // Same as minMaxDecimate but returns *indices* into the source, so callers
113
+ // can keep exact x positions and tooltip lookups for the original points.
114
+ function minMaxDecimateIdx(n, targetCount, valueAt) {
115
+ const out = [];
116
+ if (n <= targetCount) { for (let i = 0; i < n; i++) out.push(i); return out; }
117
+ const bucket = n / targetCount;
118
+ for (let b = 0; b < targetCount; b++) {
119
+ const lo = Math.floor(b * bucket);
120
+ const hi = Math.min(n, Math.floor((b + 1) * bucket));
121
+ let mnI = lo, mxI = lo, mn = valueAt(lo), mx = mn;
122
+ for (let j = lo + 1; j < hi; j++) {
123
+ const v = valueAt(j);
124
+ if (v < mn) { mn = v; mnI = j; }
125
+ if (v > mx) { mx = v; mxI = j; }
126
+ }
127
+ if (mnI === mxI) out.push(mnI);
128
+ else if (mnI < mxI) out.push(mnI, mxI);
129
+ else out.push(mxI, mnI);
130
+ }
131
+ return out;
132
+ }
133
+
109
134
  function shouldDecimate(n, pxWidth) {
110
135
  return n > pxWidth * 2;
111
136
  }
@@ -121,11 +146,14 @@ function bezierControlPoints(pts, tension = 1 / 6) {
121
146
  const p1 = pts[i];
122
147
  const p2 = pts[i + 1];
123
148
  const p3 = pts[i + 2] || p2;
149
+ // Control points stay between the two endpoints' y values, so the curve
150
+ // never overshoots past a data point (no fake peaks or dips).
151
+ const lo = Math.min(p1.y, p2.y), hi = Math.max(p1.y, p2.y);
124
152
  cps.push({
125
153
  c1x: p1.x + (p2.x - p0.x) * tension,
126
- c1y: p1.y + (p2.y - p0.y) * tension,
154
+ c1y: clamp(p1.y + (p2.y - p0.y) * tension, lo, hi),
127
155
  c2x: p2.x - (p3.x - p1.x) * tension,
128
- c2y: p2.y - (p3.y - p1.y) * tension,
156
+ c2y: clamp(p2.y - (p3.y - p1.y) * tension, lo, hi),
129
157
  });
130
158
  }
131
159
  return cps;
@@ -155,8 +183,9 @@ function colorParse(c) {
155
183
  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
184
  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
185
  }
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];
186
+ // rgb(1, 2, 3) · rgba(1,2,3,.5) · rgb(1 2 3 / 50%)
187
+ const m = c.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+)(%?))?\s*\)/);
188
+ if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : m[5] ? m[4] / 100 : +m[4]];
160
189
  return [0, 0, 0, 1];
161
190
  }
162
191
 
@@ -178,13 +207,26 @@ const PALETTE = [
178
207
  '#8b5cf6', '#ec4899', '#84cc16', '#6366f1',
179
208
  ];
180
209
 
210
+ // Number formatting shared by axes and tooltips.
211
+ // • fmt: function → used as-is; object → Intl.NumberFormat options; otherwise default.
212
+ // • axis: true → compact (12K, 3.4M) once values reach 10,000.
213
+ const NF = new Map();
214
+ function nf(opts) {
215
+ const key = JSON.stringify(opts);
216
+ let f = NF.get(key);
217
+ if (!f) NF.set(key, (f = new Intl.NumberFormat(undefined, opts)));
218
+ return f;
219
+ }
220
+ function formatNumber(v, fmt, axis) {
221
+ if (typeof fmt === 'function') return String(fmt(v));
222
+ if (typeof v !== 'number' || !isFinite(v)) return v == null ? '—' : String(v);
223
+ if (fmt && typeof fmt === 'object') return nf(fmt).format(v);
224
+ return nf(axis && Math.abs(v) >= 1e4
225
+ ? { notation: 'compact', maximumFractionDigits: 1 }
226
+ : { maximumFractionDigits: 2 }).format(v);
227
+ }
181
228
 
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;
229
+ // ── src/engine.js
188
230
  // engine.js — Canvas 2D rendering primitives.
189
231
  // All public coordinates are CSS pixels. DPR is handled internally.
190
232
 
@@ -208,12 +250,13 @@ class Engine {
208
250
  const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
209
251
  this.canvas.width = Math.round(w * dpr);
210
252
  this.canvas.height = Math.round(h * dpr);
211
- this.canvas.style.width = w + 'px';
212
- this.canvas.style.height = h + 'px';
253
+ // CSS size stays 100% (set by Chart): fixed px sizes here used to stop the
254
+ // container from shrinking inside flex/grid layouts.
213
255
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
214
256
  this.w = w;
215
257
  this.h = h;
216
258
  this.dpr = dpr;
259
+ this._font = null; // resizing the backing store resets context state
217
260
  this._gradCache.clear();
218
261
  }
219
262
 
@@ -398,9 +441,15 @@ class Engine {
398
441
  }
399
442
  }
400
443
 
444
+ // ctx.font assignment re-parses the font string — skip it when unchanged.
445
+ _setFont(font) {
446
+ font = font || '11px ui-monospace, monospace';
447
+ if (this._font !== font) { this.ctx.font = font; this._font = font; }
448
+ }
449
+
401
450
  text(str, x, y, opts = {}) {
402
451
  const ctx = this.ctx;
403
- ctx.font = opts.font || '11px ui-monospace, monospace';
452
+ this._setFont(opts.font);
404
453
  ctx.fillStyle = opts.fill || '#000';
405
454
  ctx.textAlign = opts.align || 'left';
406
455
  ctx.textBaseline = opts.baseline || 'alphabetic';
@@ -416,7 +465,7 @@ class Engine {
416
465
  }
417
466
 
418
467
  measure(str, font) {
419
- this.ctx.font = font || '11px ui-monospace, monospace';
468
+ this._setFont(font);
420
469
  return this.ctx.measureText(str).width;
421
470
  }
422
471
 
@@ -461,7 +510,7 @@ class Engine {
461
510
  ctx.rect(x, y, w, h);
462
511
  ctx.clip();
463
512
  }
464
- unclip() { this.ctx.restore(); }
513
+ unclip() { this.ctx.restore(); this._font = null; }
465
514
 
466
515
  withAlpha(a, fn) {
467
516
  const ctx = this.ctx;
@@ -472,83 +521,90 @@ class Engine {
472
521
  }
473
522
  }
474
523
 
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)
524
+ // ── src/base.js
525
+ const M = { TAU, clamp, lerp, extent, niceTicks, niceDomain, timeNiceTicks, timeFormat, minMaxDecimate, minMaxDecimateIdx, shouldDecimate, bezierControlPoints, springStep, colorParse, colorAlpha, colorMix, PALETTE, formatNumber };
526
+ // base.js — Chart base class. Every chart type extends this. Handles:
527
+ // • Lifecycle (mount, resize, update, destroy) on one shared rAF loop
528
+ // • Theme from CSS variables, falling back to the container's own text
529
+ // color / font / background — so charts fit light and dark pages untouched
530
+ // • Hit testing (rects or custom shapes), DOM tooltip, legend with toggling
531
+ // • Pointer (mouse · touch · pen) and keyboard input, screen-reader text
532
+ // • State overlays (loading, empty, error), reduced motion, off-screen pause
533
+ //
534
+ // Cartesian axes live in cartesian.js so charts without axes (pie, gauge,
535
+ // treemap …) do not carry that code.
482
536
 
483
537
 
484
538
 
485
539
  // ─── Global animation scheduler ─────────────────────────────
540
+ // One requestAnimationFrame loop for every chart on the page. It only runs
541
+ // while some chart is animating or has something to redraw.
486
542
  const charts = new Set();
487
543
  let rafId = null;
488
- let lastTime = 0;
489
- let fps = 60;
490
- let frames = 0;
491
- let fpsT = 0;
492
544
 
493
545
  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
546
  let active = false;
501
547
  for (const c of charts) {
502
548
  try {
503
- const stillActive = c._tick(now);
504
- if (stillActive) active = true;
549
+ if (c._tick(now)) active = true;
505
550
  } catch (e) { console.error(e); }
506
551
  }
507
- rafId = (charts.size > 0 && active) ? requestAnimationFrame(tick) : null;
552
+ rafId = charts.size > 0 && active ? requestAnimationFrame(tick) : null;
508
553
  }
509
554
 
510
555
  function schedule(chart) {
556
+ if (chart._destroyed) return;
511
557
  charts.add(chart);
512
- if (rafId === null) {
513
- fpsT = lastTime = performance.now();
514
- rafId = requestAnimationFrame(tick);
515
- }
558
+ if (rafId === null) rafId = requestAnimationFrame(tick);
516
559
  }
517
560
 
518
- const scheduler = { schedule, fps: () => fps, count: () => charts.size };
519
- function getFPS() { return fps; }
520
- function getActiveCount() { return charts.size; }
561
+ // Media queries are shared by every chart (one listener each, not per chart).
562
+ const mq = (q) => (typeof matchMedia === 'function' ? matchMedia(q) : null);
563
+ const reducedMotion = mq('(prefers-reduced-motion: reduce)');
564
+ const darkScheme = mq('(prefers-color-scheme: dark)');
565
+ // OS light/dark switch → every chart re-reads its theme.
566
+ darkScheme?.addEventListener?.('change', () => { for (const c of charts) { c._theme(); schedule(c); } });
521
567
 
522
568
  // ─── 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
- };
569
+ // Theme key → CSS variable: fontSize → --aeriz-font-size, tooltipBg → --aeriz-tooltip-bg …
570
+ const TOKENS = ['bg', 'surface', 'text', 'muted', 'grid', 'axis', 'accent', 'font', 'fontSize',
571
+ 'lineWidth', 'pointRadius', 'barRadius', 'tooltipBg', 'tooltipFg'];
572
+ const cssVar = (k) => '--aeriz-' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
573
+
574
+ // Nearest ancestor with an opaque background — used to "cut out" points and
575
+ // tint overlays so they match whatever page the chart sits on.
576
+ function backgroundOf(el) {
577
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
578
+ const c = getComputedStyle(n).backgroundColor;
579
+ if (c && M.colorParse(c)[3] > 0.5) return c;
580
+ }
581
+ return '#ffffff';
582
+ }
539
583
 
540
584
  function resolveTheme(el) {
541
585
  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;
586
+ const v = {};
587
+ for (const k of TOKENS) v[k] = cs.getPropertyValue(cssVar(k)).trim();
588
+ const text = v.text || cs.color || '#222';
589
+ const num = (s, d) => (s && !isNaN(parseFloat(s)) ? parseFloat(s) : d);
590
+ return {
591
+ bg: v.bg || backgroundOf(el),
592
+ surface: v.surface || 'transparent',
593
+ text,
594
+ muted: v.muted || M.colorAlpha(text, 0.62),
595
+ grid: v.grid || M.colorAlpha(text, 0.09),
596
+ axis: v.axis || M.colorAlpha(text, 0.3),
597
+ accent: v.accent || '#64c8ff',
598
+ font: v.font || cs.fontFamily || 'system-ui, sans-serif',
599
+ fontSize: num(v.fontSize, 11),
600
+ lineWidth: num(v.lineWidth, 2),
601
+ pointRadius: num(v.pointRadius, 3),
602
+ barRadius: num(v.barRadius, 3),
603
+ tooltipBg: v.tooltipBg || 'rgba(20,22,30,0.94)',
604
+ tooltipFg: v.tooltipFg || '#fff',
605
+ };
549
606
  }
550
607
 
551
- // ─── Color palette resolution ───────────────────────────────
552
608
  function readPalette(el) {
553
609
  const cs = getComputedStyle(el);
554
610
  const out = [];
@@ -559,63 +615,56 @@ function readPalette(el) {
559
615
  return out.length ? out : M.PALETTE;
560
616
  }
561
617
 
618
+ // ─── Small DOM helper ───────────────────────────────────────
619
+ function div(parent, css, tag = 'div') {
620
+ const el = document.createElement(tag);
621
+ if (css) el.style.cssText = css;
622
+ parent.appendChild(el);
623
+ return el;
624
+ }
625
+
626
+ function tipText(content) {
627
+ if (typeof content === 'string') return content;
628
+ return [content.title, ...content.rows.map((r) => `${r.name ? r.name + ' ' : ''}${r.value == null ? '—' : r.value}`)]
629
+ .filter(Boolean)
630
+ .join(', ');
631
+ }
632
+
562
633
  // ─── Tooltip ────────────────────────────────────────────────
563
634
  class Tooltip {
564
635
  constructor(parent) {
565
636
  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);
637
+ this.el = div(parent,
638
+ 'position:absolute;left:0;top:0;pointer-events:none;padding:6px 10px;border-radius:4px;' +
639
+ 'font:inherit;font-size:11px;line-height:1.5;white-space:nowrap;opacity:0;transition:opacity .1s;' +
640
+ 'z-index:20;box-shadow:0 4px 16px rgba(0,0,0,.3)');
641
+ this.el.setAttribute('aria-hidden', 'true');
583
642
  }
643
+ // Content is always set with textContent — labels from data can't inject HTML.
584
644
  show(x, y, content, theme) {
585
- this.el.style.background = theme.tooltipBg;
586
- this.el.style.color = theme.tooltipFg;
645
+ const el = this.el;
646
+ el.style.background = theme.tooltipBg;
647
+ el.style.color = theme.tooltipFg;
587
648
  if (typeof content === 'string') {
588
- this.el.textContent = content;
649
+ el.textContent = content;
589
650
  } 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
- }
651
+ el.textContent = '';
652
+ if (content.title) div(el, 'opacity:.7;font-size:10px;margin-bottom:4px').textContent = content.title;
597
653
  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);
654
+ const row = div(el, 'display:flex;align-items:center;gap:6px;line-height:1.4');
655
+ if (r.color) div(row, `width:8px;height:8px;border-radius:2px;background:${r.color}`, 'span');
656
+ div(row, 'opacity:.8;flex:1', 'span').textContent = r.name || '';
657
+ div(row, 'font-weight:700;margin-left:12px', 'span').textContent = r.value == null ? '—' : String(r.value);
614
658
  }
615
659
  }
616
- this.el.style.left = x + 'px';
617
- this.el.style.top = y + 'px';
618
- this.el.style.opacity = '1';
660
+ // Keep the tooltip inside the chart: centre above the point, flip below
661
+ // when there is no room on top, clamp left/right.
662
+ const w = el.offsetWidth, h = el.offsetHeight;
663
+ const pw = this.parent.clientWidth;
664
+ const left = Math.max(0, Math.min(pw - w, x - w / 2));
665
+ const top = y - h - 8 < 0 ? y + 12 : y - h - 8;
666
+ el.style.transform = `translate(${Math.round(left)}px,${Math.round(top)}px)`;
667
+ el.style.opacity = '1';
619
668
  }
620
669
  hide() { this.el.style.opacity = '0'; }
621
670
  destroy() { this.el.remove(); }
@@ -624,226 +673,463 @@ class Tooltip {
624
673
  // ─── State overlays (loading / empty / error) ───────────────
625
674
  class Overlay {
626
675
  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);
676
+ this.el = div(parent,
677
+ 'position:absolute;inset:0;display:none;align-items:center;justify-content:center;flex-direction:column;' +
678
+ 'gap:8px;font:inherit;font-size:12px;pointer-events:none;z-index:15');
643
679
  }
644
680
  show(kind, msg, theme) {
645
681
  const el = this.el;
646
682
  el.style.display = 'flex';
647
683
  el.style.background = M.colorAlpha(theme.bg, 0.85);
648
684
  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';
685
+ el.textContent = '';
686
+ el.setAttribute('role', kind === 'error' ? 'alert' : 'status');
687
+ const icon = div(el, 'font-size:20px;opacity:.6;line-height:1');
653
688
  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'; }
689
+ icon.innerHTML = `<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" 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>`;
690
+ icon.style.color = theme.accent;
691
+ } else {
692
+ icon.textContent = kind === 'error' ? '!' : '◌';
693
+ if (kind === 'error') icon.style.color = '#ef4444';
694
+ }
695
+ if (msg) div(el, 'opacity:.85').textContent = msg;
696
+ }
697
+ hide() { this.el.style.display = 'none'; this.el.removeAttribute('role'); }
670
698
  destroy() { this.el.remove(); }
671
699
  }
672
700
 
673
701
  // ─── Hit index ──────────────────────────────────────────────
674
- // Canvas-pixel rectangles with arbitrary metadata. Used for hover/click.
702
+ // Regions are rectangles in CSS pixels, optionally with a precise `test(x, y)`
703
+ // for non-rectangular shapes (pie slices, polar sectors).
675
704
  class HitIndex {
676
705
  constructor() { this.regions = []; }
677
706
  clear() { this.regions.length = 0; }
678
707
  add(r) { this.regions.push(r); }
679
- // Find the topmost (last-added) region containing (x, y).
708
+ // Topmost (last-added) region containing (x, y).
680
709
  find(x, y) {
681
710
  for (let i = this.regions.length - 1; i >= 0; i--) {
682
711
  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;
712
+ if (r.test ? r.test(x, y) : x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return r;
684
713
  }
685
714
  return null;
686
715
  }
687
716
  }
688
717
 
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;
718
+ // ─── Data: one accepted shape per kind ──────────────────────
719
+ // Single-series rows. All three give the same [{ label, value }] rows:
720
+ // [3, 7, 4] → labels 1, 2, 3
721
+ // { Jan: 3, Feb: 7 } → labels Jan, Feb
722
+ // [{ label: 'Jan', value: 3 }, …] (other fields: use the label/value options)
723
+ const isList = (d) => Array.isArray(d) || ArrayBuffer.isView(d);
724
+ const isObj = (d) => d != null && typeof d === 'object' && !isList(d);
725
+
726
+ function toRows(data) {
727
+ if (isList(data)) {
728
+ if (!data.length || Array.isArray(data[0])) return [];
729
+ return typeof data[0] === 'number' ? Array.from(data, (v, i) => ({ label: String(i + 1), value: v })) : data;
730
+ }
731
+ return isObj(data) ? Object.keys(data).map((k) => ({ label: k, value: +data[k] })) : [];
732
+ }
733
+
734
+ // ─── Developer warnings ─────────────────────────────────────
735
+ // Mistakes don't fail silently: the console says what is wrong and how to fix
736
+ // it, once per message.
737
+ const said = new Set();
738
+ function warn(msg) {
739
+ if (said.has(msg)) return;
740
+ said.add(msg);
741
+ console.warn('[aeriz] ' + msg);
742
+ }
743
+
744
+ // Options every chart takes.
745
+ const COMMON = ['data', 'title', 'colors', 'format', 'legend', 'animate', 'emptyText', 'onClick', 'onLegend'];
746
+
747
+ const SHAPES = {
748
+ rows: 'an array of numbers, an object like { Jan: 64 }, or rows like [{ label, value }]',
749
+ series: '{ categories: [...], series: [{ name, values: [...] }] }',
750
+ stream: '{ series: [{ name }] } (values arrive with chart.push())',
751
+ points: 'points like [{ x, y }]',
752
+ matrix: '{ x: [...], y: [...], values: [[...], ...] } with values[y][x]',
753
+ boxes: 'rows like [{ label, min, q1, median, q3, max }]',
754
+ steps: 'rows like [{ label, value }] (add total: true for a total bar)',
755
+ number: 'a number',
756
+ };
757
+
758
+ // 1.x option names → what to write in 2.0.
759
+ const DATA = 'data: { categories, series }';
760
+ const RENAMED = {
761
+ ariaLabel: 'title', palette: 'colors', color: 'colors',
762
+ xTickFormat: 'labelFormat (category axis) or format (value axis)', yTickFormat: 'format',
763
+ yLeftTickFormat: 'format on the series', yRightTickFormat: 'format on the series',
764
+ categories: DATA, series: DATA, layers: DATA, axes: DATA,
765
+ xLabels: 'data: { x, y, values }', yLabels: 'data: { x, y, values }',
766
+ interpolation: 'curve', windowSize: 'window', yMin: 'min', yMax: 'max', waitingText: 'emptyText',
767
+ colorLow: 'colors: { low, high }', colorHigh: 'colors: { low, high }',
768
+ colorUp: 'colors: { up, down, total }', colorDown: 'colors: { up, down, total }', colorTotal: 'colors: { up, down, total }',
769
+ minRadius: 'radius: [min, max]', maxRadius: 'radius: [min, max]',
770
+ centerLabel: 'center', totalLabel: 'center', type: 'the chart class itself',
771
+ };
772
+ const FUNCS = ['label', 'value', 'labelFormat', 'onClick', 'onLegend'];
773
+
774
+ function distance(a, b) {
775
+ const d = Array.from({ length: b.length + 1 }, (_, j) => j);
776
+ for (let i = 1; i <= a.length; i++) {
777
+ let p = d[0];
778
+ d[0] = i;
779
+ for (let j = 1; j <= b.length; j++) {
780
+ const t = d[j];
781
+ d[j] = Math.min(d[j] + 1, d[j - 1] + 1, p + (a[i - 1] === b[j - 1] ? 0 : 1));
782
+ p = t;
783
+ }
784
+ }
785
+ return d[b.length];
786
+ }
787
+
788
+ function checkOptions(meta, o) {
789
+ const name = meta.name;
790
+ const allowed = [...COMMON, ...meta.options];
791
+ for (const k of Object.keys(o)) {
792
+ const v = o[k];
793
+ if (!allowed.includes(k)) {
794
+ const hint = meta.renamed?.[k] || RENAMED[k];
795
+ if (hint) { warn(`${name}: "${k}" is a 1.x option. In 2.0 use ${hint}.`); continue; }
796
+ let best = null, bd = 3;
797
+ for (const a of allowed) {
798
+ const dd = a.toLowerCase() === k.toLowerCase() ? 0 : distance(k, a);
799
+ if (dd < bd) { bd = dd; best = a; }
800
+ }
801
+ warn(`${name}: unknown option "${k}"${best ? ` — did you mean "${best}"?` : `. Options: ${allowed.join(', ')}`}`);
802
+ continue;
803
+ }
804
+ if (v == null) continue;
805
+ if (FUNCS.includes(k) && typeof v !== 'function') warn(`${name}: "${k}" must be a function${k === 'label' || k === 'value' ? `, like ${k}: (row) => row.${k === 'label' ? 'name' : 'amount'}` : ''}.`);
806
+ const en = meta.enums?.[k];
807
+ if (en && !en.includes(v)) warn(`${name}: ${k} must be ${en.map((x) => `'${x}'`).join(' | ')}, got ${JSON.stringify(v)}.`);
808
+ if (k === 'format' && typeof v !== 'function' && !isObj(v)) warn(`${name}: format must be Intl.NumberFormat options like { style: 'percent' } or a function (v) => string.`);
809
+ if (k === 'colors' && (meta.colors ? !isObj(v) : !Array.isArray(v))) warn(`${name}: colors must be ${meta.colors || 'an array like [\'#64c8ff\', \'#10b981\']'}.`);
810
+ }
811
+ if ('data' in o) checkData(meta, o.data, o);
812
+ }
697
813
 
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';
814
+ function checkData(meta, d, o) {
815
+ if (d == null) return;
816
+ const kind = meta.data;
817
+ const bad = (why) => warn(`${meta.name}: data must be ${SHAPES[kind]}${why ? ` — ${why}` : ''}.`);
818
+ const first = isList(d) ? d[0] : undefined;
819
+ switch (kind) {
820
+ case 'rows':
821
+ if (isList(d)) {
822
+ if (Array.isArray(first)) return bad(`pairs like ['Jan', 64] were removed in 2.0`);
823
+ if (first != null && typeof first !== 'number' && !isObj(first)) return bad(`got ${typeof first} items`);
824
+ if (isObj(first) && !o.value && !('value' in first)) warn(`${meta.name}: rows need a "value" field, or pass value: (row) => row.amount.`);
825
+ if (isObj(first) && !o.label && !('label' in first)) warn(`${meta.name}: rows need a "label" field, or pass label: (row) => row.name.`);
826
+ } else if (!isObj(d)) bad(`got ${typeof d}`);
827
+ return;
828
+ case 'series':
829
+ case 'stream': {
830
+ if (!isObj(d) || !Array.isArray(d.series) || (kind === 'series' && !isList(d.categories))) return bad(isList(d) ? 'got an array' : '');
831
+ const n = kind === 'series' ? d.categories.length : 0;
832
+ d.series.forEach((s, i) => {
833
+ const who = `series[${i}]${s?.name ? ` "${s.name}"` : ''}`;
834
+ if (kind === 'stream') return;
835
+ if (!s || !isList(s.values)) return warn(`${meta.name}: ${who} needs values: [...].`);
836
+ if (s.values.length !== n) warn(`${meta.name}: ${who} has ${s.values.length} values but there are ${n} categories.`);
837
+ });
838
+ return;
702
839
  }
703
- if (!this.container.style.height && this.container.offsetHeight === 0) {
704
- this.container.style.height = '320px';
840
+ case 'matrix':
841
+ if (!isObj(d) || !isList(d.x) || !isList(d.y) || !Array.isArray(d.values)) return bad(isList(d) ? 'got an array' : '');
842
+ if (d.values.length !== d.y.length) warn(`${meta.name}: values has ${d.values.length} rows but y has ${d.y.length} labels.`);
843
+ return;
844
+ case 'number':
845
+ if (typeof d !== 'number') bad(`got ${typeof d}`);
846
+ return;
847
+ default: {
848
+ // points · boxes · steps: arrays of objects with known fields
849
+ if (!Array.isArray(d)) return bad(`got ${isObj(d) ? 'an object' : typeof d}`);
850
+ if (!first) return;
851
+ const need = { points: ['x', 'y'], boxes: ['q1', 'median', 'q3'], steps: ['value'] }[kind];
852
+ const miss = need.filter((k) => !(k in first));
853
+ if (miss.length) bad(`the first item has no ${miss.join(', ')}`);
854
+ if (kind === 'steps' && 'isTotal' in first) warn(`${meta.name}: "isTotal" is 1.x. In 2.0 use total: true.`);
855
+ if (kind === 'points' && ('r' in first || 'z' in first)) warn(`${meta.name}: point "${'r' in first ? 'r' : 'z'}" is 1.x. In 2.0 use size.`);
705
856
  }
857
+ }
858
+ }
859
+
860
+ // ─── Chart base ─────────────────────────────────────────────
861
+ const STATES = ['loading', 'empty', 'error'];
862
+
863
+ class Chart {
864
+ // Each chart type describes itself: name for messages, aria for the default
865
+ // accessible name, data shape, and the options it takes beyond COMMON.
866
+ static get meta() { return null; }
706
867
 
707
- // Canvas fills the *content area* (inside container's padding/border).
708
- // We size the canvas from clientWidth/clientHeight (CSS content box).
868
+ constructor(container, options = {}) {
869
+ const meta = this.constructor.meta;
870
+ const el = typeof container === 'string' ? document.querySelector(container) : container;
871
+ if (!el) throw new Error(`aeriz: ${meta ? meta.name + ': ' : ''}container not found${typeof container === 'string' ? ': ' + container : ''}`);
872
+ this.container = el;
873
+ this.options = { ...options };
874
+ if (meta) checkOptions(meta, this.options);
875
+
876
+ // Remember what we touch on the container so destroy() can undo it.
877
+ this._restore = { position: el.style.position, height: el.style.height };
878
+ el.classList.add('aeriz');
879
+ if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
880
+ if (!el.style.height && el.offsetHeight === 0) el.style.height = '320px';
881
+
882
+ // container > wrap(flex column) > [legend, plot > canvas + tooltip + overlay]
883
+ this.wrap = div(el, 'display:flex;flex-direction:column;width:100%;height:100%');
884
+ this.legendEl = div(this.wrap,
885
+ 'display:none;flex:none;flex-wrap:wrap;gap:2px 12px;padding:0 0 6px;font:inherit;font-size:11px');
886
+ this.plot = div(this.wrap, 'position:relative;flex:1 1 auto;min-height:0');
709
887
  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);
888
+ // pan-y: a touch drag can still scroll the page vertically.
889
+ this.canvas.style.cssText = 'position:absolute;inset:0;display:block;width:100%;height:100%;touch-action:pan-y;outline:none';
890
+ this.canvas.tabIndex = 0;
891
+ this.canvas.setAttribute('role', 'img');
892
+ this.plot.appendChild(this.canvas);
893
+ // Visually hidden live region: keyboard users hear the focused point.
894
+ this.live = div(this.plot, 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap');
895
+ this.live.setAttribute('aria-live', 'polite');
712
896
 
713
897
  this.engine = new Engine(this.canvas);
714
- this.tooltip = new Tooltip(this.container);
715
- this.overlay = new Overlay(this.container);
898
+ this.tooltip = new Tooltip(this.plot);
899
+ this.overlay = new Overlay(this.plot);
716
900
  this.hits = new HitIndex();
717
901
 
718
- this.theme = resolveTheme(this.container);
719
- this.palette = options.palette || readPalette(this.container);
902
+ this._off = new Set(); // legend-hidden series / item indices
903
+ this._legendKey = '';
720
904
  this._cursor = null;
721
905
  this._hover = null;
722
906
  this._dirty = true;
723
907
  this._destroyed = false;
724
- this._mounted = false;
908
+ // With IntersectionObserver the first draw waits for its first report,
909
+ // so a chart below the fold animates in when scrolled to.
910
+ this._visible = typeof IntersectionObserver === 'undefined';
725
911
  this._anim = { progress: 0, start: 0, duration: 600 };
726
- this._state = null; // 'loading' | 'empty' | 'error' | null
912
+ this._state = null; // 'loading' | 'empty' | 'error' | null
913
+ this._auto = false; // _state was set automatically by "No data"
914
+ this._theme();
915
+ this._label();
727
916
 
728
917
  this._setupEvents();
729
- this._observeResize();
918
+ this._observe();
730
919
  this._resize();
920
+ if (options.animate === false || reducedMotion?.matches) this._anim.progress = 1;
731
921
 
732
922
  schedule(this);
733
923
  }
734
924
 
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);
925
+ // ─── Public API: update · setState · render · destroy ────
926
+ // Change any options (data included). Changed values animate in.
927
+ update(patch = {}) {
928
+ const meta = this.constructor.meta;
929
+ if (meta) checkOptions(meta, patch);
930
+ Object.assign(this.options, patch);
931
+ this._cache = null; // charts may cache work per data array; data can be mutated in place
932
+ if ('colors' in patch) this._theme();
933
+ if (this._auto) { this._state = null; this._auto = false; }
934
+ this._label();
935
+ this._restart();
936
+ return this;
744
937
  }
745
938
 
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;
939
+ // Show a loading / empty / error overlay, or remove it with null.
940
+ setState(state, text) {
941
+ if (state != null && !STATES.includes(state)) {
942
+ throw new Error(`aeriz: setState() takes 'loading', 'empty', 'error' or null — got ${JSON.stringify(state)}`);
943
+ }
944
+ this._state = state || null;
945
+ this._stateMsg = text ?? (state === 'empty' ? this.options.emptyText || 'No data' : state === 'error' ? 'Error' : '');
946
+ this._auto = false;
752
947
  this._dirty = true;
753
948
  schedule(this);
754
949
  return this;
755
950
  }
756
951
 
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; }
952
+ // Re-read CSS variables / theme and draw the final frame right now — also
953
+ // when the chart is off-screen (printing, export, measuring).
954
+ render() {
955
+ if (this._destroyed) return this;
956
+ this._theme();
957
+ this._anim.progress = 1;
958
+ this._resize();
959
+ this._dirty = false;
960
+ this._draw();
961
+ return this;
962
+ }
761
963
 
762
964
  destroy() {
965
+ if (this._destroyed) return;
763
966
  this._destroyed = true;
764
967
  charts.delete(this);
765
- this._ro && this._ro.disconnect();
968
+ this._ro?.disconnect();
969
+ this._io?.disconnect();
766
970
  this._removeEvents();
767
- this.tooltip.destroy();
768
- this.overlay.destroy();
769
- this.canvas.remove();
770
- this.container.classList.remove('aeriz');
971
+ this.wrap.remove();
972
+ const el = this.container;
973
+ el.classList.remove('aeriz');
974
+ el.style.position = this._restore.position;
975
+ el.style.height = this._restore.height;
771
976
  }
772
977
 
773
978
  // ─── Internals ───────────────────────────────────────────
979
+ _theme() {
980
+ const o = this.options;
981
+ this.theme = resolveTheme(this.container);
982
+ this.palette = Array.isArray(o.colors) && o.colors.length ? o.colors : readPalette(this.container);
983
+ this.engine._gradCache.clear();
984
+ this._legendKey = '';
985
+ this._dirty = true;
986
+ }
987
+
988
+ // Charts call this when they find nothing to draw. Unlike setState('empty')
989
+ // it clears itself on the next update().
990
+ _empty(msg = this.options.emptyText || 'No data') {
991
+ this._state = 'empty';
992
+ this._stateMsg = msg;
993
+ this._auto = true;
994
+ this.overlay.show('empty', msg, this.theme);
995
+ }
996
+
997
+ _restart() {
998
+ const instant = this.options.animate === false || reducedMotion?.matches;
999
+ this._anim.start = 0;
1000
+ this._anim.progress = instant ? 1 : 0;
1001
+ this._dirty = true;
1002
+ schedule(this);
1003
+ }
1004
+
1005
+ _label() {
1006
+ this.canvas.setAttribute('aria-label', this.options.title || this.constructor.meta?.aria || 'Chart');
1007
+ }
1008
+
1009
+ _fmt(v) { return M.formatNumber(v, this.options.format); }
1010
+ _tickFmt(v) { return M.formatNumber(v, this.options.format, true); }
1011
+ // Category / time label as text (labelFormat wins).
1012
+ _lab(l, time) {
1013
+ const f = this._fn('labelFormat');
1014
+ return f ? String(f(l)) : time ? new Date(+l).toLocaleString() : String(l);
1015
+ }
1016
+ // Numeric x value (scatter, bubble) as text.
1017
+ _xFmt(v) {
1018
+ const f = this._fn('labelFormat');
1019
+ return f ? String(f(v)) : this._fmt(v);
1020
+ }
1021
+ _isOff(i) { return this._off.has(i); }
1022
+ // label / value accessor, when it is a function (otherwise warned about).
1023
+ _fn(k) {
1024
+ const f = this.options[k];
1025
+ return typeof f === 'function' ? f : null;
1026
+ }
1027
+
1028
+ // What onClick receives: the clicked item, without drawing internals.
1029
+ _hit(h) {
1030
+ if (!h) return null;
1031
+ const out = { index: h.index, label: h.label };
1032
+ if ('value' in h) out.value = h.value;
1033
+ if ('series' in h) out.series = h.series;
1034
+ if ('row' in h) out.row = h.row;
1035
+ return out;
1036
+ }
1037
+ _click(h) {
1038
+ const hit = this._hit(h);
1039
+ if (hit) this.options.onClick?.(hit, this);
1040
+ }
1041
+
774
1042
  _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;
1043
+ const w = this.plot.clientWidth;
1044
+ const h = this.plot.clientHeight;
1045
+ if (w === this.engine.w && h === this.engine.h) return false;
782
1046
  this.engine.resize(Math.max(50, w), Math.max(50, h));
783
- this.theme = resolveTheme(this.container);
784
1047
  this._dirty = true;
1048
+ return true;
785
1049
  }
786
1050
 
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);
1051
+ _observe() {
1052
+ if (typeof ResizeObserver !== 'undefined') {
1053
+ this._ro = new ResizeObserver(() => { if (this._resize()) schedule(this); });
1054
+ this._ro.observe(this.plot);
1055
+ }
1056
+ // Pause off-screen charts; their entry animation plays when they scroll in.
1057
+ if (typeof IntersectionObserver !== 'undefined') {
1058
+ this._io = new IntersectionObserver((entries) => {
1059
+ this._visible = entries[entries.length - 1].isIntersecting;
1060
+ if (this._visible && this._dirty) schedule(this);
1061
+ });
1062
+ this._io.observe(this.container);
1063
+ }
794
1064
  }
795
1065
 
796
1066
  _setupEvents() {
797
1067
  const c = this.canvas;
798
1068
  this._handlers = {
799
- move: (e) => this._onMove(e),
800
- leave: () => this._onLeave(),
1069
+ pointermove: (e) => this._onMove(e),
1070
+ pointerdown: (e) => { if (e.pointerType !== 'mouse') this._onMove(e); },
1071
+ // Touch: a tap shows the tooltip and it stays until the next tap or blur.
1072
+ pointerleave: (e) => { if (e.pointerType === 'mouse') this._onLeave(); },
801
1073
  click: (e) => this._onClick(e),
1074
+ keydown: (e) => this._onKey(e),
1075
+ blur: () => this._onLeave(),
802
1076
  };
803
- c.addEventListener('mousemove', this._handlers.move);
804
- c.addEventListener('mouseleave', this._handlers.leave);
805
- c.addEventListener('click', this._handlers.click);
1077
+ for (const k in this._handlers) c.addEventListener(k, this._handlers[k]);
1078
+ this._legendClick = (e) => {
1079
+ const b = e.target.closest('button[data-i]');
1080
+ if (b) this._toggle(+b.dataset.i);
1081
+ };
1082
+ this.legendEl.addEventListener('click', this._legendClick);
806
1083
  }
807
1084
  _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);
1085
+ for (const k in this._handlers) this.canvas.removeEventListener(k, this._handlers[k]);
1086
+ this.legendEl.removeEventListener('click', this._legendClick);
1087
+ }
1088
+
1089
+ _toggle(i) {
1090
+ const items = this._legend() || [];
1091
+ if (this._off.has(i)) this._off.delete(i);
1092
+ // Keep at least one series visible.
1093
+ else if (items.length - this._off.size > 1) this._off.add(i);
1094
+ this._legendKey = '';
1095
+ this.options.onLegend?.(i, !this._off.has(i), this);
1096
+ this._restart();
812
1097
  }
813
1098
 
814
- // Convert a mouse event to canvas-CSS-pixel coordinates.
815
1099
  _ptr(e) {
816
1100
  const r = this.canvas.getBoundingClientRect();
817
1101
  return { x: e.clientX - r.left, y: e.clientY - r.top };
818
1102
  }
819
1103
 
820
- _onMove(e) {
821
- const p = this._ptr(e);
822
- this._cursor = p;
823
- const hit = this.hits.find(p.x, p.y);
1104
+ _setHover(hit, p) {
824
1105
  const changed = (hit?._id ?? null) !== (this._hover?._id ?? null);
825
1106
  this._hover = hit;
826
1107
  if (changed) {
827
1108
  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) {
1109
+ this.tooltip.show(hit.tipX ?? hit.x + hit.w / 2, hit.tipY ?? hit.y, hit.tooltip, this.theme);
1110
+ } else if (!this._follow) {
835
1111
  this.tooltip.hide();
836
1112
  }
1113
+ }
1114
+ // Crosshair charts (_follow) redraw on every move; others only on change.
1115
+ if (changed || this._follow) {
837
1116
  this._dirty = true;
838
1117
  schedule(this);
839
1118
  }
840
- // Subclass hook for shared/follow tooltips
841
1119
  if (typeof this._onMoveX === 'function') this._onMoveX(p);
1120
+ return changed;
1121
+ }
1122
+
1123
+ _onMove(e) {
1124
+ const p = this._ptr(e);
1125
+ this._cursor = p;
1126
+ this._setHover(this.hits.find(p.x, p.y), p);
842
1127
  }
843
1128
 
844
1129
  _onLeave() {
845
1130
  this._cursor = null;
846
1131
  this._hover = null;
1132
+ this._key = -1;
847
1133
  this.tooltip.hide();
848
1134
  this._dirty = true;
849
1135
  schedule(this);
@@ -851,257 +1137,349 @@ class Chart {
851
1137
 
852
1138
  _onClick(e) {
853
1139
  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);
1140
+ this._click(this.hits.find(p.x, p.y) || (this._follow ? this._hover : null));
1141
+ }
1142
+
1143
+ // ← → (↑ ↓) walk through the data points, Home/End jump, Enter clicks,
1144
+ // Escape clears. Crosshair charts step along their x positions instead.
1145
+ _onKey(e) {
1146
+ const k = e.key;
1147
+ const fwd = k === 'ArrowRight' || k === 'ArrowDown';
1148
+ const back = k === 'ArrowLeft' || k === 'ArrowUp';
1149
+ if (k === 'Escape') return this._onLeave();
1150
+ if (k === 'Enter' && this._hover) return this._click(this._hover);
1151
+ if (!fwd && !back && k !== 'Home' && k !== 'End') return;
1152
+ const steps = this._steps?.length
1153
+ ? this._steps.map((x) => ({ x, y: this.engine.h / 2 }))
1154
+ : this.hits.regions.filter((r) => r.tooltip);
1155
+ if (!steps.length) return;
1156
+ e.preventDefault();
1157
+ const n = steps.length;
1158
+ const cur = this._key ?? -1;
1159
+ this._key = k === 'Home' ? 0 : k === 'End' ? n - 1 : cur < 0 ? (fwd ? 0 : n - 1) : (cur + (fwd ? 1 : n - 1)) % n;
1160
+ const s = steps[this._key];
1161
+ if (this._steps?.length) {
1162
+ this._cursor = { x: s.x, y: s.y };
1163
+ this._setHover(null, this._cursor);
1164
+ this._dirty = true;
1165
+ this._tick(performance.now()); // draw now so the shared tooltip text is ready
1166
+ this.live.textContent = this._tipNow ? tipText(this._tipNow) : '';
1167
+ } else {
1168
+ this._cursor = { x: s.tipX ?? s.x + s.w / 2, y: s.tipY ?? s.y };
1169
+ this._setHover(s, this._cursor);
1170
+ this.live.textContent = tipText(s.tooltip);
1171
+ }
856
1172
  }
857
1173
 
858
- // Per-frame: advance animation, draw if dirty, return whether more frames
859
- // are needed.
1174
+ // Per frame: advance animation, draw if dirty. Returns true while more
1175
+ // frames are needed.
860
1176
  _tick(now) {
861
1177
  if (this._destroyed) return false;
1178
+ if (!this._visible) {
1179
+ if (this._anim.progress < 1) this._anim.start = 0;
1180
+ return false;
1181
+ }
862
1182
  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);
1183
+ const a = this._anim;
1184
+ if (a.progress < 1) {
1185
+ if (!a.start) a.start = now;
1186
+ const t = Math.min(1, (now - a.start) / a.duration);
1187
+ a.progress = t === 1 ? 1 : 1 - Math.pow(1 - t, 3); // ease-out cubic
866
1188
  this._dirty = true;
867
- if (this._anim.progress < 1) active = true;
1189
+ if (t < 1) active = true;
868
1190
  }
869
1191
  if (this._dirty) {
870
- this._draw();
871
1192
  this._dirty = false;
1193
+ this._draw();
872
1194
  }
873
1195
  return active;
874
1196
  }
875
1197
 
876
1198
  _draw() {
877
1199
  const e = this.engine;
1200
+ this._renderLegend();
1201
+ this._resize(); // the legend may have changed the plot height
878
1202
  e.clear();
879
-
880
- // Background fill if surface is set.
1203
+ this.hits.clear();
1204
+ this._steps = null;
881
1205
  if (this.theme.surface && this.theme.surface !== 'transparent') {
882
1206
  e.rect(0, 0, e.w, e.h, { fill: this.theme.surface });
883
1207
  }
884
-
885
- // State overlay paths
886
- if (this._state) {
1208
+ if (this._state && !this._auto) {
887
1209
  this.overlay.show(this._state, this._stateMsg, this.theme);
888
- this.hits.clear();
889
1210
  return;
890
- } else {
891
- this.overlay.hide();
892
1211
  }
893
-
894
- // Subclass renders here.
895
- this.hits.clear();
1212
+ this._state = null;
1213
+ this._auto = false;
1214
+ this.overlay.hide();
896
1215
  if (typeof this._render === 'function') this._render();
897
1216
  }
898
1217
 
899
- // ─── Helpers shared by subclasses ────────────────────────
1218
+ // Legend items for this chart: [{ label, color }] or null for no legend.
1219
+ // Subclasses override. `options.legend === false` hides it.
1220
+ _legend() { return null; }
1221
+
1222
+ _renderLegend() {
1223
+ const items = this.options.legend === false ? null : this._legend();
1224
+ const key = items ? items.map((it, i) => `${it.label}|${it.color}|${this._off.has(i)}`).join(',') : '';
1225
+ if (key === this._legendKey) return;
1226
+ this._legendKey = key;
1227
+ const el = this.legendEl;
1228
+ el.textContent = '';
1229
+ el.style.display = items && items.length ? 'flex' : 'none';
1230
+ if (!items) return;
1231
+ el.style.color = this.theme.text;
1232
+ items.forEach((it, i) => {
1233
+ const off = this._off.has(i);
1234
+ const b = div(el,
1235
+ 'display:inline-flex;align-items:center;gap:6px;padding:2px 0;border:0;background:none;color:inherit;' +
1236
+ `font:inherit;letter-spacing:normal;text-transform:none;border-radius:0;box-shadow:none;min-width:0;min-height:0;cursor:pointer;opacity:${off ? 0.4 : 1}`, 'button');
1237
+ b.type = 'button';
1238
+ b.dataset.i = i;
1239
+ b.setAttribute('aria-pressed', String(!off));
1240
+ div(b, `width:10px;height:10px;border-radius:2px;background:${it.color}`, 'span');
1241
+ b.appendChild(document.createTextNode(it.label));
1242
+ });
1243
+ }
1244
+
1245
+ // Series legend shared by multi-series charts.
1246
+ _seriesLegend(series) {
1247
+ return series.map((s, i) => ({ label: s.name || 'Series ' + (i + 1), color: this._colorOf(i, s) }));
1248
+ }
900
1249
 
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
- };
1250
+ // Color for series / item index, respecting per-item overrides.
1251
+ _colorOf(idx, item) {
1252
+ if (item && item.color) return item.color;
1253
+ return this.palette[idx % this.palette.length];
910
1254
  }
1255
+ }
911
1256
 
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}`;
1257
+ // Removed in 2.0 — fail loudly with the replacement instead of "is not a function".
1258
+ const GONE = {
1259
+ setData: 'update({ data })', setLoading: "setState('loading', text)", setEmpty: "setState('empty', text)",
1260
+ setError: "setState('error', text)", clearState: 'setState(null)', invalidate: 'render()', setValue: 'update({ data: value })',
1261
+ };
1262
+ for (const k in GONE) {
1263
+ Chart.prototype[k] = function () { throw new Error(`aeriz 2.0: chart.${k}() was removed. Use chart.${GONE[k]}.`); };
1264
+ }
925
1265
 
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
- }
1266
+ // ── src/cartesian.js
1267
+ // cartesian.js — Axes + grid for x/y charts. Only charts with axes import it.
942
1268
 
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
1269
 
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
- }
1270
+ // Local-time tick labels sized to the tick step (1.x printed UTC slices).
1271
+ const DTF = new Map();
1272
+ function dateFmt(opts) {
1273
+ const k = JSON.stringify(opts);
1274
+ let f = DTF.get(k);
1275
+ if (!f) DTF.set(k, (f = new Intl.DateTimeFormat(undefined, opts)));
1276
+ return f;
1277
+ }
1278
+ function localTimeFormat(step) {
1279
+ const o = step < 6e4 ? { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }
1280
+ : step < 864e5 ? { hour: '2-digit', minute: '2-digit', hour12: false }
1281
+ : step < 2.5e9 ? { month: 'short', day: 'numeric' }
1282
+ : step < 3e10 ? { year: '2-digit', month: 'short' }
1283
+ : { year: 'numeric' };
1284
+ const f = dateFmt(o);
1285
+ return (t) => f.format(t);
1286
+ }
961
1287
 
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
- }
1288
+ // Draw grid, y axis (left, optional right) and x axis. Returns plot geometry.
1289
+ //
1290
+ // spec: { yMin, yMax, yScale?: 'linear'|'log', yFormat?,
1291
+ // xLabels?: any[] | xTime?: {min,max} | xLinear?: {min,max},
1292
+ // right?: { yMin, yMax, yFormat? }, pad?: {l,r,t,b} }
1293
+ // The value axis uses the chart's `format`; x labels use its `labelFormat`.
1294
+ function drawCartesian(chart, spec) {
1295
+ const e = chart.engine;
1296
+ const t = chart.theme;
1297
+ const font = `${t.fontSize}px ${t.font}`;
1298
+ const yMin = spec.yMin != null ? spec.yMin : 0;
1299
+ const yMax = spec.yMax;
1300
+ const yScale = spec.yScale || 'linear';
1301
+
1302
+ // Y ticks first: their label width decides the left padding.
1303
+ let yTicks, yNorm;
1304
+ if (yScale === 'log') {
1305
+ const lo = Math.max(1e-10, spec.yMin || 1);
1306
+ const lLo = Math.log10(lo), lHi = Math.log10(Math.max(lo * 10, yMax));
1307
+ yNorm = (v) => (Math.log10(Math.max(lo, v)) - lLo) / (lHi - lLo);
1308
+ yTicks = [];
1309
+ for (let p = Math.floor(lLo); p <= Math.ceil(lHi); p++) {
1310
+ const v = Math.pow(10, p);
1311
+ if (v >= lo && v <= yMax) yTicks.push(v);
1312
+ }
1313
+ } else {
1314
+ yTicks = M.niceTicks(yMin, yMax, 5);
1315
+ yNorm = (v) => (v - yMin) / (yMax - yMin || 1);
1316
+ }
1317
+ const yFmt = spec.yFormat ? (v) => M.formatNumber(v, spec.yFormat, true) : (v) => chart._tickFmt(v);
1318
+ const yText = yTicks.map((v) => yFmt(v));
1319
+ let maxW = 0;
1320
+ for (const s of yText) maxW = Math.max(maxW, e.measure(s, font));
1321
+
1322
+ const right = spec.right;
1323
+ let rTicks = [], rText = [], rNorm = null, rMaxW = 0;
1324
+ if (right) {
1325
+ rTicks = M.niceTicks(right.yMin, right.yMax, 5);
1326
+ rNorm = (v) => (v - right.yMin) / (right.yMax - right.yMin || 1);
1327
+ const rf = right.yFormat ? (v) => M.formatNumber(v, right.yFormat, true) : (v) => chart._tickFmt(v);
1328
+ rText = rTicks.map((v) => rf(v));
1329
+ for (const s of rText) rMaxW = Math.max(rMaxW, e.measure(s, font));
1330
+ }
1331
+
1332
+ const p = spec.pad || {};
1333
+ const pad = {
1334
+ l: p.l != null ? p.l : Math.ceil(maxW) + 12,
1335
+ r: p.r != null ? p.r : right ? Math.ceil(rMaxW) + 12 : 16,
1336
+ t: p.t != null ? p.t : 12,
1337
+ b: p.b != null ? p.b : t.fontSize + 16,
1338
+ };
1339
+ const px = pad.l, py = pad.t;
1340
+ const pw = Math.max(1, e.w - pad.l - pad.r);
1341
+ const ph = Math.max(1, e.h - pad.t - pad.b);
1342
+ const yToPx = (v) => py + ph * (1 - yNorm(v));
1343
+ const yRToPx = rNorm ? (v) => py + ph * (1 - rNorm(v)) : null;
1344
+
1345
+ // Grid
1346
+ const ctx = e.ctx;
1347
+ ctx.strokeStyle = t.grid;
1348
+ ctx.lineWidth = 1;
1349
+ ctx.beginPath();
1350
+ for (const v of yTicks) {
1351
+ const y = Math.round(yToPx(v)) + 0.5;
1352
+ ctx.moveTo(px, y);
1353
+ ctx.lineTo(px + pw, y);
1354
+ }
1355
+ ctx.stroke();
1356
+
1357
+ yTicks.forEach((v, i) => e.text(yText[i], px - 6, yToPx(v), { fill: t.muted, font, align: 'right', baseline: 'middle' }));
1358
+ rTicks.forEach((v, i) => e.text(rText[i], px + pw + 6, yRToPx(v), { fill: t.muted, font, align: 'left', baseline: 'middle' }));
1359
+
1360
+ // X axis: categorical labels, time, or linear.
1361
+ let xToPx, xTicks, xFmt;
1362
+ const lf = chart._fn('labelFormat');
1363
+ if (spec.xTime) {
1364
+ const { min, max } = spec.xTime;
1365
+ xToPx = (v) => px + (pw * (v - min)) / (max - min || 1);
1366
+ xTicks = M.timeNiceTicks(min, max, Math.max(2, Math.floor(pw / 90)));
1367
+ const step = xTicks.length > 1 ? xTicks[1] - xTicks[0] : max - min;
1368
+ xFmt = lf ? (v) => String(lf(v)) : localTimeFormat(step);
1369
+ } else if (spec.xLinear) {
1370
+ const { min, max } = spec.xLinear;
1371
+ xToPx = (v) => px + (pw * (v - min)) / (max - min || 1);
1372
+ xTicks = M.niceTicks(min, max, 6);
1373
+ xFmt = lf ? (v) => String(lf(v)) : (v) => chart._tickFmt(v);
1374
+ } else if (spec.xLabels) {
1375
+ const n = spec.xLabels.length || 1;
1376
+ xToPx = (i) => px + (i + 0.5) * (pw / n);
1377
+ xFmt = (i) => chart._lab(spec.xLabels[i]);
1378
+ // Only look at every k-th label: with 1M categories we can't measure them all.
1379
+ const guess = Math.max(e.measure(xFmt(0), font), e.measure(xFmt(n - 1), font)) + 6;
1380
+ const k = Math.max(1, Math.ceil((n * guess) / pw));
1381
+ xTicks = [];
1382
+ for (let i = 0; i < n; i += k) xTicks.push(i);
1383
+ }
1384
+ if (xToPx) {
1385
+ // Skip labels that would collide; keep the outer ones inside the canvas.
1386
+ let lastEnd = -Infinity;
1387
+ for (const v of xTicks) {
1388
+ const txt = xFmt(v);
1389
+ const w = e.measure(txt, font);
1390
+ const x = Math.max(w / 2, Math.min(e.w - w / 2, xToPx(v)));
1391
+ if (x - w / 2 < lastEnd + 6) continue;
1392
+ lastEnd = x + w / 2;
1393
+ e.text(txt, x, py + ph + 6, { fill: t.muted, font, align: 'center', baseline: 'top' });
995
1394
  }
1395
+ }
996
1396
 
997
- // Axis line
998
- e.poly([{x: px, y: py + ph}, {x: px + pw, y: py + ph}], {
999
- stroke: t.axis, lineWidth: 1,
1000
- });
1397
+ e.poly([{ x: px, y: py + ph }, { x: px + pw, y: py + ph }], { stroke: t.axis, lineWidth: 1 });
1001
1398
 
1002
- return { px, py, pw, ph, yToPx, xToPx, yTicks, xTicks, yMin, yMax };
1003
- }
1399
+ return { px, py, pw, ph, yToPx, yRToPx, xToPx, yTicks, xTicks, yMin, yMax };
1400
+ }
1004
1401
 
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;
1402
+ // Min/max over many arrays without spreading (Math.min(...big) throws for
1403
+ // ~100k+ values).
1404
+ function range(arrays, lo = Infinity, hi = -Infinity) {
1405
+ for (const arr of arrays) {
1406
+ for (let i = 0; i < arr.length; i++) {
1407
+ const v = +arr[i];
1408
+ if (v < lo) lo = v;
1409
+ if (v > hi) hi = v;
1026
1410
  }
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
1411
  }
1412
+ if (lo === Infinity) return [0, 1];
1413
+ return [lo, hi];
1035
1414
  }
1036
1415
 
1037
- // charts.js — Concrete chart implementations.
1038
- // Each subclass implements _render(). All share Chart's lifecycle, hit
1039
- // testing, tooltip, theme, and animation infrastructure.
1416
+ // Value-axis domain from the data range, with the chart's min / max options
1417
+ // taking precedence over the nice rounding.
1418
+ function valueDomain(o, lo, hi) {
1419
+ const [a, b] = M.niceDomain(o.min ?? lo, o.max ?? hi, 5);
1420
+ return [o.min ?? a, o.max ?? b];
1421
+ }
1040
1422
 
1423
+ // Option names shared by charts with x/y axes.
1424
+ const AXIS = ['labelFormat', 'min', 'max'];
1041
1425
 
1426
+ // ── src/charts/bar.js
1427
+ // BarChart — vertical bars, negative values grow downward from zero.
1042
1428
 
1043
1429
 
1044
- // ─────────────────────────────────────────────────────────
1045
- // 1. BAR (vertical)
1046
- // ─────────────────────────────────────────────────────────
1047
1430
  class BarChart extends Chart {
1431
+ static get meta() {
1432
+ return { id: 'bar', name: 'BarChart', aria: 'Bar chart', data: 'rows', options: ['label', 'value', 'labelFormat', 'min', 'max', 'dataLabels'] };
1433
+ }
1434
+
1048
1435
  _render() {
1049
- const data = this.options.data || [];
1050
- if (data.length === 0) { this.setEmpty('No data'); return; }
1436
+ const data = toRows(this.options.data);
1437
+ if (!data.length) return this._empty();
1051
1438
  const e = this.engine;
1052
1439
  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({
1440
+ const o = this.options;
1441
+ const labelOf = this._fn('label') || ((d) => d.label);
1442
+ const valueOf = this._fn('value') || ((d) => d.value);
1443
+ const values = data.map((d, i) => +valueOf(d, i) || 0);
1444
+ const labels = data.map((d, i) => labelOf(d, i));
1445
+ const [lo, hi] = range([values], 0, 0);
1446
+ const [yLo, yHi] = valueDomain(o, lo, hi);
1447
+ const dl = o.dataLabels;
1448
+ const ax = drawCartesian(this, {
1062
1449
  yMin: yLo, yMax: yHi,
1063
- xLabels: data.map(labelOf),
1064
- yTickFormat: this.options.yTickFormat,
1065
- xTickFormat: this.options.xTickFormat,
1450
+ xLabels: labels,
1451
+ pad: dl ? { t: t.fontSize + 10 } : undefined,
1066
1452
  });
1067
1453
 
1068
1454
  const slot = ax.pw / data.length;
1069
- const barW = Math.max(2, Math.min(60, slot - 8));
1455
+ const barW = Math.max(1, Math.min(60, slot * 0.8, slot - 2));
1070
1456
  const prog = this._anim.progress;
1071
- const radius = t.barRadius;
1072
-
1073
- // Group by color for batching.
1457
+ const y0 = ax.yToPx(Math.max(yLo, 0));
1074
1458
  const groups = new Map();
1075
- const fmt = this.options.dataLabels;
1459
+ const font = `${t.fontSize}px ${t.font}`;
1076
1460
 
1077
1461
  for (let i = 0; i < data.length; i++) {
1078
1462
  const v = values[i];
1079
1463
  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;
1464
+ const h = Math.abs(ax.yToPx(v) - y0) * prog;
1465
+ const top = v >= 0 ? y0 - h : y0;
1085
1466
  const color = this._colorOf(i, data[i]);
1086
1467
  if (!groups.has(color)) groups.set(color, []);
1087
- groups.get(color).push({ x, y: animTop, w: barW, h, radius });
1088
-
1468
+ groups.get(color).push({ x, y: top, w: barW, h, radius: t.barRadius });
1469
+ const label = this._lab(labels[i]);
1089
1470
  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}`,
1471
+ _id: i, x, y: Math.min(top, y0 - 2), w: barW, h: Math.max(h, 4),
1472
+ tipX: x + barW / 2, tipY: top,
1473
+ tooltip: `${label}: ${this._fmt(v)}`,
1474
+ index: i, label: labels[i], value: v, row: data[i],
1094
1475
  });
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',
1476
+ if (dl && prog > 0.95) {
1477
+ const txt = typeof dl === 'function' ? dl(v, data[i], i) : this._fmt(v);
1478
+ e.text(txt, x + barW / 2, v >= 0 ? top - 4 : top + h + 4, {
1479
+ fill: t.text, font, align: 'center', baseline: v >= 0 ? 'bottom' : 'top',
1101
1480
  });
1102
1481
  }
1103
1482
  }
1104
-
1105
1483
  for (const [color, items] of groups) e.rects(items, color);
1106
1484
 
1107
1485
  if (this._hover) {
@@ -1111,78 +1489,76 @@ class BarChart extends Chart {
1111
1489
  }
1112
1490
  }
1113
1491
 
1114
- // ─────────────────────────────────────────────────────────
1115
- // 2. HORIZONTAL BAR
1116
- // ─────────────────────────────────────────────────────────
1492
+ // ── src/charts/hbar.js
1493
+ // HBarChart — horizontal bars. The label column is as wide as the longest label.
1494
+
1495
+
1496
+
1117
1497
  class HBarChart extends Chart {
1118
- _axisPad() {
1119
- return { l: 80, r: 16, t: 16, b: 32 };
1498
+ static get meta() {
1499
+ return { id: 'hbar', name: 'HBarChart', aria: 'Horizontal bar chart', data: 'rows', options: ['label', 'value', 'labelFormat', 'min', 'max'] };
1120
1500
  }
1501
+
1121
1502
  _render() {
1122
- const data = this.options.data || [];
1123
- if (data.length === 0) { this.setEmpty('No data'); return; }
1503
+ const data = toRows(this.options.data);
1504
+ if (!data.length) return this._empty();
1124
1505
  const e = this.engine;
1125
1506
  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;
1507
+ const o = this.options;
1508
+ const labelOf = this._fn('label') || ((d) => d.label);
1509
+ const valueOf = this._fn('value') || ((d) => d.value);
1510
+ const values = data.map((d, i) => +valueOf(d, i) || 0);
1511
+ const raw = data.map((d, i) => labelOf(d, i));
1512
+ const labels = raw.map((l) => this._lab(l));
1513
+ const [lo, hi] = range([values], 0, 0);
1514
+ const [xLo, xHi] = valueDomain(o, lo, hi);
1137
1515
  const font = `${t.fontSize}px ${t.font}`;
1138
1516
 
1139
- // X grid + labels
1140
- const xTicks = M.niceTicks(xLo, xHi, 5);
1141
- const xToPx = v => px + pw * (v - xLo) / (xHi - xLo);
1517
+ let labelW = 0;
1518
+ for (const s of labels) labelW = Math.max(labelW, e.measure(s, font));
1519
+ const px = Math.min(Math.ceil(labelW) + 12, e.w * 0.4), py = 12;
1520
+ const pw = Math.max(1, e.w - px - 20);
1521
+ const ph = Math.max(1, e.h - py - t.fontSize - 16);
1522
+
1523
+ const xTicks = M.niceTicks(xLo, xHi, Math.max(2, Math.min(6, Math.floor(pw / 70))));
1524
+ const xToPx = (v) => px + (pw * (v - xLo)) / (xHi - xLo || 1);
1142
1525
  e.ctx.strokeStyle = t.grid;
1143
1526
  e.ctx.lineWidth = 1;
1144
1527
  e.ctx.beginPath();
1145
1528
  for (const v of xTicks) {
1146
1529
  const x = Math.round(xToPx(v)) + 0.5;
1147
- e.ctx.moveTo(x, py); e.ctx.lineTo(x, py + ph);
1530
+ e.ctx.moveTo(x, py);
1531
+ e.ctx.lineTo(x, py + ph);
1148
1532
  }
1149
1533
  e.ctx.stroke();
1150
1534
  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
- });
1535
+ e.text(this._tickFmt(v), xToPx(v), py + ph + 6, { fill: t.muted, font, align: 'center', baseline: 'top' });
1154
1536
  }
1155
- e.poly([{x: px, y: py + ph}, {x: px + pw, y: py + ph}], { stroke: t.axis, lineWidth: 1 });
1537
+ e.poly([{ x: px, y: py + ph }, { x: px + pw, y: py + ph }], { stroke: t.axis, lineWidth: 1 });
1156
1538
 
1157
1539
  const slot = ph / data.length;
1158
- const barH = Math.max(2, Math.min(40, slot - 6));
1540
+ const barH = Math.max(1, Math.min(40, slot * 0.75, slot - 2));
1159
1541
  const prog = this._anim.progress;
1542
+ const x0 = xToPx(Math.max(xLo, 0));
1160
1543
  const groups = new Map();
1161
1544
 
1162
1545
  for (let i = 0; i < data.length; i++) {
1163
1546
  const v = values[i];
1164
1547
  const yMid = py + (i + 0.5) * slot;
1165
1548
  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;
1549
+ const w = Math.abs(xToPx(v) - x0) * prog;
1550
+ const left = v >= 0 ? x0 : x0 - w;
1171
1551
  const color = this._colorOf(i, data[i]);
1172
1552
  if (!groups.has(color)) groups.set(color, []);
1173
- groups.get(color).push({ x: animLeft, y, w, h: barH, radius: t.barRadius });
1174
-
1553
+ groups.get(color).push({ x: left, y, w, h: barH, radius: t.barRadius });
1175
1554
  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',
1555
+ _id: i, x: Math.min(left, x0 - 2), y, w: Math.max(w, 4), h: barH,
1556
+ tipX: left + w / 2, tipY: y,
1557
+ tooltip: `${labels[i]}: ${this._fmt(v)}`,
1558
+ index: i, label: raw[i], value: v, row: data[i],
1183
1559
  });
1560
+ e.text(labels[i], px - 6, yMid, { fill: t.muted, font, align: 'right', baseline: 'middle' });
1184
1561
  }
1185
-
1186
1562
  for (const [color, items] of groups) e.rects(items, color);
1187
1563
  if (this._hover) {
1188
1564
  const r = this._hover;
@@ -1191,1407 +1567,1431 @@ class HBarChart extends Chart {
1191
1567
  }
1192
1568
  }
1193
1569
 
1194
- // ─────────────────────────────────────────────────────────
1195
- // 3. LINE
1196
- // ─────────────────────────────────────────────────────────
1570
+ // ── src/charts/line.js
1571
+ // LineChart — smooth · linear · step, category / time x, log y, and min-max
1572
+ // decimation for very large series (handles millions of points).
1573
+
1574
+
1575
+
1576
+ const LABEL = (d) => d.label;
1577
+ const VALUE = (d) => d.value;
1578
+
1197
1579
  class LineChart extends Chart {
1580
+ static get meta() {
1581
+ return {
1582
+ id: 'line', name: 'LineChart', aria: 'Line chart', data: 'rows',
1583
+ options: ['label', 'value', 'labelFormat', 'min', 'max', 'curve', 'fill', 'points', 'xScale', 'yScale'],
1584
+ enums: { curve: ['smooth', 'linear', 'step'], xScale: ['category', 'time'], yScale: ['linear', 'log'] },
1585
+ };
1586
+ }
1587
+
1198
1588
  _render() {
1199
- const data = this.options.data || [];
1200
- if (data.length === 0) { this.setEmpty('No data'); return; }
1589
+ const o = this.options;
1590
+ const labelOf = this._fn('label') || LABEL;
1591
+ const valueOf = this._fn('value') || VALUE;
1592
+
1593
+ // Rows and values are cached per data array so hover redraws don't
1594
+ // re-map 1M rows.
1595
+ let c = this._cache;
1596
+ if (!c || c.data !== o.data || c.valueOf !== valueOf || c.labelOf !== labelOf) {
1597
+ const d = o.data;
1598
+ // Plain numbers (incl. typed arrays): skip building a row object per point.
1599
+ const nums = d && typeof d[0] === 'number' && (Array.isArray(d) || ArrayBuffer.isView(d)) && !this._fn('value') && !this._fn('label');
1600
+ const rows = nums ? d : toRows(d);
1601
+ const values = nums ? Float64Array.from(d) : new Float64Array(rows.length);
1602
+ if (!nums) for (let i = 0; i < rows.length; i++) values[i] = +valueOf(rows[i], i);
1603
+ const labels = nums ? Array.from({ length: d.length }, (_, i) => i + 1) : null;
1604
+ c = this._cache = { data: o.data, rows, valueOf, labelOf, values, idx: null, idxW: 0, xs: null, labels, range: null };
1605
+ }
1606
+ const data = c.rows;
1607
+ if (!data.length) return this._empty();
1201
1608
  const e = this.engine;
1202
1609
  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';
1610
+ const n = data.length;
1611
+ const values = c.values;
1612
+ const log = o.yScale === 'log';
1613
+ const [lo, hi] = (c.range ||= range([values], log ? Infinity : 0));
1614
+ const [yLo, yHi] = log ? [o.min ?? lo, o.max ?? hi] : valueDomain(o, lo, hi);
1615
+ const useTime = o.xScale === 'time';
1616
+ const spec = { yMin: yLo, yMax: yHi, yScale: o.yScale };
1617
+ const xs = useTime ? (c.xs ||= Array.from(data, (d, i) => +labelOf(d, i))) : null;
1618
+ if (useTime) spec.xTime = { min: xs[0], max: xs[n - 1] };
1619
+ else spec.xLabels = c.labels ||= Array.from(data, (d, i) => labelOf(d, i));
1620
+ const ax = drawCartesian(this, spec);
1621
+
1622
+ const color = this.palette[0];
1623
+ const interp = o.curve || 'smooth';
1226
1624
  const lineW = t.lineWidth;
1227
1625
  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)
1626
+ const xAt = useTime ? (i) => ax.xToPx(xs[i]) : (i) => ax.px + ((i + 0.5) / n) * ax.pw;
1627
+
1628
+ // Decimate to ~2 points per pixel column, keeping original indices.
1629
+ const decimated = M.shouldDecimate(n, ax.pw);
1630
+ if (decimated && (!c.idx || c.idxW !== Math.floor(ax.pw))) {
1631
+ c.idxW = Math.floor(ax.pw);
1632
+ c.idx = M.minMaxDecimateIdx(n, c.idxW, (i) => values[i]);
1633
+ }
1634
+ const idx = decimated ? c.idx : null;
1635
+ const count = idx ? idx.length : n;
1636
+ const points = new Array(count);
1637
+ for (let k = 0; k < count; k++) {
1638
+ const i = idx ? idx[k] : k;
1639
+ points[k] = { x: xAt(i), y: ax.yToPx(values[i]), i };
1640
+ }
1641
+
1642
+ // Reveal left → right.
1643
+ e.clip(ax.px - lineW, ax.py - lineW, (ax.pw + lineW * 2) * prog, ax.ph + lineW * 2);
1644
+ const smooth = interp === 'smooth' && !decimated;
1645
+ const base = ax.py + ax.ph;
1250
1646
  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 },
1647
+ const cps = smooth ? M.bezierControlPoints(points) : null;
1648
+ if (o.fill !== false) {
1649
+ const grad = e.vGradient(`area-${color}`, ax.px, ax.py, base, [
1650
+ [0, M.colorAlpha(color, 0.25)],
1651
+ [1, M.colorAlpha(color, 0)],
1261
1652
  ]);
1262
- e.poly(areaPts, { fill: grad });
1653
+ if (cps) e.smoothArea(points, cps, base, grad);
1654
+ else e.poly(points.concat([{ x: points[count - 1].x, y: base }, { x: points[0].x, y: base }]), { fill: grad });
1263
1655
  }
1264
1656
  if (cps) e.smooth(points, cps, { stroke: color, lineWidth: lineW });
1265
1657
  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 });
1658
+ } else {
1659
+ e.step(points, 'before', { stroke: color, lineWidth: lineW });
1269
1660
  }
1270
-
1271
1661
  e.unclip();
1272
1662
 
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);
1663
+ const labelAt = (i) => (useTime ? xs[i] : c.labels[i]);
1664
+ const tip = (i) => `${this._lab(labelAt(i), useTime)}: ${this._fmt(values[i])}`;
1665
+ if (!decimated && n <= 50) {
1666
+ if (o.points !== false) e.circles(points, color, t.pointRadius);
1277
1667
  for (const p of points) {
1278
1668
  this.hits.add({
1279
1669
  _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)}`,
1670
+ tipX: p.x, tipY: p.y - t.pointRadius,
1671
+ tooltip: tip(p.i), index: p.i, label: labelAt(p.i), value: values[p.i], row: data[p.i],
1282
1672
  });
1283
1673
  }
1284
1674
  } 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];
1675
+ // Invisible full-height columns, one per drawn point.
1676
+ for (let k = 0; k < count; k++) {
1677
+ const p = points[k];
1678
+ const l = k ? (points[k - 1].x + p.x) / 2 : ax.px;
1679
+ const r = k < count - 1 ? (points[k + 1].x + p.x) / 2 : ax.px + ax.pw;
1680
+ if (r <= l && k < count - 1) continue;
1289
1681
  this.hits.add({
1290
- _id: p.i, x: p.x - colW / 2, y: ax.py, w: colW, h: ax.ph,
1682
+ _id: p.i, x: l, y: ax.py, w: Math.max(1, r - l), h: ax.ph,
1291
1683
  tipX: p.x, tipY: p.y - 8,
1292
- tooltip: `${labelOf(p.d, p.i)}: ${valueOf(p.d, p.i)}`,
1684
+ tooltip: tip(p.i), index: p.i, label: labelAt(p.i), value: values[p.i], row: data[p.i],
1293
1685
  });
1294
1686
  }
1295
1687
  }
1296
1688
 
1297
1689
  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
- });
1690
+ const p = points.find((pp) => pp.i === this._hover._id);
1691
+ if (p) e.circle(p.x, p.y, t.pointRadius + 3, { stroke: color, lineWidth: 2, fill: t.bg });
1302
1692
  }
1303
1693
  }
1304
1694
  }
1305
1695
 
1306
- // ─────────────────────────────────────────────────────────
1307
- // 4. AREA (line with always-filled area, supports stacked)
1308
- // ─────────────────────────────────────────────────────────
1696
+ // ── src/charts/area.js
1697
+ // AreaChart — one or more filled series; `stacked: true` stacks them.
1698
+
1699
+
1700
+
1309
1701
  class AreaChart extends Chart {
1702
+ static get meta() {
1703
+ return { id: 'area', name: 'AreaChart', aria: 'Area chart', data: 'series', options: ['labelFormat', 'min', 'max', 'stacked'] };
1704
+ }
1705
+
1706
+ _legend() { return this._seriesLegend(this.options.data?.series || []); }
1310
1707
  _render() {
1311
- const series = this.options.series || [];
1312
- const cats = this.options.categories || [];
1313
- if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
1708
+ const o = this.options;
1709
+ const { categories: cats = [], series = [] } = o.data || {};
1710
+ if (!series.length || !cats.length) return this._empty();
1314
1711
  const e = this.engine;
1315
1712
  const t = this.theme;
1316
- const stacked = this.options.stacked === true;
1713
+ const stacked = o.stacked === true;
1714
+ const on = series.map((s, i) => i).filter((i) => !this._isOff(i));
1317
1715
 
1318
- // Compute y domain.
1319
- let yMax;
1716
+ let yMin = 0, yMax;
1320
1717
  if (stacked) {
1321
1718
  yMax = 0;
1322
1719
  for (let i = 0; i < cats.length; i++) {
1323
1720
  let sum = 0;
1324
- for (const s of series) sum += +s.values[i] || 0;
1721
+ for (const s of on) sum += +series[s].values[i] || 0;
1325
1722
  if (sum > yMax) yMax = sum;
1326
1723
  }
1327
1724
  } else {
1328
- yMax = -Infinity;
1329
- for (const s of series) for (const v of s.values) if (+v > yMax) yMax = +v;
1725
+ [yMin, yMax] = range(on.map((s) => series[s].values), 0, 0);
1330
1726
  }
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
- });
1727
+ const [yLo, yHi] = valueDomain(o, yMin, yMax);
1728
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: cats });
1337
1729
  const lineW = t.lineWidth;
1338
1730
  const prog = this._anim.progress;
1731
+ const xAt = (i) => ax.px + ((i + 0.5) / cats.length) * ax.pw;
1732
+ const ctx = e.ctx;
1339
1733
 
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 });
1734
+ e.clip(ax.px, ax.py - lineW, ax.pw * prog, ax.ph + lineW * 2);
1735
+ const cum = new Array(cats.length).fill(0);
1736
+ for (const s of on) {
1737
+ const ser = series[s];
1738
+ const color = this._colorOf(s, ser);
1739
+ const top = [], bot = [];
1740
+ for (let i = 0; i < cats.length; i++) {
1741
+ const v = +ser.values[i] || 0;
1742
+ bot.push({ x: xAt(i), y: ax.yToPx(stacked ? cum[i] : Math.max(yLo, 0)) });
1743
+ top.push({ x: xAt(i), y: ax.yToPx(stacked ? cum[i] + v : v) });
1744
+ if (stacked) cum[i] += v;
1375
1745
  }
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 });
1746
+ const cps = M.bezierControlPoints(top);
1747
+ ctx.beginPath();
1748
+ ctx.moveTo(top[0].x, top[0].y);
1749
+ for (let i = 0; i < cps.length; i++) {
1750
+ ctx.bezierCurveTo(cps[i].c1x, cps[i].c1y, cps[i].c2x, cps[i].c2y, top[i + 1].x, top[i + 1].y);
1394
1751
  }
1752
+ for (let i = bot.length - 1; i >= 0; i--) ctx.lineTo(bot[i].x, bot[i].y);
1753
+ ctx.closePath();
1754
+ ctx.fillStyle = stacked
1755
+ ? M.colorAlpha(color, 0.7)
1756
+ : e.vGradient(`area-${s}-${color}`, ax.px, ax.py, ax.py + ax.ph, [[0, M.colorAlpha(color, 0.4)], [1, M.colorAlpha(color, 0)]]);
1757
+ ctx.fill();
1758
+ e.smooth(top, cps, { stroke: color, lineWidth: lineW });
1395
1759
  }
1396
-
1397
1760
  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);
1761
+
1762
+ // One hover column per category with every visible series.
1763
+ const slot = ax.pw / cats.length;
1764
+ for (let i = 0; i < cats.length; i++) {
1765
+ this.hits.add({
1766
+ _id: i, x: ax.px + i * slot, y: ax.py, w: slot, h: ax.ph,
1767
+ tipX: xAt(i), tipY: ax.py + 8, index: i, label: cats[i],
1768
+ tooltip: {
1769
+ title: this._lab(cats[i]),
1770
+ rows: on.map((s) => ({ color: this._colorOf(s, series[s]), name: series[s].name || 'Series ' + (s + 1), value: this._fmt(+series[s].values[i]) })),
1771
+ },
1772
+ });
1773
+ }
1774
+ if (this._hover) {
1775
+ const x = xAt(this._hover._id);
1776
+ e.poly([{ x, y: ax.py }, { x, y: ax.py + ax.ph }], { stroke: t.muted, lineWidth: 1, dash: [3, 3] });
1777
+ }
1402
1778
  }
1403
1779
  }
1404
1780
 
1405
- // ─────────────────────────────────────────────────────────
1406
- // 5. STACKED BAR
1407
- // ─────────────────────────────────────────────────────────
1781
+ // ── src/charts/stacked-bar.js
1782
+ // StackedBarChart — positive values stack up from zero, negative values down.
1783
+
1784
+
1408
1785
  class StackedBarChart extends Chart {
1786
+ static get meta() {
1787
+ return { id: 'stacked-bar', name: 'StackedBarChart', aria: 'Stacked bar chart', data: 'series', options: ['labelFormat', 'min', 'max'] };
1788
+ }
1789
+
1790
+ _legend() { return this._seriesLegend(this.options.data?.series || []); }
1409
1791
  _render() {
1410
- const series = this.options.series || [];
1411
- const cats = this.options.categories || [];
1412
- if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
1792
+ const o = this.options;
1793
+ const { categories: cats = [], series = [] } = o.data || {};
1794
+ if (!series.length || !cats.length) return this._empty();
1413
1795
  const e = this.engine;
1414
1796
  const t = this.theme;
1797
+ const on = series.map((s, i) => i).filter((i) => !this._isOff(i));
1415
1798
 
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;
1799
+ let lo = 0, hi = 0;
1800
+ for (let i = 0; i < cats.length; i++) {
1801
+ let pos = 0, neg = 0;
1802
+ for (const s of on) {
1803
+ const v = +series[s].values[i] || 0;
1804
+ if (v >= 0) pos += v; else neg += v;
1805
+ }
1806
+ if (pos > hi) hi = pos;
1807
+ if (neg < lo) lo = neg;
1808
+ }
1809
+ const [yLo, yHi] = valueDomain(o, lo, hi);
1810
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: cats });
1811
+
1812
+ const slot = ax.pw / cats.length;
1813
+ const barW = Math.max(1, Math.min(50, slot * 0.8, slot - 2));
1814
+ const prog = this._anim.progress;
1427
1815
  const groups = new Map();
1816
+ const y0 = ax.yToPx(0);
1817
+ const lastPos = [], lastNeg = [];
1428
1818
 
1429
1819
  for (let i = 0; i < cats.length; i++) {
1430
- let cum = 0;
1820
+ let pos = 0, neg = 0;
1431
1821
  const x = ax.px + (i + 0.5) * slot - barW / 2;
1432
- for (let s = 0; s < series.length; s++) {
1822
+ for (const s of on) {
1433
1823
  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;
1824
+ if (!v) continue;
1825
+ const from = v > 0 ? pos : neg;
1826
+ const to = from + v;
1827
+ if (v > 0) pos = to; else neg = to;
1828
+ // Animate the whole stack growing from the baseline.
1829
+ const ya = y0 + (ax.yToPx(from) - y0) * prog;
1830
+ const yb = y0 + (ax.yToPx(to) - y0) * prog;
1831
+ const y = Math.min(ya, yb), h = Math.abs(yb - ya);
1438
1832
  const color = this._colorOf(s, series[s]);
1439
1833
  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
- });
1834
+ const item = { x, y, w: barW, h, radius: 0 };
1835
+ groups.get(color).push(item);
1836
+ (v > 0 ? lastPos : lastNeg)[i] = item;
1445
1837
  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
- },
1838
+ _id: `${i}-${s}`, x, y, w: barW, h,
1839
+ tipX: x + barW / 2, tipY: y, index: i, label: cats[i], series: s, value: v, row: series[s],
1840
+ tooltip: { title: this._lab(cats[i]), rows: [{ color, name: series[s].name || 'Series ' + (s + 1), value: this._fmt(v) }] },
1454
1841
  });
1455
1842
  }
1456
1843
  }
1844
+ // Round only the outer end of each stack.
1845
+ for (const it of lastPos) if (it) it.radius = t.barRadius;
1846
+ for (const it of lastNeg) if (it) it.radius = t.barRadius;
1457
1847
  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);
1848
+ if (this._hover) {
1849
+ const r = this._hover;
1850
+ e.rect(r.x, r.y, r.w, r.h, { stroke: t.text, lineWidth: 1.5 });
1851
+ }
1463
1852
  }
1464
1853
  }
1465
1854
 
1466
- // ─────────────────────────────────────────────────────────
1467
- // 6. GROUPED BAR (clustered)
1468
- // ─────────────────────────────────────────────────────────
1855
+ // ── src/charts/grouped-bar.js
1856
+ // GroupedBarChart — series side by side in each category.
1857
+
1858
+
1469
1859
  class GroupedBarChart extends Chart {
1860
+ static get meta() {
1861
+ return { id: 'grouped-bar', name: 'GroupedBarChart', aria: 'Grouped bar chart', data: 'series', options: ['labelFormat', 'min', 'max'] };
1862
+ }
1863
+
1864
+ _legend() { return this._seriesLegend(this.options.data?.series || []); }
1470
1865
  _render() {
1471
- const series = this.options.series || [];
1472
- const cats = this.options.categories || [];
1473
- if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
1866
+ const o = this.options;
1867
+ const { categories: cats = [], series = [] } = o.data || {};
1868
+ if (!series.length || !cats.length) return this._empty();
1474
1869
  const e = this.engine;
1475
1870
  const t = this.theme;
1871
+ const on = series.map((s, i) => i).filter((i) => !this._isOff(i));
1476
1872
 
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
- });
1873
+ const [lo, hi] = range(on.map((s) => series[s].values), 0, 0);
1874
+ const [yLo, yHi] = valueDomain(o, lo, hi);
1875
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: cats });
1485
1876
 
1486
1877
  const slot = ax.pw / cats.length;
1487
1878
  const inner = slot * 0.8;
1488
- const barW = Math.max(2, inner / series.length);
1879
+ const barW = Math.max(1, inner / on.length);
1489
1880
  const prog = this._anim.progress;
1881
+ const y0 = ax.yToPx(0);
1490
1882
  const groups = new Map();
1491
1883
 
1492
1884
  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++) {
1885
+ const left = ax.px + (i + 0.5) * slot - inner / 2;
1886
+ on.forEach((s, k) => {
1496
1887
  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);
1888
+ const x = left + k * barW;
1889
+ const h = Math.abs(ax.yToPx(v) - y0) * prog;
1890
+ const y = v >= 0 ? y0 - h : y0;
1500
1891
  const color = this._colorOf(s, series[s]);
1501
1892
  if (!groups.has(color)) groups.set(color, []);
1502
- groups.get(color).push({ x, y, w: barW - 1, h, radius: t.barRadius });
1893
+ const w = Math.max(1, barW - (barW > 4 ? 1 : 0));
1894
+ groups.get(color).push({ x, y, w, h, radius: t.barRadius });
1503
1895
  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}`,
1896
+ _id: `${i}-${s}`, x, y: Math.min(y, y0 - 2), w, h: Math.max(h, 4),
1897
+ tipX: x + barW / 2, tipY: y, index: i, label: cats[i], series: s, value: v, row: series[s],
1898
+ tooltip: { title: this._lab(cats[i]), rows: [{ color, name: series[s].name || 'Series ' + (s + 1), value: this._fmt(v) }] },
1507
1899
  });
1508
- }
1900
+ });
1509
1901
  }
1510
1902
  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);
1903
+ if (this._hover) {
1904
+ const r = this._hover;
1905
+ e.rect(r.x, r.y, r.w, r.h, { stroke: t.text, lineWidth: 1.5 });
1906
+ }
1516
1907
  }
1517
1908
  }
1518
1909
 
1519
- // ─────────────────────────────────────────────────────────
1520
- // 7. MULTI-LINE (multiple series, shared x)
1521
- // ─────────────────────────────────────────────────────────
1910
+ // ── src/charts/multi-line.js
1911
+ // MultiLineChart — several series on a shared x, with a crosshair tooltip
1912
+ // that follows the pointer (and the arrow keys).
1913
+
1914
+
1915
+
1522
1916
  class MultiLineChart extends Chart {
1917
+ static get meta() {
1918
+ return { id: 'multi-line', name: 'MultiLineChart', aria: 'Multi line chart', data: 'series', options: ['labelFormat', 'min', 'max', 'curve', 'xScale'], enums: { curve: ['smooth', 'linear', 'step'], xScale: ['category', 'time'] } };
1919
+ }
1920
+ constructor(container, options) {
1921
+ super(container, options);
1922
+ this._follow = true;
1923
+ }
1924
+ _legend() { return this._seriesLegend(this.options.data?.series || []); }
1523
1925
  _render() {
1524
- const series = this.options.series || [];
1525
- const cats = this.options.categories || [];
1526
- if (!series.length || !cats.length) { this.setEmpty('No data'); return; }
1926
+ const o = this.options;
1927
+ const { categories: cats = [], series = [] } = o.data || {};
1928
+ if (!series.length || !cats.length) return this._empty();
1527
1929
  const e = this.engine;
1528
1930
  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);
1931
+ const interp = o.curve || 'smooth';
1932
+ const on = series.map((s, i) => i).filter((i) => !this._isOff(i));
1933
+
1934
+ const [lo, hi] = range(on.map((s) => series[s].values), 0);
1935
+ const [yLo, yHi] = valueDomain(o, lo, hi);
1936
+ const useTime = o.xScale === 'time';
1937
+ const spec = { yMin: yLo, yMax: yHi };
1938
+ if (useTime) spec.xTime = { min: +cats[0], max: +cats[cats.length - 1] };
1939
+ else spec.xLabels = cats;
1940
+ const ax = drawCartesian(this, spec);
1549
1941
  const lineW = t.lineWidth;
1550
1942
  const prog = this._anim.progress;
1943
+ const xAt = useTime ? (i) => ax.xToPx(+cats[i]) : (i) => ax.px + ((i + 0.5) / cats.length) * ax.pw;
1944
+ this._steps = cats.map((_, i) => xAt(i));
1551
1945
 
1552
- e.clip(ax.px, ax.py, ax.pw * prog, ax.ph);
1553
- const allPts = [];
1554
- for (let s = 0; s < series.length; s++) {
1946
+ e.clip(ax.px - lineW, ax.py - lineW, (ax.pw + lineW * 2) * prog, ax.ph + lineW * 2);
1947
+ const all = [];
1948
+ for (const s of on) {
1555
1949
  const ser = series[s];
1556
- if (ser.hidden) continue;
1557
1950
  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);
1951
+ const pts = ser.values.map((v, i) => ({ x: xAt(i), y: ax.yToPx(+v) }));
1952
+ all.push({ pts, color, s, ser });
1953
+ if (interp === 'smooth') e.smooth(pts, M.bezierControlPoints(pts), { stroke: color, lineWidth: lineW });
1954
+ else if (interp === 'linear') e.poly(pts, { stroke: color, lineWidth: lineW, cap: 'round', join: 'round' });
1955
+ else e.step(pts, 'before', { stroke: color, lineWidth: lineW });
1956
+ if (pts.length <= 50) e.circles(pts, color, t.pointRadius);
1574
1957
  }
1575
1958
  e.unclip();
1576
1959
 
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;
1960
+ // Crosshair: nearest x position to the pointer.
1961
+ this._tipNow = null;
1962
+ const cx = this._cursor?.x;
1963
+ if (cx != null && cx >= ax.px - 4 && cx <= ax.px + ax.pw + 4 && all.length) {
1964
+ let idx = 0, best = Infinity;
1965
+ for (let i = 0; i < cats.length; i++) {
1966
+ const d = Math.abs(this._steps[i] - cx);
1967
+ if (d < best) { best = d; idx = i; }
1968
+ }
1969
+ const xPx = this._steps[idx];
1970
+ e.poly([{ x: xPx, y: ax.py }, { x: xPx, y: ax.py + ax.ph }], { stroke: t.muted, lineWidth: 1, dash: [3, 3] });
1971
+ const rows = [];
1972
+ for (const sp of all) {
1973
+ const p = sp.pts[idx];
1974
+ if (!p) continue;
1975
+ e.circle(p.x, p.y, t.pointRadius + 2, { fill: t.bg, stroke: sp.color, lineWidth: 2 });
1976
+ rows.push({ color: sp.color, name: sp.ser.name || 'Series ' + (sp.s + 1), value: this._fmt(+sp.ser.values[idx]) });
1612
1977
  }
1978
+ const title = this._lab(cats[idx], useTime);
1979
+ this._tipNow = { title, rows };
1980
+ this._hover = { _id: idx, index: idx, label: cats[idx] };
1981
+ this.tooltip.show(xPx, ax.py + 8, this._tipNow, t);
1982
+ } else if (this._cursor) {
1983
+ this.tooltip.hide();
1613
1984
  }
1985
+ }
1986
+ }
1614
1987
 
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);
1988
+ // ── src/charts/scatter.js
1989
+ // ScatterChart — points [{ x, y, size?, group?, label?, color? }], batched per
1990
+ // color/radius; `group` picks the color, `size` is the radius in px.
1991
+
1992
+
1993
+
1994
+ function xyExtent(data) {
1995
+ let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
1996
+ for (const d of data) {
1997
+ if (d.x < xMin) xMin = d.x;
1998
+ if (d.x > xMax) xMax = d.x;
1999
+ if (d.y < yMin) yMin = d.y;
2000
+ if (d.y > yMax) yMax = d.y;
1619
2001
  }
2002
+ return [xMin, xMax, yMin, yMax];
1620
2003
  }
1621
2004
 
1622
- // ─────────────────────────────────────────────────────────
1623
- // 8. SCATTER
1624
- // ─────────────────────────────────────────────────────────
1625
2005
  class ScatterChart extends Chart {
2006
+ static get meta() {
2007
+ return { id: 'scatter', name: 'ScatterChart', aria: 'Scatter chart', data: 'points', options: ['labelFormat', 'min', 'max'] };
2008
+ }
2009
+
1626
2010
  _render() {
1627
- const data = this.options.data || [];
1628
- if (!data.length) { this.setEmpty('No data'); return; }
2011
+ const o = this.options;
2012
+ const data = o.data || [];
2013
+ if (!data.length) return this._empty();
1629
2014
  const e = this.engine;
1630
2015
  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
- }
2016
+ const [xMin, xMax, yMin, yMax] = xyExtent(data);
1636
2017
  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 },
2018
+ const [yLo, yHi] = valueDomain(o, yMin, yMax);
2019
+ const ax = drawCartesian(this, {
2020
+ yMin: yLo, yMax: yHi, xLinear: { min: xLo, max: xHi },
1641
2021
  });
1642
2022
  const prog = this._anim.progress;
1643
2023
  const groups = new Map();
1644
2024
  const defaultR = t.pointRadius + 1;
2025
+ // Hover boxes only while they stay useful; huge clouds skip them.
2026
+ const hitsOn = data.length <= 20000;
1645
2027
 
1646
2028
  for (let i = 0; i < data.length; i++) {
1647
2029
  const d = data[i];
1648
2030
  const cx = ax.xToPx(d.x);
1649
2031
  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 });
2032
+ const r = (d.size || defaultR) * prog;
2033
+ const color = d.color || this._colorOf(d.group != null ? d.group : 0);
1653
2034
  const key = color + ':' + r.toFixed(1);
1654
2035
  if (!groups.has(key)) groups.set(key, { color, r, items: [] });
1655
2036
  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
- });
2037
+ if (hitsOn) {
2038
+ this.hits.add({
2039
+ _id: i, x: cx - r - 2, y: cy - r - 2, w: r * 2 + 4, h: r * 2 + 4,
2040
+ tipX: cx, tipY: cy - r, index: i, label: d.label, value: d.y, row: d,
2041
+ tooltip: `${d.label ? d.label + ' ' : ''}(${this._xFmt(d.x)}, ${this._fmt(d.y)})`,
2042
+ });
2043
+ }
1661
2044
  }
1662
2045
  e.withAlpha(0.7, () => {
1663
2046
  for (const { color, r, items } of groups.values()) e.circles(items, color, r);
1664
2047
  });
2048
+ if (this._hover) {
2049
+ const h = this._hover;
2050
+ e.circle(h.tipX, h.y + h.h / 2, h.w / 2, { stroke: t.text, lineWidth: 1.5 });
2051
+ }
1665
2052
  }
1666
2053
  }
1667
2054
 
1668
- // ─────────────────────────────────────────────────────────
1669
- // 9. BUBBLE (scatter with explicit z radius)
1670
- // ─────────────────────────────────────────────────────────
2055
+ // ── src/charts/bubble.js
2056
+ // BubbleChart — scatter where `size` (a data value) sets the bubble area,
2057
+ // scaled into the `radius: [min, max]` range in px.
2058
+
2059
+
2060
+
2061
+
1671
2062
  class BubbleChart extends Chart {
2063
+ static get meta() {
2064
+ return { id: 'bubble', name: 'BubbleChart', aria: 'Bubble chart', data: 'points', options: ['labelFormat', 'min', 'max', 'radius'] };
2065
+ }
2066
+
1672
2067
  _render() {
1673
- const data = this.options.data || [];
1674
- if (!data.length) { this.setEmpty('No data'); return; }
2068
+ const o = this.options;
2069
+ const data = o.data || [];
2070
+ if (!data.length) return this._empty();
1675
2071
  const e = this.engine;
1676
2072
  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 },
2073
+ const [xMin, xMax, yMin, yMax] = xyExtent(data);
2074
+ let zMax = 0;
2075
+ for (const d of data) if ((d.size || 0) > zMax) zMax = d.size;
2076
+ // Pad the domain so the biggest bubbles stay inside the plot.
2077
+ const px = (xMax - xMin || 1) * 0.08, py = (yMax - yMin || 1) * 0.1;
2078
+ const [xLo, xHi] = M.niceDomain(xMin - px, xMax + px, 6);
2079
+ const [yLo, yHi] = valueDomain(o, yMin - py, yMax + py);
2080
+ const ax = drawCartesian(this, {
2081
+ yMin: yLo, yMax: yHi, xLinear: { min: xLo, max: xHi },
1688
2082
  });
1689
2083
  const prog = this._anim.progress;
1690
- const maxR = 32, minR = 4;
2084
+ const [minR, maxR] = Array.isArray(o.radius) ? o.radius : [4, 32];
1691
2085
 
1692
2086
  for (let i = 0; i < data.length; i++) {
1693
2087
  const d = data[i];
1694
2088
  const cx = ax.xToPx(d.x);
1695
2089
  const cy = ax.yToPx(d.y);
1696
- const r = (minR + (Math.sqrt((d.z || 0) / Math.max(1, zMax))) * (maxR - minR)) * prog;
2090
+ const r = (minR + Math.sqrt((d.size || 0) / Math.max(1e-9, zMax)) * (maxR - minR)) * prog;
1697
2091
  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
- });
2092
+ const hover = this._hover && this._hover._id === i;
2093
+ e.circle(cx, cy, r, { fill: M.colorAlpha(color, hover ? 0.75 : 0.5), stroke: color, lineWidth: 1.5 });
1702
2094
  this.hits.add({
1703
2095
  _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}`,
2096
+ test: (x, y) => (x - cx) ** 2 + (y - cy) ** 2 <= r * r,
2097
+ tipX: cx, tipY: cy - r, index: i, label: d.label, value: d.y, row: d,
2098
+ tooltip: `${d.label ? d.label + ' ' : ''}(${this._xFmt(d.x)}, ${this._fmt(d.y)}) · ${this._fmt(d.size)}`,
1706
2099
  });
1707
2100
  }
1708
2101
  }
1709
2102
  }
1710
2103
 
1711
- // ─────────────────────────────────────────────────────────
1712
- // 10. PIE / DONUT
1713
- // ─────────────────────────────────────────────────────────
2104
+ // ── src/charts/pie.js
2105
+ // PieChart — `innerRadius` (0–0.9 of the radius) turns it into a donut with
2106
+ // a center label (`center`: text, or false for none).
2107
+ // Slices are hit-tested by angle and radius, and toggle from the legend.
2108
+
2109
+
2110
+ // Point-in-sector test, angles measured like canvas arc().
2111
+ function inSector(cx, cy, rIn, rOut, a0, a1) {
2112
+ return (x, y) => {
2113
+ const dx = x - cx, dy = y - cy;
2114
+ const d = dx * dx + dy * dy;
2115
+ if (d < rIn * rIn || d > rOut * rOut) return false;
2116
+ let a = Math.atan2(dy, dx);
2117
+ while (a < a0) a += TAU;
2118
+ return a <= a1;
2119
+ };
2120
+ }
2121
+
1714
2122
  class PieChart extends Chart {
2123
+ static get meta() {
2124
+ return { id: 'pie', name: 'PieChart', aria: 'Pie chart', data: 'rows', options: ['label', 'value', 'innerRadius', 'center'] };
2125
+ }
2126
+
2127
+ _legend() {
2128
+ const o = this.options;
2129
+ const labelOf = this._fn('label') || ((d) => d.label);
2130
+ return toRows(o.data).map((d, i) => ({ label: String(labelOf(d, i)), color: this._colorOf(i, d) }));
2131
+ }
1715
2132
  _render() {
1716
- const data = this.options.data || [];
1717
- if (!data.length) { this.setEmpty('No data'); return; }
2133
+ const o = this.options;
2134
+ const data = toRows(o.data);
2135
+ if (!data.length) return this._empty();
1718
2136
  const e = this.engine;
1719
2137
  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;
2138
+ const labelOf = this._fn('label') || ((d) => d.label);
2139
+ const valueOf = this._fn('value') || ((d) => d.value);
2140
+ const raw = data.map((d, i) => +valueOf(d, i) || 0);
2141
+ const neg = raw.findIndex((v) => v < 0);
2142
+ if (neg >= 0) warn(`PieChart: values can't be negative — "${labelOf(data[neg], neg)}" is ${raw[neg]}. It is drawn as 0.`);
2143
+ const vals = raw.map((v, i) => (this._isOff(i) ? 0 : Math.max(0, v)));
2144
+ const total = vals.reduce((a, v) => a + v, 0);
2145
+ if (total <= 0) return this._empty();
2146
+
2147
+ const cx = e.w / 2, cy = e.h / 2;
2148
+ const R = Math.max(4, Math.min(e.w, e.h) / 2 - 12);
2149
+ const ir = +o.innerRadius || 0;
2150
+ if (ir > 0.9) warn(`PieChart: innerRadius is a ratio from 0 to 0.9 (0.6 = donut) — got ${ir}.`);
2151
+ const innerR = R * Math.min(0.9, Math.max(0, ir));
1732
2152
  const prog = this._anim.progress;
1733
2153
  let a0 = -Math.PI / 2;
1734
2154
  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;
2155
+ const v = vals[i];
2156
+ if (!v) continue;
2157
+ const a1 = a0 + (v / total) * TAU * prog;
1738
2158
  const color = this._colorOf(i, data[i]);
1739
- const isHover = this._hover && this._hover._id === i;
1740
- const offset = isHover ? 6 : 0;
2159
+ const hover = this._hover && this._hover._id === i;
1741
2160
  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
2161
+ const off = hover ? 6 : 0;
2162
+ // A background-colored outline separates neighbouring slices.
2163
+ e.arc(cx + Math.cos(mid) * off, cy + Math.sin(mid) * off, innerR, R, a0, a1, {
2164
+ fill: color, stroke: data.length > 1 ? t.bg : null, lineWidth: 1.5,
2165
+ });
1745
2166
  const midR = (innerR + R) / 2;
1746
- const hx = cx + Math.cos(mid) * midR;
1747
- const hy = cy + Math.sin(mid) * midR;
1748
2167
  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)}%)`,
2168
+ _id: i, x: cx - R, y: cy - R, w: R * 2, h: R * 2,
2169
+ test: inSector(cx, cy, innerR, R + off, a0, a1),
2170
+ tipX: cx + Math.cos(mid) * midR, tipY: cy + Math.sin(mid) * midR - 8,
2171
+ index: i, label: labelOf(data[i], i), value: v, row: data[i],
2172
+ tooltip: `${this._lab(labelOf(data[i], i))}: ${this._fmt(v)} (${((v / total) * 100).toFixed(1)}%)`,
1752
2173
  });
1753
2174
  a0 = a1;
1754
2175
  }
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());
2176
+ if (innerR > 0 && o.center !== false) {
2177
+ const h = this._hover;
2178
+ const center = h ? `${((vals[h._id] / total) * 100).toFixed(0)}%` : o.center ?? this._fmt(total);
1760
2179
  e.text(center, cx, cy, {
1761
- fill: t.text, font: `600 ${t.fontSize * 1.6}px ${t.font}`,
2180
+ fill: t.text, font: `600 ${Math.max(12, Math.min(t.fontSize * 1.8, innerR / 2.2))}px ${t.font}`,
1762
2181
  align: 'center', baseline: 'middle',
1763
2182
  });
1764
2183
  }
1765
2184
  }
1766
2185
  }
1767
2186
 
1768
- // ─────────────────────────────────────────────────────────
1769
- // 11. RADAR
1770
- // ─────────────────────────────────────────────────────────
2187
+ // ── src/charts/radar.js
2188
+ // RadarChart — several series over shared axes, with point tooltips.
2189
+
2190
+
2191
+
2192
+
1771
2193
  class RadarChart extends Chart {
2194
+ static get meta() {
2195
+ return { id: 'radar', name: 'RadarChart', aria: 'Radar chart', data: 'series', options: ['labelFormat', 'max'] };
2196
+ }
2197
+
2198
+ _legend() { return this._seriesLegend(this.options.data?.series || []); }
1772
2199
  _render() {
1773
- const series = this.options.series || [];
1774
- const axes = this.options.axes || [];
1775
- if (!series.length || !axes.length) { this.setEmpty('No data'); return; }
2200
+ const o = this.options;
2201
+ const { categories: axes = [], series = [] } = o.data || {};
2202
+ if (!series.length || !axes.length) return this._empty();
1776
2203
  const e = this.engine;
1777
2204
  const t = this.theme;
2205
+ const font = `${t.fontSize}px ${t.font}`;
2206
+ let labelW = 0;
2207
+ const names = axes.map((a) => this._lab(a));
2208
+ for (const a of names) labelW = Math.max(labelW, e.measure(a, font));
1778
2209
  const cx = e.w / 2;
1779
- const cy = e.h / 2 + 4;
1780
- const R = Math.min(e.w, e.h) / 2 - 32;
2210
+ const cy = e.h / 2;
2211
+ const R = Math.max(10, Math.min(e.w / 2 - labelW - 18, e.h / 2 - t.fontSize - 16));
1781
2212
  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) {
2213
+ const angle = (i) => -Math.PI / 2 + (i / N) * TAU;
2214
+ const on = series.map((s, i) => i).filter((i) => !this._isOff(i));
2215
+ const max = range(on.map((s) => series[s].values), 0)[1];
2216
+ const niceMax = o.max ?? (M.niceDomain(0, max, 5)[1] || 1);
2217
+
2218
+ const ctx = e.ctx;
2219
+ ctx.strokeStyle = t.grid;
2220
+ ctx.lineWidth = 1;
2221
+ for (const v of M.niceTicks(0, niceMax, 4)) {
2222
+ if (v <= 0) continue;
1794
2223
  const r = (v / niceMax) * R;
1795
- e.ctx.beginPath();
2224
+ ctx.beginPath();
1796
2225
  for (let i = 0; i <= N; i++) {
1797
2226
  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);
2227
+ if (i === 0) ctx.moveTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
2228
+ else ctx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
1801
2229
  }
1802
- e.ctx.stroke();
2230
+ ctx.stroke();
1803
2231
  }
1804
- // Spokes
1805
- e.ctx.strokeStyle = t.grid;
2232
+ ctx.beginPath();
1806
2233
  for (let i = 0; i < N; i++) {
1807
2234
  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();
2235
+ ctx.moveTo(cx, cy);
2236
+ ctx.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
1812
2237
  }
1813
- // Axis labels
1814
- const font = `${t.fontSize}px ${t.font}`;
2238
+ ctx.stroke();
1815
2239
  for (let i = 0; i < N; i++) {
1816
2240
  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, {
2241
+ e.text(names[i], cx + Math.cos(a) * (R + 10), cy + Math.sin(a) * (R + 10), {
1820
2242
  fill: t.muted, font,
1821
2243
  align: Math.cos(a) > 0.3 ? 'left' : Math.cos(a) < -0.3 ? 'right' : 'center',
1822
2244
  baseline: Math.sin(a) > 0.3 ? 'top' : Math.sin(a) < -0.3 ? 'bottom' : 'middle',
1823
2245
  });
1824
2246
  }
1825
2247
  const prog = this._anim.progress;
1826
- for (let s = 0; s < series.length; s++) {
2248
+ for (const s of on) {
1827
2249
  const ser = series[s];
1828
2250
  const color = this._colorOf(s, ser);
1829
2251
  const pts = [];
1830
2252
  for (let i = 0; i < N; i++) {
1831
- const v = (+ser.values[i] || 0) * prog;
1832
- const r = (v / niceMax) * R;
2253
+ const v = +ser.values[i] || 0;
2254
+ const r = Math.min(1, (v * prog) / niceMax) * R;
1833
2255
  const a = angle(i);
1834
- pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
2256
+ const p = { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r };
2257
+ pts.push(p);
2258
+ this.hits.add({
2259
+ _id: `${s}-${i}`, x: p.x - 7, y: p.y - 7, w: 14, h: 14,
2260
+ tipX: p.x, tipY: p.y - 4, index: i, label: axes[i], series: s, value: v, row: ser,
2261
+ tooltip: { title: names[i], rows: [{ color, name: ser.name || 'Series ' + (s + 1), value: this._fmt(v) }] },
2262
+ });
1835
2263
  }
1836
- e.poly(pts, {
1837
- fill: M.colorAlpha(color, 0.25),
1838
- stroke: color, lineWidth: 2, close: true,
1839
- });
2264
+ e.poly(pts, { fill: M.colorAlpha(color, 0.2), stroke: color, lineWidth: 2, close: true, join: 'round' });
1840
2265
  e.circles(pts, color, t.pointRadius);
1841
2266
  }
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);
2267
+ if (this._hover) {
2268
+ const h = this._hover;
2269
+ e.circle(h.x + 7, h.y + 7, t.pointRadius + 3, { stroke: t.text, lineWidth: 1.5 });
2270
+ }
1846
2271
  }
1847
2272
  }
1848
2273
 
1849
- // ─────────────────────────────────────────────────────────
1850
- // 12. POLAR (radial bars)
1851
- // ─────────────────────────────────────────────────────────
2274
+ // ── src/charts/polar.js
2275
+ // PolarChart — radial bars (area-correct: radius grows with the square root).
2276
+
2277
+
2278
+
1852
2279
  class PolarChart extends Chart {
2280
+ static get meta() {
2281
+ return { id: 'polar', name: 'PolarChart', aria: 'Polar chart', data: 'rows', options: ['label', 'value', 'scale'], enums: { scale: ['sqrt', 'linear'] } };
2282
+ }
2283
+
1853
2284
  _render() {
1854
- const data = this.options.data || [];
1855
- if (!data.length) { this.setEmpty('No data'); return; }
2285
+ const o = this.options;
2286
+ const data = toRows(o.data);
2287
+ if (!data.length) return this._empty();
1856
2288
  const e = this.engine;
1857
2289
  const t = this.theme;
1858
- const labelOf = this.options.label || (d => d.label);
1859
- const valueOf = this.options.value || (d => d.value);
2290
+ const labelOf = this._fn('label') || ((d) => d.label);
2291
+ const valueOf = this._fn('value') || ((d) => d.value);
1860
2292
  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)));
2293
+ const font = `${t.fontSize}px ${t.font}`;
2294
+ const cx = e.w / 2, cy = e.h / 2;
2295
+ const R = Math.max(10, Math.min(e.w, e.h) / 2 - t.fontSize - 14);
2296
+ let max = 0;
2297
+ for (let i = 0; i < N; i++) max = Math.max(max, +valueOf(data[i], i) || 0);
1864
2298
  const prog = this._anim.progress;
2299
+ const sqrt = o.scale !== 'linear';
1865
2300
 
2301
+ e.circle(cx, cy, R, { stroke: t.grid, lineWidth: 1 });
1866
2302
  for (let i = 0; i < N; i++) {
1867
- const v = valueOf(data[i], i);
2303
+ const v = Math.max(0, +valueOf(data[i], i) || 0);
1868
2304
  const a0 = -Math.PI / 2 + (i / N) * TAU;
1869
2305
  const a1 = a0 + (TAU / N) * 0.92;
1870
- const r = (v / max) * R * prog;
2306
+ const ratio = max ? v / max : 0;
2307
+ const r = (sqrt ? Math.sqrt(ratio) : ratio) * R * prog;
1871
2308
  const color = this._colorOf(i, data[i]);
1872
- e.arc(cx, cy, 0, r, a0, a1, { fill: color });
1873
- // Label
2309
+ const hover = this._hover && this._hover._id === i;
2310
+ e.arc(cx, cy, 0, r + (hover ? 4 : 0), a0, a1, { fill: color });
1874
2311
  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',
2312
+ const c = Math.cos(mid), s = Math.sin(mid);
2313
+ e.text(String(labelOf(data[i], i)), cx + c * (R + 8), cy + s * (R + 8), {
2314
+ fill: t.muted, font,
2315
+ align: c > 0.3 ? 'left' : c < -0.3 ? 'right' : 'center',
2316
+ baseline: s > 0.3 ? 'top' : s < -0.3 ? 'bottom' : 'middle',
2317
+ });
2318
+ this.hits.add({
2319
+ _id: i, x: cx - R, y: cy - R, w: R * 2, h: R * 2,
2320
+ test: inSector(cx, cy, 0, Math.max(r, 12), a0, a1),
2321
+ tipX: cx + c * r * 0.7, tipY: cy + s * r * 0.7 - 6,
2322
+ index: i, label: labelOf(data[i], i), value: v, row: data[i],
2323
+ tooltip: `${labelOf(data[i], i)}: ${this._fmt(v)}`,
1880
2324
  });
1881
2325
  }
1882
2326
  }
1883
2327
  }
1884
2328
 
1885
- // ─────────────────────────────────────────────────────────
1886
- // 13. GAUGE
1887
- // ─────────────────────────────────────────────────────────
2329
+ // ── src/charts/gauge.js
2330
+ // GaugeChart — animated arc; update({ data }) tweens from the shown value.
2331
+
1888
2332
  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;
2333
+ static get meta() {
2334
+ return {
2335
+ id: 'gauge', name: 'GaugeChart', aria: 'Gauge', data: 'number', options: ['min', 'max', 'unit'],
2336
+ renamed: { value: 'data', label: 'title' },
2337
+ };
2338
+ }
2339
+ update(patch = {}) {
2340
+ if ('data' in patch) this._from = this._shown;
2341
+ return super.update(patch);
1896
2342
  }
1897
2343
  _render() {
1898
2344
  const e = this.engine;
1899
2345
  const t = this.theme;
1900
- const val = this.options.value || 0;
1901
- const max = this.options.max || 100;
2346
+ const o = this.options;
2347
+ const min = o.min ?? 0;
2348
+ const max = o.max ?? 100;
2349
+ const target = Math.max(min, Math.min(max, +o.data || 0));
2350
+ const from = this._from ?? min;
2351
+ const val = from + (target - from) * this._anim.progress;
2352
+ this._shown = val;
1902
2353
  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;
2354
+ const cy = e.h * 0.68;
2355
+ const R = Math.max(10, Math.min(e.w / 2 - 8, e.h * 0.62));
2356
+ const r = R * 0.8;
1906
2357
  const a0 = Math.PI * 1.1;
1907
2358
  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
2359
+ const color = this.palette[0];
1912
2360
  e.arc(cx, cy, r, R, a0, a1, { fill: t.grid });
1913
- // Filled
1914
- const aEnd = a0 + span * (val / max) * prog;
2361
+ const aEnd = a0 + ((a1 - a0) * (val - min)) / (max - min || 1);
1915
2362
  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',
2363
+ const txt = (o.format ? this._fmt(val) : String(Math.round(val))) + (o.unit || '');
2364
+ e.text(txt, cx, cy - R * 0.08, {
2365
+ fill: t.text, font: `700 ${Math.max(12, R / 2.6)}px ${t.font}`, align: 'center', baseline: 'middle',
1922
2366
  });
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
- });
2367
+ if (o.title) {
2368
+ e.text(o.title, cx, cy + R * 0.22, { fill: t.muted, font: `${t.fontSize}px ${t.font}`, align: 'center', baseline: 'top' });
1928
2369
  }
2370
+ this.canvas.setAttribute('aria-label', `${o.title || 'Gauge'}: ${txt}`);
1929
2371
  }
1930
2372
  }
1931
2373
 
1932
- // ─────────────────────────────────────────────────────────
1933
- // 14. HEATMAP
1934
- // ─────────────────────────────────────────────────────────
2374
+ // ── src/charts/heatmap.js
2375
+ // HeatmapChart — data { x: [...], y: [...], values: [[...]] } (values[y][x]),
2376
+ // cells colored between colors.low and colors.high.
2377
+
2378
+
1935
2379
  class HeatmapChart extends Chart {
1936
- _axisPad() { return { l: 60, r: 16, t: 16, b: 40 }; }
2380
+ static get meta() {
2381
+ return { id: 'heatmap', name: 'HeatmapChart', aria: 'Heatmap', data: 'matrix', options: ['labelFormat'], colors: 'an object like { low: \'#eef\', high: \'#36c\' }' };
2382
+ }
2383
+
1937
2384
  _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
- }
2385
+ const o = this.options;
2386
+ const { x: xLabels = [], y: yLabels = [], values = [] } = o.data || {};
2387
+ if (!values.length || !xLabels.length || !yLabels.length) return this._empty();
1944
2388
  const e = this.engine;
1945
2389
  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
2390
  const font = `${t.fontSize}px ${t.font}`;
2391
+ let labelW = 0;
2392
+ const yText = Array.from(yLabels, (l) => this._lab(l));
2393
+ for (const s of yText) labelW = Math.max(labelW, e.measure(s, font));
2394
+ const px = Math.ceil(labelW) + 12, py = 8;
2395
+ const pw = Math.max(1, e.w - px - 8);
2396
+ const ph = Math.max(1, e.h - py - t.fontSize - 14);
1951
2397
 
1952
2398
  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;
2399
+ for (const row of values) {
2400
+ for (const v of row || []) {
2401
+ if (v > vMax) vMax = v;
2402
+ if (v < vMin) vMin = v;
2403
+ }
1956
2404
  }
1957
2405
  const cellW = pw / xLabels.length;
1958
2406
  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;
2407
+ const c = o.colors || {};
2408
+ const hi = c.high || this.palette[0];
2409
+ const lo = c.low || M.colorAlpha(hi, 0.08);
2410
+ const gap = cellW > 6 && cellH > 6 ? 1 : 0;
1962
2411
  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
- });
2412
+ for (let cy = 0; cy < yLabels.length; cy++) {
2413
+ const row = values[cy] || [];
2414
+ for (let cx = 0; cx < xLabels.length; cx++) {
2415
+ const v = row[cx];
2416
+ if (v == null || v !== v) continue;
2417
+ const x = px + cx * cellW;
2418
+ const y = py + cy * cellH;
2419
+ const n = vMax > vMin ? (v - vMin) / (vMax - vMin) : 0.5;
2420
+ e.rect(x + gap, y + gap, cellW - gap * 2, cellH - gap * 2, { fill: M.colorMix(lo, hi, n * prog), radius: gap ? 2 : 0 });
2421
+ this.hits.add({
2422
+ _id: cy * xLabels.length + cx, x, y, w: cellW, h: cellH, tipX: x + cellW / 2, tipY: y,
2423
+ index: cx, series: cy, label: xLabels[cx], value: v, row: { x: xLabels[cx], y: yLabels[cy], value: v },
2424
+ tooltip: `${this._lab(xLabels[cx])} · ${yText[cy]}: ${this._fmt(v)}`,
2425
+ });
2426
+ }
1978
2427
  }
1979
- // X labels
2428
+ let lastEnd = -Infinity;
1980
2429
  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
- });
2430
+ const txt = this._lab(xLabels[i]);
2431
+ const w = e.measure(txt, font);
2432
+ const x = px + (i + 0.5) * cellW;
2433
+ if (x - w / 2 < lastEnd + 4) continue;
2434
+ lastEnd = x + w / 2;
2435
+ e.text(txt, x, py + ph + 6, { fill: t.muted, font, align: 'center', baseline: 'top' });
1984
2436
  }
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
- });
2437
+ const step = Math.ceil((t.fontSize + 2) / cellH);
2438
+ for (let i = 0; i < yLabels.length; i += step) {
2439
+ e.text(yText[i], px - 6, py + (i + 0.5) * cellH, { fill: t.muted, font, align: 'right', baseline: 'middle' });
2440
+ }
2441
+ if (this._hover) {
2442
+ const r = this._hover;
2443
+ e.rect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1, { stroke: t.text, lineWidth: 1.5 });
1990
2444
  }
1991
2445
  }
1992
2446
  }
1993
2447
 
1994
- // ─────────────────────────────────────────────────────────
1995
- // 15. BOXPLOT
1996
- // ─────────────────────────────────────────────────────────
2448
+ // ── src/charts/boxplot.js
2449
+ // BoxplotChart — rows of { label, min, q1, median, q3, max, outliers? }.
2450
+
2451
+
2452
+
1997
2453
  class BoxplotChart extends Chart {
2454
+ static get meta() {
2455
+ return { id: 'boxplot', name: 'BoxplotChart', aria: 'Box plot', data: 'boxes', options: ['labelFormat', 'min', 'max'] };
2456
+ }
2457
+
1998
2458
  _render() {
1999
- const data = this.options.data || []; // [{ label, q1, median, q3, min, max, outliers? }]
2000
- if (!data.length) { this.setEmpty('No data'); return; }
2459
+ const o = this.options;
2460
+ const data = o.data || [];
2461
+ if (!data.length) return this._empty();
2001
2462
  const e = this.engine;
2002
- const t = this.theme;
2003
2463
  let yMax = -Infinity, yMin = Infinity;
2004
2464
  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;
2465
+ if (d.max > yMax) yMax = d.max;
2466
+ if (d.min < yMin) yMin = d.min;
2467
+ if (d.outliers) for (const v of d.outliers) {
2468
+ if (v > yMax) yMax = v;
2469
+ if (v < yMin) yMin = v;
2008
2470
  }
2009
2471
  }
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
- });
2472
+ const [yLo, yHi] = valueDomain(o, yMin, yMax);
2473
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: data.map((d) => d.label) });
2015
2474
  const slot = ax.pw / data.length;
2016
2475
  const boxW = Math.min(40, slot * 0.5);
2476
+ const prog = this._anim.progress;
2017
2477
 
2018
2478
  for (let i = 0; i < data.length; i++) {
2019
2479
  const d = data[i];
2020
2480
  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);
2481
+ const med = ax.yToPx(d.median);
2482
+ const y = (v) => med + (ax.yToPx(v) - med) * prog; // grow out of the median
2026
2483
  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,
2484
+ const hover = this._hover && this._hover._id === i;
2485
+ e.poly([{ x: cx, y: y(d.max) }, { x: cx, y: y(d.min) }], { stroke: color, lineWidth: 1 });
2486
+ e.poly([{ x: cx - boxW / 3, y: y(d.max) }, { x: cx + boxW / 3, y: y(d.max) }], { stroke: color, lineWidth: 1 });
2487
+ e.poly([{ x: cx - boxW / 3, y: y(d.min) }, { x: cx + boxW / 3, y: y(d.min) }], { stroke: color, lineWidth: 1 });
2488
+ e.rect(cx - boxW / 2, y(d.q3), boxW, y(d.q1) - y(d.q3), {
2489
+ fill: M.colorAlpha(color, hover ? 0.6 : 0.4), stroke: color, lineWidth: 1.5,
2039
2490
  });
2040
- // Outliers
2041
- if (d.outliers) {
2042
- e.circles(d.outliers.map(v => ({ x: cx, y: ax.yToPx(v) })), color, 2);
2043
- }
2491
+ e.poly([{ x: cx - boxW / 2, y: med }, { x: cx + boxW / 2, y: med }], { stroke: color, lineWidth: 2 });
2492
+ if (d.outliers) e.circles(d.outliers.map((v) => ({ x: cx, y: y(v) })), color, 2);
2493
+ const top = ax.yToPx(d.max), bot = ax.yToPx(d.min);
2044
2494
  this.hits.add({
2045
- _id: i, x: cx - boxW / 2, y: yQ3, w: boxW, h: yQ1 - yQ3,
2046
- tipX: cx, tipY: yQ3,
2495
+ _id: i, x: cx - boxW / 2, y: top, w: boxW, h: Math.max(4, bot - top),
2496
+ tipX: cx, tipY: ax.yToPx(d.q3), index: i, label: d.label, value: d.median, row: d,
2047
2497
  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
- ],
2498
+ title: this._lab(d.label),
2499
+ rows: [['max', d.max], ['Q3', d.q3], ['median', d.median], ['Q1', d.q1], ['min', d.min]]
2500
+ .map(([name, v]) => ({ name, value: this._fmt(v) })),
2056
2501
  },
2057
2502
  });
2058
2503
  }
2059
2504
  }
2060
2505
  }
2061
2506
 
2062
- // ─────────────────────────────────────────────────────────
2063
- // 16. FUNNEL
2064
- // ─────────────────────────────────────────────────────────
2507
+ // ── src/charts/funnel.js
2508
+ // FunnelChart — conversion stages; each segment narrows to the next value.
2509
+
2065
2510
  class FunnelChart extends Chart {
2511
+ static get meta() {
2512
+ return { id: 'funnel', name: 'FunnelChart', aria: 'Funnel chart', data: 'rows', options: ['label', 'value', 'labelColor'] };
2513
+ }
2514
+
2066
2515
  _render() {
2067
- const data = this.options.data || [];
2068
- if (!data.length) { this.setEmpty('No data'); return; }
2516
+ const o = this.options;
2517
+ const data = toRows(o.data);
2518
+ if (!data.length) return this._empty();
2069
2519
  const e = this.engine;
2070
2520
  const t = this.theme;
2071
- const labelOf = this.options.label || (d => d.label);
2072
- const valueOf = this.options.value || (d => d.value);
2521
+ const labelOf = this._fn('label') || ((d) => d.label);
2522
+ const valueOf = this._fn('value') || ((d) => d.value);
2073
2523
  const N = data.length;
2074
- const max = Math.max(...data.map((d, i) => valueOf(d, i)));
2075
- const pad = 24;
2524
+ const vals = data.map((d, i) => Math.max(0, +valueOf(d, i) || 0));
2525
+ const max = Math.max(1e-9, vals.reduce((a, v) => (v > a ? v : a), 0));
2526
+ const first = vals[0] || max;
2527
+ const pad = 12;
2076
2528
  const w = e.w - pad * 2;
2077
2529
  const segH = (e.h - pad * 2) / N;
2078
2530
  const cx = e.w / 2;
2079
2531
  const prog = this._anim.progress;
2080
- const font = `${t.fontSize}px ${t.font}`;
2532
+ const font = `600 ${t.fontSize}px ${t.font}`;
2081
2533
 
2082
2534
  for (let i = 0; i < N; i++) {
2083
- const v = valueOf(data[i], i);
2535
+ const v = vals[i];
2084
2536
  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;
2537
+ const wBot = i + 1 < N ? (vals[i + 1] / max) * w * prog : wTop * 0.85;
2088
2538
  const yTop = pad + i * segH;
2089
2539
  const yBot = yTop + segH - 2;
2090
2540
  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',
2541
+ const hover = this._hover && this._hover._id === i;
2542
+ e.withAlpha(hover ? 1 : 0.9, () => e.poly([
2543
+ { x: cx - wTop / 2, y: yTop }, { x: cx + wTop / 2, y: yTop },
2544
+ { x: cx + wBot / 2, y: yBot }, { x: cx - wBot / 2, y: yBot },
2545
+ ], { fill: color, close: true }));
2546
+ // Label sits inside when it fits, otherwise to the right.
2547
+ const txt = `${labelOf(data[i], i)} ${this._fmt(v)}`;
2548
+ const tw = e.measure(txt, font);
2549
+ const inside = tw + 16 < Math.min(wTop, wBot);
2550
+ e.text(txt, inside ? cx : cx + Math.max(wTop, wBot) / 2 + 8, yTop + segH / 2, {
2551
+ fill: inside ? o.labelColor || '#fff' : t.text, font, align: inside ? 'center' : 'left', baseline: 'middle',
2102
2552
  });
2103
2553
  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)}%)`,
2554
+ _id: i, x: cx - Math.max(wTop, 8) / 2, y: yTop, w: Math.max(wTop, 8), h: segH,
2555
+ tipX: cx, tipY: yTop, index: i, label: labelOf(data[i], i), value: v, row: data[i],
2556
+ tooltip: `${labelOf(data[i], i)}: ${this._fmt(v)} (${((v / first) * 100).toFixed(1)}%)`,
2107
2557
  });
2108
2558
  }
2109
2559
  }
2110
2560
  }
2111
2561
 
2112
- // ─────────────────────────────────────────────────────────
2113
- // 17. TREEMAP
2114
- // ─────────────────────────────────────────────────────────
2562
+ // ── src/charts/treemap.js
2563
+ // TreemapChart — squarified layout (Bruls et al.), labels when they fit.
2564
+
2565
+ // Lays out `items` (sorted desc by value) inside rect; calls place(item, rect).
2566
+ function squarify(items, rect, place) {
2567
+ let i = 0;
2568
+ let { x, y, w, h } = rect;
2569
+ let rest = items.reduce((a, it) => a + it.value, 0);
2570
+ while (i < items.length && rest > 0 && w >= 1 && h >= 1) {
2571
+ const side = Math.min(w, h);
2572
+ const scale = (w * h) / rest; // px² per unit of value
2573
+ // Grow the row while the worst aspect ratio improves.
2574
+ let sum = 0, best = Infinity, j = i;
2575
+ for (; j < items.length; j++) {
2576
+ const s = sum + items[j].value;
2577
+ const len = (s * scale) / side;
2578
+ let worst = 0;
2579
+ for (let k = i; k <= j; k++) {
2580
+ const l = (items[k].value * scale) / len;
2581
+ worst = Math.max(worst, len / l, l / len);
2582
+ }
2583
+ if (worst > best) break;
2584
+ best = worst;
2585
+ sum = s;
2586
+ }
2587
+ if (j === i) { sum = items[i].value; j = i + 1; }
2588
+ const thick = (sum * scale) / side;
2589
+ let pos = 0;
2590
+ for (let k = i; k < j; k++) {
2591
+ const l = (items[k].value * scale) / thick;
2592
+ place(items[k], w >= h ? { x, y: y + pos, w: thick, h: l } : { x: x + pos, y, w: l, h: thick });
2593
+ pos += l;
2594
+ }
2595
+ if (w >= h) { x += thick; w -= thick; } else { y += thick; h -= thick; }
2596
+ rest -= sum;
2597
+ i = j;
2598
+ }
2599
+ }
2600
+
2115
2601
  class TreemapChart extends Chart {
2602
+ static get meta() {
2603
+ return { id: 'treemap', name: 'TreemapChart', aria: 'Treemap', data: 'rows', options: ['label', 'value', 'labelColor'] };
2604
+ }
2605
+
2116
2606
  _render() {
2117
- const data = this.options.data || [];
2118
- if (!data.length) { this.setEmpty('No data'); return; }
2607
+ const o = this.options;
2608
+ const data = toRows(o.data);
2609
+ if (!data.length) return this._empty();
2119
2610
  const e = this.engine;
2120
2611
  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 };
2612
+ const labelOf = this._fn('label') || ((d) => d.label);
2613
+ const valueOf = this._fn('value') || ((d) => d.value);
2614
+ const items = data
2615
+ .map((d, i) => ({ raw: labelOf(d, i), label: String(labelOf(d, i)), value: Math.max(0, +valueOf(d, i) || 0), color: this._colorOf(i, d), data: d, _id: i }))
2616
+ .filter((it) => it.value > 0)
2617
+ .sort((a, b) => b.value - a.value);
2618
+ if (!items.length) return this._empty();
2128
2619
  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;
2620
+ const fg = o.labelColor || '#fff';
2621
+ squarify(items, { x: 0, y: 0, w: e.w, h: e.h }, (it, r) => {
2622
+ const gap = 1;
2623
+ const w = (r.w - gap * 2) * prog;
2624
+ const h = (r.h - gap * 2) * prog;
2625
+ const x = r.x + gap + (r.w - gap * 2 - w) / 2;
2626
+ const y = r.y + gap + (r.h - gap * 2 - h) / 2;
2134
2627
  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
- });
2628
+ const hover = this._hover && this._hover._id === it._id;
2629
+ e.withAlpha(hover ? 0.85 : 1, () => e.rect(x, y, w, h, { fill: it.color, radius: 2 }));
2630
+ if (w > 48 && h > t.fontSize * 2 + 12) {
2631
+ e.text(it.label, x + 6, y + 6, { fill: fg, font: `600 ${t.fontSize}px ${t.font}`, align: 'left', baseline: 'top' });
2632
+ e.withAlpha(0.85, () => e.text(this._fmt(it.value), x + 6, y + 8 + t.fontSize, { fill: fg, font: `${t.fontSize}px ${t.font}`, align: 'left', baseline: 'top' }));
2146
2633
  }
2147
2634
  this.hits.add({
2148
- _id: it._id, x, y, w, h,
2149
- tipX: x + w / 2, tipY: y,
2150
- tooltip: `${it.label}: ${it.value}`,
2635
+ _id: it._id, x, y, w, h, tipX: x + w / 2, tipY: y, index: it._id, label: it.raw, value: it.value, row: it.data,
2636
+ tooltip: `${it.label}: ${this._fmt(it.value)}`,
2151
2637
  });
2152
2638
  });
2153
2639
  }
2154
2640
  }
2155
2641
 
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;
2642
+ // ── src/charts/sparkline.js
2643
+ // SparklineChart — tiny trend line, no axes. Same data as LineChart.
2644
+
2645
+
2646
+
2647
+ class SparklineChart extends Chart {
2648
+ static get meta() {
2649
+ return { id: 'sparkline', name: 'SparklineChart', aria: 'Sparkline', data: 'rows', options: ['label', 'value', 'min', 'max', 'fill', 'lastDot'] };
2216
2650
  }
2217
- }
2218
2651
 
2219
- // ─────────────────────────────────────────────────────────
2220
- // 18. SPARKLINE (mini line, no axes)
2221
- // ─────────────────────────────────────────────────────────
2222
- class Sparkline extends Chart {
2223
2652
  _render() {
2224
- const data = this.options.data || [];
2653
+ const o = this.options;
2654
+ const data = toRows(o.data);
2225
2655
  if (!data.length) return;
2226
2656
  const e = this.engine;
2227
2657
  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);
2658
+ const valueOf = this._fn('value') || ((d) => d.value);
2659
+ const labelOf = this._fn('label') || ((d) => d.label);
2660
+ const values = data.map((d, i) => +valueOf(d, i));
2661
+ const [lo, hi] = range([values]);
2662
+ const yMin = o.min ?? lo, yMax = o.max ?? hi;
2231
2663
  const yRange = yMax - yMin || 1;
2232
- const pad = 2;
2664
+ const pad = Math.max(2, t.pointRadius + 1);
2233
2665
  const w = e.w - pad * 2;
2234
2666
  const h = e.h - pad * 2;
2235
2667
  const points = values.map((v, i) => ({
2236
2668
  x: pad + (i / (values.length - 1 || 1)) * w,
2237
2669
  y: pad + h - ((v - yMin) / yRange) * h,
2238
2670
  }));
2239
- const color = this.options.color || this.palette[0];
2671
+ const color = this.palette[0];
2240
2672
  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
- ]);
2673
+ e.clip(0, 0, e.w * this._anim.progress, e.h);
2674
+ if (o.fill !== false) {
2675
+ const grad = e.vGradient(`spark-${color}`, 0, pad, pad + h, [[0, M.colorAlpha(color, 0.3)], [1, M.colorAlpha(color, 0)]]);
2246
2676
  e.smoothArea(points, cps, pad + h, grad);
2247
2677
  }
2248
2678
  e.smooth(points, cps, { stroke: color, lineWidth: t.lineWidth });
2249
- if (this.options.lastDot !== false) {
2679
+ e.unclip();
2680
+ if (o.lastDot !== false) {
2250
2681
  const last = points[points.length - 1];
2251
2682
  e.circle(last.x, last.y, t.pointRadius + 1, { fill: color });
2252
2683
  }
2684
+ const n = points.length;
2685
+ points.forEach((p, i) => {
2686
+ const l = i ? (points[i - 1].x + p.x) / 2 : 0;
2687
+ const r = i < n - 1 ? (points[i + 1].x + p.x) / 2 : e.w;
2688
+ const label = labelOf(data[i], i);
2689
+ this.hits.add({ _id: i, x: l, y: 0, w: r - l, h: e.h, tipX: p.x, tipY: p.y - 4, index: i, label, value: values[i], row: data[i], tooltip: `${label}: ${this._fmt(values[i])}` });
2690
+ });
2691
+ if (this._hover) {
2692
+ const p = points[this._hover._id];
2693
+ if (p) e.circle(p.x, p.y, t.pointRadius + 1, { fill: t.bg, stroke: color, lineWidth: 2 });
2694
+ }
2253
2695
  }
2254
2696
  }
2255
2697
 
2256
- // ─────────────────────────────────────────────────────────
2257
- // 19. REALTIME (streaming line, ring buffer)
2258
- // ─────────────────────────────────────────────────────────
2698
+ // ── src/charts/realtime.js
2699
+ // RealtimeChart — streaming lines over a rolling window.
2700
+ // data: { series: [{ name, color? }] } then chart.push([v1, v2, …]) per sample
2701
+
2702
+
2259
2703
  class RealtimeChart extends Chart {
2704
+ static get meta() {
2705
+ return { id: 'realtime', name: 'RealtimeChart', aria: 'Realtime chart', data: 'stream', options: ['min', 'max', 'window'] };
2706
+ }
2260
2707
  constructor(container, options = {}) {
2261
2708
  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;
2709
+ this._series = [];
2710
+ this._sync();
2711
+ this._follow = true;
2712
+ }
2713
+ // (Re)build the series from data, keeping samples already received.
2714
+ _sync() {
2715
+ const list = this.options.data?.series || [{ name: 'Series' }];
2716
+ this._series = list.map((s, i) => ({ name: s.name || 'Series ' + (i + 1), color: s.color, values: this._series[i]?.values || [] }));
2717
+ }
2718
+ update(patch = {}) {
2719
+ super.update(patch);
2720
+ if ('data' in patch) this._sync();
2721
+ return this;
2267
2722
  }
2723
+ _legend() { return this._series.length > 1 ? this._seriesLegend(this._series) : null; }
2268
2724
  push(values) {
2269
2725
  const arr = Array.isArray(values) ? values : [values];
2726
+ const N = this.options.window || 200;
2270
2727
  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();
2728
+ const v = this._series[i].values;
2729
+ v.push(arr[i] != null ? +arr[i] : NaN);
2730
+ if (v.length > N) v.splice(0, v.length - N);
2274
2731
  }
2275
- // Clear any auto-set empty state from a prior render.
2276
- if (this._state === 'empty') this._state = null;
2732
+ if (this._auto) { this._state = null; this._auto = false; }
2277
2733
  this._dirty = true;
2278
- scheduler.schedule(this);
2734
+ this._anim.progress = 1;
2735
+ schedule(this);
2736
+ return this;
2279
2737
  }
2280
2738
  clear() {
2281
2739
  for (const s of this._series) s.values.length = 0;
2282
2740
  this._dirty = true;
2283
- scheduler.schedule(this);
2741
+ schedule(this);
2742
+ return this;
2284
2743
  }
2285
2744
  _render() {
2286
2745
  const e = this.engine;
2287
2746
  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.
2747
+ const o = this.options;
2748
+ const on = this._series.map((s, i) => i).filter((i) => !this._isOff(i));
2749
+ const hasData = this._series.some((s) => s.values.length > 0);
2293
2750
  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',
2751
+ const ax = drawCartesian(this, { yMin: o.min ?? 0, yMax: o.max ?? 100 });
2752
+ e.text(o.emptyText || 'Waiting for data…', ax.px + ax.pw / 2, ax.py + ax.ph / 2, {
2753
+ fill: t.muted, font: `${t.fontSize}px ${t.font}`, align: 'center', baseline: 'middle',
2301
2754
  });
2302
2755
  return;
2303
2756
  }
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++) {
2757
+ const [lo, hi] = range(on.map((s) => this._series[s].values.filter((v) => v === v)), 0);
2758
+ const [yLo, yHi] = valueDomain(o, lo, hi);
2759
+ const N = o.window || 200;
2760
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi });
2761
+ const xAt = (i, len) => ax.px + ((N - len + i) / (N - 1)) * ax.pw; // newest sample on the right edge
2762
+ const len = this._series[0].values.length;
2763
+ this._steps = [];
2764
+ for (let i = 0; i < len; i++) this._steps.push(xAt(i, len));
2765
+
2766
+ e.clip(ax.px, ax.py - t.lineWidth, ax.pw + t.lineWidth, ax.ph + t.lineWidth * 2);
2767
+ const rows = [];
2768
+ let k = -1;
2769
+ if (this._cursor) {
2770
+ let best = Infinity;
2771
+ this._steps.forEach((x, i) => { const d = Math.abs(x - this._cursor.x); if (d < best) { best = d; k = i; } });
2772
+ }
2773
+ for (const s of on) {
2317
2774
  const ser = this._series[s];
2318
- if (ser.hidden) continue;
2319
2775
  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
2776
+ const pts = ser.values.map((v, i) => ({ x: xAt(i, ser.values.length), y: ax.yToPx(v) }));
2777
+ if (pts.length === 1) e.circle(pts[0].x, pts[0].y, t.pointRadius + 2, { fill: color });
2778
+ if (pts.length < 2) continue;
2779
+ e.poly(pts, { stroke: color, lineWidth: t.lineWidth, cap: 'round', join: 'round' });
2331
2780
  const last = pts[pts.length - 1];
2332
- e.circle(last.x, last.y, t.pointRadius + 2, { fill: color });
2781
+ e.circle(last.x, last.y, t.pointRadius + 1, { fill: color });
2782
+ if (k >= 0 && pts[k]) {
2783
+ e.circle(pts[k].x, pts[k].y, t.pointRadius + 2, { fill: t.bg, stroke: color, lineWidth: 2 });
2784
+ rows.push({ color, name: ser.name, value: this._fmt(ser.values[k]) });
2785
+ }
2786
+ }
2787
+ e.unclip();
2788
+ this._tipNow = null;
2789
+ if (k >= 0 && rows.length && this._cursor.x >= ax.px - 4) {
2790
+ const x = this._steps[k];
2791
+ e.poly([{ x, y: ax.py }, { x, y: ax.py + ax.ph }], { stroke: t.muted, lineWidth: 1, dash: [3, 3] });
2792
+ this._tipNow = { title: `#${k + 1 - len}`, rows };
2793
+ this._hover = { _id: k, index: k, label: k + 1 - len };
2794
+ this.tooltip.show(x, ax.py + 8, this._tipNow, t);
2333
2795
  }
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
2796
  }
2338
2797
  }
2339
2798
 
2340
- // ─────────────────────────────────────────────────────────
2341
- // 20. WATERFALL
2342
- // ─────────────────────────────────────────────────────────
2799
+ // ── src/charts/waterfall.js
2800
+ // WaterfallChart — running total. A row with total: true is drawn from zero to
2801
+ // its value (a subtotal or the end result). Colors: { up, down, total }.
2802
+
2803
+
2343
2804
  class WaterfallChart extends Chart {
2805
+ static get meta() {
2806
+ return { id: 'waterfall', name: 'WaterfallChart', aria: 'Waterfall chart', data: 'steps', options: ['labelFormat', 'min', 'max'], colors: 'an object like { up, down, total }' };
2807
+ }
2808
+
2344
2809
  _render() {
2345
- const data = this.options.data || []; // [{label, value, isTotal?}]
2346
- if (!data.length) { this.setEmpty('No data'); return; }
2810
+ const o = this.options;
2811
+ const data = Array.isArray(o.data) ? o.data : [];
2812
+ if (!data.length) return this._empty();
2347
2813
  const e = this.engine;
2348
2814
  const t = this.theme;
2349
2815
  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),
2816
+ const segs = data.map((d) => {
2817
+ const v = +d.value || 0;
2818
+ const seg = d.total ? { from: 0, to: v } : { from: cum, to: cum + v };
2819
+ cum = seg.to;
2820
+ return { ...seg, value: v, total: !!d.total, label: d.label };
2366
2821
  });
2822
+ const [lo, hi] = range([segs.map((s) => s.from), segs.map((s) => s.to)], 0, 0);
2823
+ const [yLo, yHi] = valueDomain(o, lo, hi);
2824
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: segs.map((s) => s.label) });
2367
2825
  const slot = ax.pw / segs.length;
2368
- const barW = Math.max(2, Math.min(60, slot - 12));
2826
+ const barW = Math.max(1, Math.min(60, slot * 0.7));
2369
2827
  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';
2828
+ const c = o.colors || {};
2829
+ const up = c.up || '#10b981';
2830
+ const down = c.down || '#ef4444';
2831
+ const total = c.total || '#6366f1';
2373
2832
 
2374
2833
  for (let i = 0; i < segs.length; i++) {
2375
2834
  const s = segs[i];
2376
2835
  const x = ax.px + (i + 0.5) * slot - barW / 2;
2377
2836
  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;
2837
+ const h = Math.abs(ax.yToPx(s.to) - y0) * prog;
2838
+ const top = s.to >= s.from ? y0 - h : y0;
2839
+ e.rect(x, top, barW, Math.max(h, 1), { fill: s.total ? total : s.value >= 0 ? up : down, radius: t.barRadius });
2840
+ if (i < segs.length - 1 && prog === 1) {
2388
2841
  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
- });
2842
+ e.poly([{ x: x + barW, y: yEnd }, { x: x + slot, y: yEnd }], { stroke: t.muted, lineWidth: 1, dash: [3, 3] });
2392
2843
  }
2393
2844
  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}`,
2845
+ _id: i, x, y: top, w: barW, h: Math.max(h, 4), tipX: x + barW / 2, tipY: top,
2846
+ index: i, label: s.label, value: s.value, row: data[i],
2847
+ tooltip: `${this._lab(s.label)}: ${s.value > 0 && !s.total ? '+' : ''}${this._fmt(s.value)}`,
2397
2848
  });
2398
2849
  }
2850
+ if (this._hover) {
2851
+ const r = this._hover;
2852
+ e.rect(r.x, r.y, r.w, r.h, { stroke: t.text, lineWidth: 1.5 });
2853
+ }
2399
2854
  }
2400
2855
  }
2401
2856
 
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.
2857
+ // ── src/charts/composite.js
2858
+ // CompositeChart — bars, areas, lines and dots on one category axis, with an
2859
+ // optional right y axis.
2404
2860
  //
2405
- // Layer config: { type: 'bar'|'line'|'area'|'scatter', name, values, axis: 'left'|'right', color?, ... }
2861
+ // data: { categories, series: [{ type: 'bar'|'line'|'area'|'scatter', name,
2862
+ // values, axis?: 'right', format?, color? }] }
2863
+ // scatter values line up with categories like the others (null = no dot).
2864
+
2406
2865
 
2407
2866
 
2867
+ const TYPES = ['bar', 'line', 'area', 'scatter'];
2408
2868
 
2409
2869
  class CompositeChart extends Chart {
2870
+ static get meta() {
2871
+ return { id: 'composite', name: 'CompositeChart', aria: 'Composite chart', data: 'series', options: ['labelFormat', 'min', 'max', 'xScale'], enums: { xScale: ['category', 'time'] } };
2872
+ }
2873
+ constructor(container, options) {
2874
+ super(container, options);
2875
+ this._follow = true;
2876
+ }
2877
+ _legend() {
2878
+ return (this.options.data?.series || []).map((l, i) => ({
2879
+ label: (l.name || l.type) + (l.axis === 'right' ? ' (R)' : ''),
2880
+ color: this._colorOf(i, l),
2881
+ }));
2882
+ }
2410
2883
  _render() {
2411
- const layers = this.options.layers || [];
2412
- const cats = this.options.categories || [];
2413
- if (!layers.length || !cats.length) { this.setEmpty('No data'); return; }
2884
+ const o = this.options;
2885
+ const { categories: cats = [], series: all = [] } = o.data || {};
2886
+ if (!all.length || !cats.length) return this._empty();
2414
2887
  const e = this.engine;
2415
2888
  const t = this.theme;
2889
+ all.forEach((l, i) => {
2890
+ if (!TYPES.includes(l.type)) warn(`CompositeChart: series[${i}] type must be ${TYPES.map((x) => `'${x}'`).join(' | ')} — got ${JSON.stringify(l.type)}.`);
2891
+ });
2892
+ const layers = all.map((l, i) => ({ ...l, i, color: this._colorOf(i, l) })).filter((l) => !this._isOff(l.i));
2416
2893
 
2417
- // Compute domain per axis.
2418
- const leftLayers = layers.filter(l => l.axis !== 'right');
2419
- const rightLayers = layers.filter(l => l.axis === 'right');
2420
2894
  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
- }
2895
+ let mn = 0, mx = -Infinity;
2896
+ for (const l of ll) for (const v of l.values) {
2897
+ if (v == null) continue;
2898
+ const y = +v;
2899
+ if (y < mn) mn = y;
2900
+ if (y > mx) mx = y;
2432
2901
  }
2433
- if (mn === Infinity) return [0, 1];
2434
- return M.niceDomain(Math.min(0, mn), mx, 5);
2902
+ return mx === -Infinity ? [0, 1] : M.niceDomain(mn, mx, 5);
2435
2903
  };
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
-
2904
+ const left = layers.filter((l) => l.axis !== 'right');
2905
+ const rightL = layers.filter((l) => l.axis === 'right');
2906
+ // min / max set the left axis; each axis uses the format of its first
2907
+ // series that has one.
2908
+ let [lLo, lHi] = domain(left);
2909
+ if (o.min != null || o.max != null) [lLo, lHi] = valueDomain(o, o.min ?? lLo, o.max ?? lHi);
2910
+ const fmtOf = (ll) => ll.find((l) => l.format)?.format;
2911
+ const useTime = o.xScale === 'time';
2912
+ const ax = drawCartesian(this, {
2913
+ yMin: lLo, yMax: lHi,
2914
+ yFormat: fmtOf(left),
2915
+ right: rightL.length ? (([a, b]) => ({ yMin: a, yMax: b, yFormat: fmtOf(rightL) }))(domain(rightL)) : null,
2916
+ xLabels: useTime ? null : cats,
2917
+ xTime: useTime ? { min: +cats[0], max: +cats[cats.length - 1] } : null,
2918
+ });
2919
+ const yPx = (l) => (l.axis === 'right' && ax.yRToPx ? ax.yRToPx : ax.yToPx);
2920
+ const xAt = useTime
2921
+ ? (i) => ax.xToPx(+cats[i])
2922
+ : (i) => ax.px + ((i + 0.5) / cats.length) * ax.pw;
2923
+ this._steps = cats.map((_, i) => xAt(i));
2498
2924
  const prog = this._anim.progress;
2499
2925
  const lineW = t.lineWidth;
2500
2926
 
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);
2927
+ // Bars first (clustered), then areas, lines, scatter on top.
2928
+ const bars = layers.filter((l) => l.type === 'bar');
2929
+ const slot = ax.pw / cats.length;
2930
+ const inner = slot * 0.7;
2931
+ const barW = bars.length ? Math.max(1, inner / bars.length) : 0;
2932
+ bars.forEach((l, bi) => {
2933
+ const toPx = yPx(l);
2934
+ const y0 = toPx(0);
2512
2935
  const items = [];
2513
2936
  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
- });
2937
+ const v = +l.values[i] || 0;
2938
+ const h = Math.abs(toPx(v) - y0) * prog;
2939
+ items.push({ x: xAt(i) - inner / 2 + bi * barW, y: v >= 0 ? y0 - h : y0, w: Math.max(1, barW - 1), h, radius: t.barRadius });
2529
2940
  }
2530
- e.rects(items, color);
2941
+ e.rects(items, l.color);
2531
2942
  });
2532
2943
 
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) }));
2944
+ e.clip(ax.px - lineW, ax.py - lineW, (ax.pw + lineW * 2) * prog, ax.ph + lineW * 2);
2945
+ for (const l of layers.filter((x) => x.type === 'area')) {
2946
+ const toPx = yPx(l);
2947
+ const pts = l.values.map((v, i) => ({ x: xAt(i), y: toPx(+v) }));
2539
2948
  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
- });
2949
+ const grad = e.vGradient(`area-${l.i}-${l.color}`, ax.px, ax.py, ax.py + ax.ph, [[0, M.colorAlpha(l.color, 0.4)], [1, M.colorAlpha(l.color, 0)]]);
2950
+ e.smoothArea(pts, cps, Math.min(ax.py + ax.ph, toPx(0)), grad);
2951
+ e.smooth(pts, cps, { stroke: l.color, lineWidth: lineW });
2952
+ }
2953
+ for (const l of layers.filter((x) => x.type === 'line')) {
2954
+ const toPx = yPx(l);
2955
+ const pts = l.values.map((v, i) => ({ x: xAt(i), y: toPx(+v) }));
2956
+ e.smooth(pts, M.bezierControlPoints(pts), { stroke: l.color, lineWidth: lineW });
2957
+ if (pts.length <= 50) e.circles(pts, l.color, t.pointRadius);
2958
+ }
2566
2959
  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);
2960
+ for (const l of layers.filter((x) => x.type === 'scatter')) {
2961
+ const toPx = yPx(l);
2962
+ const items = [];
2963
+ l.values.forEach((v, i) => { if (v != null) items.push({ x: xAt(i), y: toPx(+v) }); });
2964
+ e.withAlpha(0.8, () => e.circles(items, l.color, t.pointRadius + 1));
2965
+ }
2966
+
2967
+ // Shared tooltip for the category under the pointer.
2968
+ this._tipNow = null;
2969
+ const cx = this._cursor?.x;
2970
+ if (cx != null && cx >= ax.px && cx <= ax.px + ax.pw) {
2971
+ let idx = 0, best = Infinity;
2972
+ this._steps.forEach((x, i) => { const d = Math.abs(x - cx); if (d < best) { best = d; idx = i; } });
2973
+ const x = this._steps[idx];
2974
+ e.withAlpha(0.06, () => e.rect(x - slot / 2, ax.py, slot, ax.ph, { fill: t.text }));
2975
+ const rows = [];
2976
+ for (const l of layers) {
2977
+ const v = l.values[idx];
2978
+ if (v == null) continue;
2979
+ rows.push({ color: l.color, name: l.name || l.type, value: M.formatNumber(+v, l.format ?? o.format) });
2980
+ }
2981
+ this._tipNow = { title: this._lab(cats[idx], useTime), rows };
2982
+ this._hover = { _id: idx, index: idx, label: cats[idx] };
2983
+ this.tooltip.show(x, ax.py + 8, this._tipNow, t);
2984
+ } else if (this._cursor) {
2985
+ this.tooltip.hide();
2986
+ }
2586
2987
  }
2587
2988
  }
2588
2989
 
2990
+ // ── src/index.js
2991
+ // @aeriz/chart 2.0 — public API.
2992
+ //
2993
+ // Every chart type is its own module, so bundlers keep only the ones you
2994
+ // import. Without a bundler, import one type directly:
2995
+ // import { BarChart } from '@aeriz/chart/bar';
2589
2996
 
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
- };
2997
+ export { Chart, PALETTE, BarChart, HBarChart, LineChart, AreaChart, StackedBarChart, GroupedBarChart, MultiLineChart, ScatterChart, BubbleChart, PieChart, RadarChart, PolarChart, GaugeChart, HeatmapChart, BoxplotChart, FunnelChart, TreemapChart, SparklineChart, RealtimeChart, WaterfallChart, CompositeChart };