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