@aeriz/chart 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +60 -34
  2. package/dist/aeriz.cjs +609 -354
  3. package/dist/aeriz.esm.js +578 -331
  4. package/dist/aeriz.esm.min.js +119 -105
  5. package/dist/aeriz.js +609 -354
  6. package/dist/aeriz.min.cjs +123 -107
  7. package/dist/aeriz.min.js +123 -107
  8. package/dist/esm/base.js +33 -22
  9. package/dist/esm/cartesian.js +7 -5
  10. package/dist/esm/charts/area.js +6 -6
  11. package/dist/esm/charts/bar.js +3 -3
  12. package/dist/esm/charts/boxplot.js +3 -3
  13. package/dist/esm/charts/bubble.js +3 -3
  14. package/dist/esm/charts/composite.js +8 -8
  15. package/dist/esm/charts/funnel.js +3 -3
  16. package/dist/esm/charts/gauge.js +5 -5
  17. package/dist/esm/charts/grouped-bar.js +4 -4
  18. package/dist/esm/charts/hbar.js +5 -5
  19. package/dist/esm/charts/heatmap.js +6 -6
  20. package/dist/esm/charts/line.js +6 -6
  21. package/dist/esm/charts/multi-line.js +5 -5
  22. package/dist/esm/charts/pie.js +6 -6
  23. package/dist/esm/charts/polar.js +3 -3
  24. package/dist/esm/charts/radar.js +6 -6
  25. package/dist/esm/charts/realtime.js +11 -9
  26. package/dist/esm/charts/scatter.js +4 -4
  27. package/dist/esm/charts/sparkline.js +4 -4
  28. package/dist/esm/charts/stacked-bar.js +5 -5
  29. package/dist/esm/charts/treemap.js +4 -4
  30. package/dist/esm/charts/waterfall.js +4 -4
  31. package/dist/esm/engine.js +1 -1
  32. package/dist/esm/index.js +2 -2
  33. package/dist/esm/jquery.js +5 -4
  34. package/dist/esm/math.js +1 -1
  35. package/dist/esm/react.js +4 -4
  36. package/dist/esm/shallow.js +4 -2
  37. package/dist/esm/svelte.js +4 -4
  38. package/dist/esm/vue.js +4 -2
  39. package/dist/index.d.ts +228 -552
  40. package/dist/jquery.d.ts +19 -8
  41. package/dist/react.d.ts +55 -18
  42. package/dist/svelte.d.ts +53 -14
  43. package/dist/types/sparkline.d.ts +1 -1
  44. package/dist/vue.d.ts +60 -10
  45. package/package.json +2 -2
package/dist/aeriz.esm.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! @aeriz/chart v1.1.0 | Apache-2.0 | https://aeriz.dev/chart/ */
1
+ /*! @aeriz/chart v2.0.0 | Apache-2.0 | https://aeriz.dev/chart/ */
2
2
  // ── src/math.js
3
3
  // math.js — Pure math: scales, ticks, color ops, smoothing, decimation, formatting.
4
4
  // No DOM, no canvas, no side effects.
@@ -541,17 +541,8 @@ const M = { TAU, clamp, lerp, extent, niceTicks, niceDomain, timeNiceTicks, time
541
541
  // while some chart is animating or has something to redraw.
542
542
  const charts = new Set();
543
543
  let rafId = null;
544
- let fps = 60;
545
- let frames = 0;
546
- let fpsT = 0;
547
544
 
548
545
  function tick(now) {
549
- if (now - fpsT > 500) {
550
- fps = Math.round((1000 * frames) / (now - fpsT));
551
- frames = 0;
552
- fpsT = now;
553
- }
554
- frames++;
555
546
  let active = false;
556
547
  for (const c of charts) {
557
548
  try {
@@ -564,22 +555,15 @@ function tick(now) {
564
555
  function schedule(chart) {
565
556
  if (chart._destroyed) return;
566
557
  charts.add(chart);
567
- if (rafId === null) {
568
- fpsT = performance.now();
569
- rafId = requestAnimationFrame(tick);
570
- }
558
+ if (rafId === null) rafId = requestAnimationFrame(tick);
571
559
  }
572
560
 
573
- const scheduler = { schedule, fps: () => fps, count: () => charts.size };
574
- function getFPS() { return fps; }
575
- function getActiveCount() { return charts.size; }
576
-
577
561
  // Media queries are shared by every chart (one listener each, not per chart).
578
562
  const mq = (q) => (typeof matchMedia === 'function' ? matchMedia(q) : null);
579
563
  const reducedMotion = mq('(prefers-reduced-motion: reduce)');
580
564
  const darkScheme = mq('(prefers-color-scheme: dark)');
581
565
  // OS light/dark switch → every chart re-reads its theme.
582
- darkScheme?.addEventListener?.('change', () => { for (const c of charts) c.invalidate(); });
566
+ darkScheme?.addEventListener?.('change', () => { for (const c of charts) { c._theme(); schedule(c); } });
583
567
 
584
568
  // ─── Theme resolution ───────────────────────────────────────
585
569
  // Theme key → CSS variable: fontSize → --aeriz-font-size, tooltipBg → --aeriz-tooltip-bg …
@@ -731,28 +715,163 @@ class HitIndex {
731
715
  }
732
716
  }
733
717
 
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, …
718
+ // ─── Data: one accepted shape per kind ──────────────────────
719
+ // Single-series rows. All three give the same [{ label, value }] rows:
720
+ // [3, 7, 4] → labels 1, 2, 3
721
+ // { Jan: 3, Feb: 7 } → labels Jan, Feb
722
+ // [{ label: 'Jan', value: 3 }, …] (other fields: use the label/value options)
723
+ const isList = (d) => Array.isArray(d) || ArrayBuffer.isView(d);
724
+ const isObj = (d) => d != null && typeof d === 'object' && !isList(d);
725
+
738
726
  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;
727
+ if (isList(data)) {
728
+ if (!data.length || Array.isArray(data[0])) return [];
729
+ return typeof data[0] === 'number' ? Array.from(data, (v, i) => ({ label: String(i + 1), value: v })) : data;
730
+ }
731
+ return isObj(data) ? Object.keys(data).map((k) => ({ label: k, value: +data[k] })) : [];
732
+ }
733
+
734
+ // ─── Developer warnings ─────────────────────────────────────
735
+ // Mistakes don't fail silently: the console says what is wrong and how to fix
736
+ // it, once per message.
737
+ const said = new Set();
738
+ function warn(msg) {
739
+ if (said.has(msg)) return;
740
+ said.add(msg);
741
+ console.warn('[aeriz] ' + msg);
742
+ }
743
+
744
+ // Options every chart takes.
745
+ const COMMON = ['data', 'title', 'colors', 'format', 'legend', 'animate', 'emptyText', 'onClick', 'onLegend'];
746
+
747
+ const SHAPES = {
748
+ rows: 'an array of numbers, an object like { Jan: 64 }, or rows like [{ label, value }]',
749
+ series: '{ categories: [...], series: [{ name, values: [...] }] }',
750
+ stream: '{ series: [{ name }] } (values arrive with chart.push())',
751
+ points: 'points like [{ x, y }]',
752
+ matrix: '{ x: [...], y: [...], values: [[...], ...] } with values[y][x]',
753
+ boxes: 'rows like [{ label, min, q1, median, q3, max }]',
754
+ steps: 'rows like [{ label, value }] (add total: true for a total bar)',
755
+ number: 'a number',
756
+ };
757
+
758
+ // 1.x option names → what to write in 2.0.
759
+ const DATA = 'data: { categories, series }';
760
+ const RENAMED = {
761
+ ariaLabel: 'title', palette: 'colors', color: 'colors',
762
+ xTickFormat: 'labelFormat (category axis) or format (value axis)', yTickFormat: 'format',
763
+ yLeftTickFormat: 'format on the series', yRightTickFormat: 'format on the series',
764
+ categories: DATA, series: DATA, layers: DATA, axes: DATA,
765
+ xLabels: 'data: { x, y, values }', yLabels: 'data: { x, y, values }',
766
+ interpolation: 'curve', windowSize: 'window', yMin: 'min', yMax: 'max', waitingText: 'emptyText',
767
+ colorLow: 'colors: { low, high }', colorHigh: 'colors: { low, high }',
768
+ colorUp: 'colors: { up, down, total }', colorDown: 'colors: { up, down, total }', colorTotal: 'colors: { up, down, total }',
769
+ minRadius: 'radius: [min, max]', maxRadius: 'radius: [min, max]',
770
+ centerLabel: 'center', totalLabel: 'center', type: 'the chart class itself',
771
+ };
772
+ const FUNCS = ['label', 'value', 'labelFormat', 'onClick', 'onLegend'];
773
+
774
+ function distance(a, b) {
775
+ const d = Array.from({ length: b.length + 1 }, (_, j) => j);
776
+ for (let i = 1; i <= a.length; i++) {
777
+ let p = d[0];
778
+ d[0] = i;
779
+ for (let j = 1; j <= b.length; j++) {
780
+ const t = d[j];
781
+ d[j] = Math.min(d[j] + 1, d[j - 1] + 1, p + (a[i - 1] === b[j - 1] ? 0 : 1));
782
+ p = t;
783
+ }
784
+ }
785
+ return d[b.length];
786
+ }
787
+
788
+ function checkOptions(meta, o) {
789
+ const name = meta.name;
790
+ const allowed = [...COMMON, ...meta.options];
791
+ for (const k of Object.keys(o)) {
792
+ const v = o[k];
793
+ if (!allowed.includes(k)) {
794
+ const hint = meta.renamed?.[k] || RENAMED[k];
795
+ if (hint) { warn(`${name}: "${k}" is a 1.x option. In 2.0 use ${hint}.`); continue; }
796
+ let best = null, bd = 3;
797
+ for (const a of allowed) {
798
+ const dd = a.toLowerCase() === k.toLowerCase() ? 0 : distance(k, a);
799
+ if (dd < bd) { bd = dd; best = a; }
800
+ }
801
+ warn(`${name}: unknown option "${k}"${best ? ` — did you mean "${best}"?` : `. Options: ${allowed.join(', ')}`}`);
802
+ continue;
803
+ }
804
+ if (v == null) continue;
805
+ if (FUNCS.includes(k) && typeof v !== 'function') warn(`${name}: "${k}" must be a function${k === 'label' || k === 'value' ? `, like ${k}: (row) => row.${k === 'label' ? 'name' : 'amount'}` : ''}.`);
806
+ const en = meta.enums?.[k];
807
+ if (en && !en.includes(v)) warn(`${name}: ${k} must be ${en.map((x) => `'${x}'`).join(' | ')}, got ${JSON.stringify(v)}.`);
808
+ if (k === 'format' && typeof v !== 'function' && !isObj(v)) warn(`${name}: format must be Intl.NumberFormat options like { style: 'percent' } or a function (v) => string.`);
809
+ if (k === 'colors' && (meta.colors ? !isObj(v) : !Array.isArray(v))) warn(`${name}: colors must be ${meta.colors || 'an array like [\'#64c8ff\', \'#10b981\']'}.`);
810
+ }
811
+ if ('data' in o) checkData(meta, o.data, o);
812
+ }
813
+
814
+ function checkData(meta, d, o) {
815
+ if (d == null) return;
816
+ const kind = meta.data;
817
+ const bad = (why) => warn(`${meta.name}: data must be ${SHAPES[kind]}${why ? ` — ${why}` : ''}.`);
818
+ const first = isList(d) ? d[0] : undefined;
819
+ switch (kind) {
820
+ case 'rows':
821
+ if (isList(d)) {
822
+ if (Array.isArray(first)) return bad(`pairs like ['Jan', 64] were removed in 2.0`);
823
+ if (first != null && typeof first !== 'number' && !isObj(first)) return bad(`got ${typeof first} items`);
824
+ if (isObj(first) && !o.value && !('value' in first)) warn(`${meta.name}: rows need a "value" field, or pass value: (row) => row.amount.`);
825
+ if (isObj(first) && !o.label && !('label' in first)) warn(`${meta.name}: rows need a "label" field, or pass label: (row) => row.name.`);
826
+ } else if (!isObj(d)) bad(`got ${typeof d}`);
827
+ return;
828
+ case 'series':
829
+ case 'stream': {
830
+ if (!isObj(d) || !Array.isArray(d.series) || (kind === 'series' && !isList(d.categories))) return bad(isList(d) ? 'got an array' : '');
831
+ const n = kind === 'series' ? d.categories.length : 0;
832
+ d.series.forEach((s, i) => {
833
+ const who = `series[${i}]${s?.name ? ` "${s.name}"` : ''}`;
834
+ if (kind === 'stream') return;
835
+ if (!s || !isList(s.values)) return warn(`${meta.name}: ${who} needs values: [...].`);
836
+ if (s.values.length !== n) warn(`${meta.name}: ${who} has ${s.values.length} values but there are ${n} categories.`);
837
+ });
838
+ return;
839
+ }
840
+ case 'matrix':
841
+ if (!isObj(d) || !isList(d.x) || !isList(d.y) || !Array.isArray(d.values)) return bad(isList(d) ? 'got an array' : '');
842
+ if (d.values.length !== d.y.length) warn(`${meta.name}: values has ${d.values.length} rows but y has ${d.y.length} labels.`);
843
+ return;
844
+ case 'number':
845
+ if (typeof d !== 'number') bad(`got ${typeof d}`);
846
+ return;
847
+ default: {
848
+ // points · boxes · steps: arrays of objects with known fields
849
+ if (!Array.isArray(d)) return bad(`got ${isObj(d) ? 'an object' : typeof d}`);
850
+ if (!first) return;
851
+ const need = { points: ['x', 'y'], boxes: ['q1', 'median', 'q3'], steps: ['value'] }[kind];
852
+ const miss = need.filter((k) => !(k in first));
853
+ if (miss.length) bad(`the first item has no ${miss.join(', ')}`);
854
+ if (kind === 'steps' && 'isTotal' in first) warn(`${meta.name}: "isTotal" is 1.x. In 2.0 use total: true.`);
855
+ if (kind === 'points' && ('r' in first || 'z' in first)) warn(`${meta.name}: point "${'r' in first ? 'r' : 'z'}" is 1.x. In 2.0 use size.`);
856
+ }
857
+ }
747
858
  }
748
859
 
749
860
  // ─── Chart base ─────────────────────────────────────────────
861
+ const STATES = ['loading', 'empty', 'error'];
862
+
750
863
  class Chart {
864
+ // Each chart type describes itself: name for messages, aria for the default
865
+ // accessible name, data shape, and the options it takes beyond COMMON.
866
+ static get meta() { return null; }
867
+
751
868
  constructor(container, options = {}) {
869
+ const meta = this.constructor.meta;
752
870
  const el = typeof container === 'string' ? document.querySelector(container) : container;
753
- if (!el) throw new Error(`aeriz: container not found${typeof container === 'string' ? ': ' + container : ''}`);
871
+ if (!el) throw new Error(`aeriz: ${meta ? meta.name + ': ' : ''}container not found${typeof container === 'string' ? ': ' + container : ''}`);
754
872
  this.container = el;
755
- this.options = options;
873
+ this.options = { ...options };
874
+ if (meta) checkOptions(meta, this.options);
756
875
 
757
876
  // Remember what we touch on the container so destroy() can undo it.
758
877
  this._restore = { position: el.style.position, height: el.style.height };
@@ -780,8 +899,6 @@ class Chart {
780
899
  this.overlay = new Overlay(this.plot);
781
900
  this.hits = new HitIndex();
782
901
 
783
- this.theme = resolveTheme(el);
784
- this.palette = options.palette || readPalette(el);
785
902
  this._off = new Set(); // legend-hidden series / item indices
786
903
  this._legendKey = '';
787
904
  this._cursor = null;
@@ -794,6 +911,7 @@ class Chart {
794
911
  this._anim = { progress: 0, start: 0, duration: 600 };
795
912
  this._state = null; // 'loading' | 'empty' | 'error' | null
796
913
  this._auto = false; // _state was set automatically by "No data"
914
+ this._theme();
797
915
  this._label();
798
916
 
799
917
  this._setupEvents();
@@ -804,34 +922,44 @@ class Chart {
804
922
  schedule(this);
805
923
  }
806
924
 
807
- // ─── Public API ──────────────────────────────────────────
808
- // Re-read theme + palette (after changing CSS variables) and redraw.
809
- invalidate() {
810
- this.theme = resolveTheme(this.container);
811
- this.palette = this.options.palette || readPalette(this.container);
812
- this.engine._gradCache.clear();
813
- this._legendKey = '';
814
- this._dirty = true;
815
- schedule(this);
816
- }
817
-
818
- // Merge new options (any option, including data) and redraw with animation.
925
+ // ─── Public API: update · setState · render · destroy ────
926
+ // Change any options (data included). Changed values animate in.
819
927
  update(patch = {}) {
928
+ const meta = this.constructor.meta;
929
+ if (meta) checkOptions(meta, patch);
820
930
  Object.assign(this.options, patch);
821
931
  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);
932
+ if ('colors' in patch) this._theme();
823
933
  if (this._auto) { this._state = null; this._auto = false; }
824
934
  this._label();
825
935
  this._restart();
826
936
  return this;
827
937
  }
828
938
 
829
- setData(data) { return this.update({ data }); }
939
+ // Show a loading / empty / error overlay, or remove it with null.
940
+ setState(state, text) {
941
+ if (state != null && !STATES.includes(state)) {
942
+ throw new Error(`aeriz: setState() takes 'loading', 'empty', 'error' or null — got ${JSON.stringify(state)}`);
943
+ }
944
+ this._state = state || null;
945
+ this._stateMsg = text ?? (state === 'empty' ? this.options.emptyText || 'No data' : state === 'error' ? 'Error' : '');
946
+ this._auto = false;
947
+ this._dirty = true;
948
+ schedule(this);
949
+ return this;
950
+ }
830
951
 
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); }
952
+ // Re-read CSS variables / theme and draw the final frame right now — also
953
+ // when the chart is off-screen (printing, export, measuring).
954
+ render() {
955
+ if (this._destroyed) return this;
956
+ this._theme();
957
+ this._anim.progress = 1;
958
+ this._resize();
959
+ this._dirty = false;
960
+ this._draw();
961
+ return this;
962
+ }
835
963
 
836
964
  destroy() {
837
965
  if (this._destroyed) return;
@@ -848,17 +976,17 @@ class Chart {
848
976
  }
849
977
 
850
978
  // ─── Internals ───────────────────────────────────────────
851
- _setState(kind, msg) {
852
- this._state = kind;
853
- this._stateMsg = msg;
854
- this._auto = false;
979
+ _theme() {
980
+ const o = this.options;
981
+ this.theme = resolveTheme(this.container);
982
+ this.palette = Array.isArray(o.colors) && o.colors.length ? o.colors : readPalette(this.container);
983
+ this.engine._gradCache.clear();
984
+ this._legendKey = '';
855
985
  this._dirty = true;
856
- schedule(this);
857
- return this;
858
986
  }
859
987
 
860
- // Charts call this when they find nothing to draw. Unlike setEmpty() it
861
- // clears itself on the next update()/setData().
988
+ // Charts call this when they find nothing to draw. Unlike setState('empty')
989
+ // it clears itself on the next update().
862
990
  _empty(msg = this.options.emptyText || 'No data') {
863
991
  this._state = 'empty';
864
992
  this._stateMsg = msg;
@@ -875,13 +1003,41 @@ class Chart {
875
1003
  }
876
1004
 
877
1005
  _label() {
878
- const o = this.options;
879
- this.canvas.setAttribute('aria-label', o.ariaLabel || o.title || this._kind || 'Chart');
1006
+ this.canvas.setAttribute('aria-label', this.options.title || this.constructor.meta?.aria || 'Chart');
880
1007
  }
881
1008
 
882
1009
  _fmt(v) { return M.formatNumber(v, this.options.format); }
883
1010
  _tickFmt(v) { return M.formatNumber(v, this.options.format, true); }
1011
+ // Category / time label as text (labelFormat wins).
1012
+ _lab(l, time) {
1013
+ const f = this._fn('labelFormat');
1014
+ return f ? String(f(l)) : time ? new Date(+l).toLocaleString() : String(l);
1015
+ }
1016
+ // Numeric x value (scatter, bubble) as text.
1017
+ _xFmt(v) {
1018
+ const f = this._fn('labelFormat');
1019
+ return f ? String(f(v)) : this._fmt(v);
1020
+ }
884
1021
  _isOff(i) { return this._off.has(i); }
1022
+ // label / value accessor, when it is a function (otherwise warned about).
1023
+ _fn(k) {
1024
+ const f = this.options[k];
1025
+ return typeof f === 'function' ? f : null;
1026
+ }
1027
+
1028
+ // What onClick receives: the clicked item, without drawing internals.
1029
+ _hit(h) {
1030
+ if (!h) return null;
1031
+ const out = { index: h.index, label: h.label };
1032
+ if ('value' in h) out.value = h.value;
1033
+ if ('series' in h) out.series = h.series;
1034
+ if ('row' in h) out.row = h.row;
1035
+ return out;
1036
+ }
1037
+ _click(h) {
1038
+ const hit = this._hit(h);
1039
+ if (hit) this.options.onClick?.(hit, this);
1040
+ }
885
1041
 
886
1042
  _resize() {
887
1043
  const w = this.plot.clientWidth;
@@ -981,8 +1137,7 @@ class Chart {
981
1137
 
982
1138
  _onClick(e) {
983
1139
  const p = this._ptr(e);
984
- const hit = this.hits.find(p.x, p.y) || (this._follow ? this._hover : null);
985
- this.options.onClick?.(hit, p, this);
1140
+ this._click(this.hits.find(p.x, p.y) || (this._follow ? this._hover : null));
986
1141
  }
987
1142
 
988
1143
  // ← → (↑ ↓) walk through the data points, Home/End jump, Enter clicks,
@@ -992,7 +1147,7 @@ class Chart {
992
1147
  const fwd = k === 'ArrowRight' || k === 'ArrowDown';
993
1148
  const back = k === 'ArrowLeft' || k === 'ArrowUp';
994
1149
  if (k === 'Escape') return this._onLeave();
995
- if (k === 'Enter' && this._hover) return this.options.onClick?.(this._hover, this._cursor, this);
1150
+ if (k === 'Enter' && this._hover) return this._click(this._hover);
996
1151
  if (!fwd && !back && k !== 'Home' && k !== 'End') return;
997
1152
  const steps = this._steps?.length
998
1153
  ? this._steps.map((x) => ({ x, y: this.engine.h / 2 }))
@@ -1099,6 +1254,15 @@ class Chart {
1099
1254
  }
1100
1255
  }
1101
1256
 
1257
+ // Removed in 2.0 — fail loudly with the replacement instead of "is not a function".
1258
+ const GONE = {
1259
+ setData: 'update({ data })', setLoading: "setState('loading', text)", setEmpty: "setState('empty', text)",
1260
+ setError: "setState('error', text)", clearState: 'setState(null)', invalidate: 'render()', setValue: 'update({ data: value })',
1261
+ };
1262
+ for (const k in GONE) {
1263
+ Chart.prototype[k] = function () { throw new Error(`aeriz 2.0: chart.${k}() was removed. Use chart.${GONE[k]}.`); };
1264
+ }
1265
+
1102
1266
  // ── src/cartesian.js
1103
1267
  // cartesian.js — Axes + grid for x/y charts. Only charts with axes import it.
1104
1268
 
@@ -1123,9 +1287,10 @@ function localTimeFormat(step) {
1123
1287
 
1124
1288
  // Draw grid, y axis (left, optional right) and x axis. Returns plot geometry.
1125
1289
  //
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} }
1290
+ // spec: { yMin, yMax, yScale?: 'linear'|'log', yFormat?,
1291
+ // xLabels?: any[] | xTime?: {min,max} | xLinear?: {min,max},
1292
+ // right?: { yMin, yMax, yFormat? }, pad?: {l,r,t,b} }
1293
+ // The value axis uses the chart's `format`; x labels use its `labelFormat`.
1129
1294
  function drawCartesian(chart, spec) {
1130
1295
  const e = chart.engine;
1131
1296
  const t = chart.theme;
@@ -1149,7 +1314,7 @@ function drawCartesian(chart, spec) {
1149
1314
  yTicks = M.niceTicks(yMin, yMax, 5);
1150
1315
  yNorm = (v) => (v - yMin) / (yMax - yMin || 1);
1151
1316
  }
1152
- const yFmt = spec.yTickFormat || ((v) => chart._tickFmt(v));
1317
+ const yFmt = spec.yFormat ? (v) => M.formatNumber(v, spec.yFormat, true) : (v) => chart._tickFmt(v);
1153
1318
  const yText = yTicks.map((v) => yFmt(v));
1154
1319
  let maxW = 0;
1155
1320
  for (const s of yText) maxW = Math.max(maxW, e.measure(s, font));
@@ -1159,7 +1324,7 @@ function drawCartesian(chart, spec) {
1159
1324
  if (right) {
1160
1325
  rTicks = M.niceTicks(right.yMin, right.yMax, 5);
1161
1326
  rNorm = (v) => (v - right.yMin) / (right.yMax - right.yMin || 1);
1162
- const rf = right.yTickFormat || ((v) => chart._tickFmt(v));
1327
+ const rf = right.yFormat ? (v) => M.formatNumber(v, right.yFormat, true) : (v) => chart._tickFmt(v);
1163
1328
  rText = rTicks.map((v) => rf(v));
1164
1329
  for (const s of rText) rMaxW = Math.max(rMaxW, e.measure(s, font));
1165
1330
  }
@@ -1194,23 +1359,24 @@ function drawCartesian(chart, spec) {
1194
1359
 
1195
1360
  // X axis: categorical labels, time, or linear.
1196
1361
  let xToPx, xTicks, xFmt;
1362
+ const lf = chart._fn('labelFormat');
1197
1363
  if (spec.xTime) {
1198
1364
  const { min, max } = spec.xTime;
1199
1365
  xToPx = (v) => px + (pw * (v - min)) / (max - min || 1);
1200
1366
  xTicks = M.timeNiceTicks(min, max, Math.max(2, Math.floor(pw / 90)));
1201
1367
  const step = xTicks.length > 1 ? xTicks[1] - xTicks[0] : max - min;
1202
- xFmt = spec.xTickFormat || localTimeFormat(step);
1368
+ xFmt = lf ? (v) => String(lf(v)) : localTimeFormat(step);
1203
1369
  } else if (spec.xLinear) {
1204
1370
  const { min, max } = spec.xLinear;
1205
1371
  xToPx = (v) => px + (pw * (v - min)) / (max - min || 1);
1206
1372
  xTicks = M.niceTicks(min, max, 6);
1207
- xFmt = spec.xTickFormat || ((v) => chart._tickFmt(v));
1373
+ xFmt = lf ? (v) => String(lf(v)) : (v) => chart._tickFmt(v);
1208
1374
  } else if (spec.xLabels) {
1209
1375
  const n = spec.xLabels.length || 1;
1210
1376
  xToPx = (i) => px + (i + 0.5) * (pw / n);
1211
- xFmt = spec.xTickFormat || ((i) => String(spec.xLabels[i]));
1377
+ xFmt = (i) => chart._lab(spec.xLabels[i]);
1212
1378
  // 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;
1379
+ const guess = Math.max(e.measure(xFmt(0), font), e.measure(xFmt(n - 1), font)) + 6;
1214
1380
  const k = Math.max(1, Math.ceil((n * guess) / pw));
1215
1381
  xTicks = [];
1216
1382
  for (let i = 0; i < n; i += k) xTicks.push(i);
@@ -1219,7 +1385,7 @@ function drawCartesian(chart, spec) {
1219
1385
  // Skip labels that would collide; keep the outer ones inside the canvas.
1220
1386
  let lastEnd = -Infinity;
1221
1387
  for (const v of xTicks) {
1222
- const txt = xFmt(v, v);
1388
+ const txt = xFmt(v);
1223
1389
  const w = e.measure(txt, font);
1224
1390
  const x = Math.max(w / 2, Math.min(e.w - w / 2, xToPx(v)));
1225
1391
  if (x - w / 2 < lastEnd + 6) continue;
@@ -1247,13 +1413,24 @@ function range(arrays, lo = Infinity, hi = -Infinity) {
1247
1413
  return [lo, hi];
1248
1414
  }
1249
1415
 
1416
+ // Value-axis domain from the data range, with the chart's min / max options
1417
+ // taking precedence over the nice rounding.
1418
+ function valueDomain(o, lo, hi) {
1419
+ const [a, b] = M.niceDomain(o.min ?? lo, o.max ?? hi, 5);
1420
+ return [o.min ?? a, o.max ?? b];
1421
+ }
1422
+
1423
+ // Option names shared by charts with x/y axes.
1424
+ const AXIS = ['labelFormat', 'min', 'max'];
1425
+
1250
1426
  // ── src/charts/bar.js
1251
1427
  // BarChart — vertical bars, negative values grow downward from zero.
1252
1428
 
1253
1429
 
1254
-
1255
1430
  class BarChart extends Chart {
1256
- get _kind() { return 'Bar chart'; } // aria-label default (bundlers minify class names)
1431
+ static get meta() {
1432
+ return { id: 'bar', name: 'BarChart', aria: 'Bar chart', data: 'rows', options: ['label', 'value', 'labelFormat', 'min', 'max', 'dataLabels'] };
1433
+ }
1257
1434
 
1258
1435
  _render() {
1259
1436
  const data = toRows(this.options.data);
@@ -1261,25 +1438,24 @@ class BarChart extends Chart {
1261
1438
  const e = this.engine;
1262
1439
  const t = this.theme;
1263
1440
  const o = this.options;
1264
- const labelOf = o.label || ((d) => d.label);
1265
- const valueOf = o.value || ((d) => d.value);
1441
+ const labelOf = this._fn('label') || ((d) => d.label);
1442
+ const valueOf = this._fn('value') || ((d) => d.value);
1266
1443
  const values = data.map((d, i) => +valueOf(d, i) || 0);
1444
+ const labels = data.map((d, i) => labelOf(d, i));
1267
1445
  const [lo, hi] = range([values], 0, 0);
1268
- const [yLo, yHi] = M.niceDomain(lo, hi, 5);
1446
+ const [yLo, yHi] = valueDomain(o, lo, hi);
1447
+ const dl = o.dataLabels;
1269
1448
  const ax = drawCartesian(this, {
1270
1449
  yMin: yLo, yMax: yHi,
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,
1450
+ xLabels: labels,
1451
+ pad: dl ? { t: t.fontSize + 10 } : undefined,
1275
1452
  });
1276
1453
 
1277
1454
  const slot = ax.pw / data.length;
1278
1455
  const barW = Math.max(1, Math.min(60, slot * 0.8, slot - 2));
1279
1456
  const prog = this._anim.progress;
1280
- const y0 = ax.yToPx(0);
1457
+ const y0 = ax.yToPx(Math.max(yLo, 0));
1281
1458
  const groups = new Map();
1282
- const dl = o.dataLabels;
1283
1459
  const font = `${t.fontSize}px ${t.font}`;
1284
1460
 
1285
1461
  for (let i = 0; i < data.length; i++) {
@@ -1287,17 +1463,18 @@ class BarChart extends Chart {
1287
1463
  const x = ax.px + (i + 0.5) * slot - barW / 2;
1288
1464
  const h = Math.abs(ax.yToPx(v) - y0) * prog;
1289
1465
  const top = v >= 0 ? y0 - h : y0;
1290
- const color = this._colorOf(o.color ? 0 : i, data[i].color ? data[i] : { color: o.color });
1466
+ const color = this._colorOf(i, data[i]);
1291
1467
  if (!groups.has(color)) groups.set(color, []);
1292
1468
  groups.get(color).push({ x, y: top, w: barW, h, radius: t.barRadius });
1469
+ const label = this._lab(labels[i]);
1293
1470
  this.hits.add({
1294
1471
  _id: i, x, y: Math.min(top, y0 - 2), w: barW, h: Math.max(h, 4),
1295
1472
  tipX: x + barW / 2, tipY: top,
1296
- tooltip: `${labelOf(data[i], i)}: ${this._fmt(v)}`,
1297
- data: data[i], index: i, value: v,
1473
+ tooltip: `${label}: ${this._fmt(v)}`,
1474
+ index: i, label: labels[i], value: v, row: data[i],
1298
1475
  });
1299
1476
  if (dl && prog > 0.95) {
1300
- const txt = typeof dl === 'function' ? dl(data[i], i) : this._fmt(v);
1477
+ const txt = typeof dl === 'function' ? dl(v, data[i], i) : this._fmt(v);
1301
1478
  e.text(txt, x + barW / 2, v >= 0 ? top - 4 : top + h + 4, {
1302
1479
  fill: t.text, font, align: 'center', baseline: v >= 0 ? 'bottom' : 'top',
1303
1480
  });
@@ -1318,7 +1495,9 @@ class BarChart extends Chart {
1318
1495
 
1319
1496
 
1320
1497
  class HBarChart extends Chart {
1321
- get _kind() { return 'Horizontal bar chart'; } // aria-label default (bundlers minify class names)
1498
+ static get meta() {
1499
+ return { id: 'hbar', name: 'HBarChart', aria: 'Horizontal bar chart', data: 'rows', options: ['label', 'value', 'labelFormat', 'min', 'max'] };
1500
+ }
1322
1501
 
1323
1502
  _render() {
1324
1503
  const data = toRows(this.options.data);
@@ -1326,12 +1505,13 @@ class HBarChart extends Chart {
1326
1505
  const e = this.engine;
1327
1506
  const t = this.theme;
1328
1507
  const o = this.options;
1329
- const labelOf = o.label || ((d) => d.label);
1330
- const valueOf = o.value || ((d) => d.value);
1508
+ const labelOf = this._fn('label') || ((d) => d.label);
1509
+ const valueOf = this._fn('value') || ((d) => d.value);
1331
1510
  const values = data.map((d, i) => +valueOf(d, i) || 0);
1332
- const labels = data.map((d, i) => String(labelOf(d, i)));
1511
+ const raw = data.map((d, i) => labelOf(d, i));
1512
+ const labels = raw.map((l) => this._lab(l));
1333
1513
  const [lo, hi] = range([values], 0, 0);
1334
- const [xLo, xHi] = M.niceDomain(lo, hi, 5);
1514
+ const [xLo, xHi] = valueDomain(o, lo, hi);
1335
1515
  const font = `${t.fontSize}px ${t.font}`;
1336
1516
 
1337
1517
  let labelW = 0;
@@ -1342,7 +1522,6 @@ class HBarChart extends Chart {
1342
1522
 
1343
1523
  const xTicks = M.niceTicks(xLo, xHi, Math.max(2, Math.min(6, Math.floor(pw / 70))));
1344
1524
  const xToPx = (v) => px + (pw * (v - xLo)) / (xHi - xLo || 1);
1345
- const xFmt = o.xTickFormat || ((v) => this._tickFmt(v));
1346
1525
  e.ctx.strokeStyle = t.grid;
1347
1526
  e.ctx.lineWidth = 1;
1348
1527
  e.ctx.beginPath();
@@ -1353,14 +1532,14 @@ class HBarChart extends Chart {
1353
1532
  }
1354
1533
  e.ctx.stroke();
1355
1534
  for (const v of xTicks) {
1356
- e.text(xFmt(v), xToPx(v), py + ph + 6, { fill: t.muted, font, align: 'center', baseline: 'top' });
1535
+ e.text(this._tickFmt(v), xToPx(v), py + ph + 6, { fill: t.muted, font, align: 'center', baseline: 'top' });
1357
1536
  }
1358
1537
  e.poly([{ x: px, y: py + ph }, { x: px + pw, y: py + ph }], { stroke: t.axis, lineWidth: 1 });
1359
1538
 
1360
1539
  const slot = ph / data.length;
1361
1540
  const barH = Math.max(1, Math.min(40, slot * 0.75, slot - 2));
1362
1541
  const prog = this._anim.progress;
1363
- const x0 = xToPx(0);
1542
+ const x0 = xToPx(Math.max(xLo, 0));
1364
1543
  const groups = new Map();
1365
1544
 
1366
1545
  for (let i = 0; i < data.length; i++) {
@@ -1369,14 +1548,14 @@ class HBarChart extends Chart {
1369
1548
  const y = yMid - barH / 2;
1370
1549
  const w = Math.abs(xToPx(v) - x0) * prog;
1371
1550
  const left = v >= 0 ? x0 : x0 - w;
1372
- const color = this._colorOf(o.color ? 0 : i, data[i].color ? data[i] : { color: o.color });
1551
+ const color = this._colorOf(i, data[i]);
1373
1552
  if (!groups.has(color)) groups.set(color, []);
1374
1553
  groups.get(color).push({ x: left, y, w, h: barH, radius: t.barRadius });
1375
1554
  this.hits.add({
1376
1555
  _id: i, x: Math.min(left, x0 - 2), y, w: Math.max(w, 4), h: barH,
1377
1556
  tipX: left + w / 2, tipY: y,
1378
1557
  tooltip: `${labels[i]}: ${this._fmt(v)}`,
1379
- data: data[i], index: i, value: v,
1558
+ index: i, label: raw[i], value: v, row: data[i],
1380
1559
  });
1381
1560
  e.text(labels[i], px - 6, yMid, { fill: t.muted, font, align: 'right', baseline: 'middle' });
1382
1561
  }
@@ -1398,42 +1577,50 @@ const LABEL = (d) => d.label;
1398
1577
  const VALUE = (d) => d.value;
1399
1578
 
1400
1579
  class LineChart extends Chart {
1401
- get _kind() { return 'Line chart'; } // aria-label default (bundlers minify class names)
1580
+ static get meta() {
1581
+ return {
1582
+ id: 'line', name: 'LineChart', aria: 'Line chart', data: 'rows',
1583
+ options: ['label', 'value', 'labelFormat', 'min', 'max', 'curve', 'fill', 'points', 'xScale', 'yScale'],
1584
+ enums: { curve: ['smooth', 'linear', 'step'], xScale: ['category', 'time'], yScale: ['linear', 'log'] },
1585
+ };
1586
+ }
1402
1587
 
1403
1588
  _render() {
1404
- const data = toRows(this.options.data);
1405
- if (!data.length) return this._empty();
1406
- const e = this.engine;
1407
- const t = this.theme;
1408
1589
  const o = this.options;
1409
- const labelOf = o.label || LABEL;
1410
- const valueOf = o.value || VALUE;
1411
- const n = data.length;
1590
+ const labelOf = this._fn('label') || LABEL;
1591
+ const valueOf = this._fn('value') || VALUE;
1412
1592
 
1413
- // Values are cached per data array so hover redraws don't re-map 1M rows.
1593
+ // Rows and values are cached per data array so hover redraws don't
1594
+ // re-map 1M rows.
1414
1595
  let c = this._cache;
1415
1596
  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
- }
1597
+ const d = o.data;
1598
+ // Plain numbers (incl. typed arrays): skip building a row object per point.
1599
+ const nums = d && typeof d[0] === 'number' && (Array.isArray(d) || ArrayBuffer.isView(d)) && !this._fn('value') && !this._fn('label');
1600
+ const rows = nums ? d : toRows(d);
1601
+ const values = nums ? Float64Array.from(d) : new Float64Array(rows.length);
1602
+ if (!nums) for (let i = 0; i < rows.length; i++) values[i] = +valueOf(rows[i], i);
1603
+ const labels = nums ? Array.from({ length: d.length }, (_, i) => i + 1) : null;
1604
+ c = this._cache = { data: o.data, rows, valueOf, labelOf, values, idx: null, idxW: 0, xs: null, labels, range: null };
1605
+ }
1606
+ const data = c.rows;
1607
+ if (!data.length) return this._empty();
1608
+ const e = this.engine;
1609
+ const t = this.theme;
1610
+ const n = data.length;
1420
1611
  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);
1612
+ const log = o.yScale === 'log';
1613
+ const [lo, hi] = (c.range ||= range([values], log ? Infinity : 0));
1614
+ const [yLo, yHi] = log ? [o.min ?? lo, o.max ?? hi] : valueDomain(o, lo, hi);
1423
1615
  const useTime = o.xScale === 'time';
1424
- const spec = {
1425
- yMin: yLo, yMax: yHi,
1426
- yScale: o.yScale,
1427
- yTickFormat: o.yTickFormat,
1428
- xTickFormat: o.xTickFormat,
1429
- };
1430
- const xs = useTime ? (c.xs ||= data.map((d, i) => +labelOf(d, i))) : null;
1616
+ const spec = { yMin: yLo, yMax: yHi, yScale: o.yScale };
1617
+ const xs = useTime ? (c.xs ||= Array.from(data, (d, i) => +labelOf(d, i))) : null;
1431
1618
  if (useTime) spec.xTime = { min: xs[0], max: xs[n - 1] };
1432
- else spec.xLabels = c.labels ||= data.map((d, i) => labelOf(d, i));
1619
+ else spec.xLabels = c.labels ||= Array.from(data, (d, i) => labelOf(d, i));
1433
1620
  const ax = drawCartesian(this, spec);
1434
1621
 
1435
- const color = o.color || this.palette[0];
1436
- const interp = o.interpolation || 'smooth';
1622
+ const color = this.palette[0];
1623
+ const interp = o.curve || 'smooth';
1437
1624
  const lineW = t.lineWidth;
1438
1625
  const prog = this._anim.progress;
1439
1626
  const xAt = useTime ? (i) => ax.xToPx(xs[i]) : (i) => ax.px + ((i + 0.5) / n) * ax.pw;
@@ -1468,19 +1655,20 @@ class LineChart extends Chart {
1468
1655
  }
1469
1656
  if (cps) e.smooth(points, cps, { stroke: color, lineWidth: lineW });
1470
1657
  else e.poly(points, { stroke: color, lineWidth: lineW, cap: 'round', join: 'round' });
1471
- } else if (interp.startsWith('step')) {
1472
- e.step(points, interp === 'step' ? 'before' : interp.split('-')[1], { stroke: color, lineWidth: lineW });
1658
+ } else {
1659
+ e.step(points, 'before', { stroke: color, lineWidth: lineW });
1473
1660
  }
1474
1661
  e.unclip();
1475
1662
 
1476
- const tip = (i) => `${useTime ? new Date(xs[i]).toLocaleString() : labelOf(data[i], i)}: ${this._fmt(values[i])}`;
1663
+ const labelAt = (i) => (useTime ? xs[i] : c.labels[i]);
1664
+ const tip = (i) => `${this._lab(labelAt(i), useTime)}: ${this._fmt(values[i])}`;
1477
1665
  if (!decimated && n <= 50) {
1478
1666
  if (o.points !== false) e.circles(points, color, t.pointRadius);
1479
1667
  for (const p of points) {
1480
1668
  this.hits.add({
1481
1669
  _id: p.i, x: p.x - 8, y: p.y - 8, w: 16, h: 16,
1482
1670
  tipX: p.x, tipY: p.y - t.pointRadius,
1483
- tooltip: tip(p.i), data: data[p.i], index: p.i, value: values[p.i],
1671
+ tooltip: tip(p.i), index: p.i, label: labelAt(p.i), value: values[p.i], row: data[p.i],
1484
1672
  });
1485
1673
  }
1486
1674
  } else {
@@ -1493,7 +1681,7 @@ class LineChart extends Chart {
1493
1681
  this.hits.add({
1494
1682
  _id: p.i, x: l, y: ax.py, w: Math.max(1, r - l), h: ax.ph,
1495
1683
  tipX: p.x, tipY: p.y - 8,
1496
- tooltip: tip(p.i), data: data[p.i], index: p.i, value: values[p.i],
1684
+ tooltip: tip(p.i), index: p.i, label: labelAt(p.i), value: values[p.i], row: data[p.i],
1497
1685
  });
1498
1686
  }
1499
1687
  }
@@ -1511,13 +1699,14 @@ class LineChart extends Chart {
1511
1699
 
1512
1700
 
1513
1701
  class AreaChart extends Chart {
1514
- get _kind() { return 'Area chart'; } // aria-label default (bundlers minify class names)
1702
+ static get meta() {
1703
+ return { id: 'area', name: 'AreaChart', aria: 'Area chart', data: 'series', options: ['labelFormat', 'min', 'max', 'stacked'] };
1704
+ }
1515
1705
 
1516
- _legend() { return this._seriesLegend(this.options.series || []); }
1706
+ _legend() { return this._seriesLegend(this.options.data?.series || []); }
1517
1707
  _render() {
1518
1708
  const o = this.options;
1519
- const series = o.series || [];
1520
- const cats = o.categories || [];
1709
+ const { categories: cats = [], series = [] } = o.data || {};
1521
1710
  if (!series.length || !cats.length) return this._empty();
1522
1711
  const e = this.engine;
1523
1712
  const t = this.theme;
@@ -1535,8 +1724,8 @@ class AreaChart extends Chart {
1535
1724
  } else {
1536
1725
  [yMin, yMax] = range(on.map((s) => series[s].values), 0, 0);
1537
1726
  }
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 });
1727
+ const [yLo, yHi] = valueDomain(o, yMin, yMax);
1728
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: cats });
1540
1729
  const lineW = t.lineWidth;
1541
1730
  const prog = this._anim.progress;
1542
1731
  const xAt = (i) => ax.px + ((i + 0.5) / cats.length) * ax.pw;
@@ -1575,9 +1764,9 @@ class AreaChart extends Chart {
1575
1764
  for (let i = 0; i < cats.length; i++) {
1576
1765
  this.hits.add({
1577
1766
  _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,
1767
+ tipX: xAt(i), tipY: ax.py + 8, index: i, label: cats[i],
1579
1768
  tooltip: {
1580
- title: String(cats[i]),
1769
+ title: this._lab(cats[i]),
1581
1770
  rows: on.map((s) => ({ color: this._colorOf(s, series[s]), name: series[s].name || 'Series ' + (s + 1), value: this._fmt(+series[s].values[i]) })),
1582
1771
  },
1583
1772
  });
@@ -1593,15 +1782,15 @@ class AreaChart extends Chart {
1593
1782
  // StackedBarChart — positive values stack up from zero, negative values down.
1594
1783
 
1595
1784
 
1596
-
1597
1785
  class StackedBarChart extends Chart {
1598
- get _kind() { return 'Stacked bar chart'; } // aria-label default (bundlers minify class names)
1786
+ static get meta() {
1787
+ return { id: 'stacked-bar', name: 'StackedBarChart', aria: 'Stacked bar chart', data: 'series', options: ['labelFormat', 'min', 'max'] };
1788
+ }
1599
1789
 
1600
- _legend() { return this._seriesLegend(this.options.series || []); }
1790
+ _legend() { return this._seriesLegend(this.options.data?.series || []); }
1601
1791
  _render() {
1602
1792
  const o = this.options;
1603
- const series = o.series || [];
1604
- const cats = o.categories || [];
1793
+ const { categories: cats = [], series = [] } = o.data || {};
1605
1794
  if (!series.length || !cats.length) return this._empty();
1606
1795
  const e = this.engine;
1607
1796
  const t = this.theme;
@@ -1617,8 +1806,8 @@ class StackedBarChart extends Chart {
1617
1806
  if (pos > hi) hi = pos;
1618
1807
  if (neg < lo) lo = neg;
1619
1808
  }
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 });
1809
+ const [yLo, yHi] = valueDomain(o, lo, hi);
1810
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: cats });
1622
1811
 
1623
1812
  const slot = ax.pw / cats.length;
1624
1813
  const barW = Math.max(1, Math.min(50, slot * 0.8, slot - 2));
@@ -1647,8 +1836,8 @@ class StackedBarChart extends Chart {
1647
1836
  (v > 0 ? lastPos : lastNeg)[i] = item;
1648
1837
  this.hits.add({
1649
1838
  _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) }] },
1839
+ tipX: x + barW / 2, tipY: y, index: i, label: cats[i], series: s, value: v, row: series[s],
1840
+ tooltip: { title: this._lab(cats[i]), rows: [{ color, name: series[s].name || 'Series ' + (s + 1), value: this._fmt(v) }] },
1652
1841
  });
1653
1842
  }
1654
1843
  }
@@ -1667,23 +1856,23 @@ class StackedBarChart extends Chart {
1667
1856
  // GroupedBarChart — series side by side in each category.
1668
1857
 
1669
1858
 
1670
-
1671
1859
  class GroupedBarChart extends Chart {
1672
- get _kind() { return 'Grouped bar chart'; } // aria-label default (bundlers minify class names)
1860
+ static get meta() {
1861
+ return { id: 'grouped-bar', name: 'GroupedBarChart', aria: 'Grouped bar chart', data: 'series', options: ['labelFormat', 'min', 'max'] };
1862
+ }
1673
1863
 
1674
- _legend() { return this._seriesLegend(this.options.series || []); }
1864
+ _legend() { return this._seriesLegend(this.options.data?.series || []); }
1675
1865
  _render() {
1676
1866
  const o = this.options;
1677
- const series = o.series || [];
1678
- const cats = o.categories || [];
1867
+ const { categories: cats = [], series = [] } = o.data || {};
1679
1868
  if (!series.length || !cats.length) return this._empty();
1680
1869
  const e = this.engine;
1681
1870
  const t = this.theme;
1682
1871
  const on = series.map((s, i) => i).filter((i) => !this._isOff(i));
1683
1872
 
1684
1873
  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 });
1874
+ const [yLo, yHi] = valueDomain(o, lo, hi);
1875
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: cats });
1687
1876
 
1688
1877
  const slot = ax.pw / cats.length;
1689
1878
  const inner = slot * 0.8;
@@ -1705,8 +1894,8 @@ class GroupedBarChart extends Chart {
1705
1894
  groups.get(color).push({ x, y, w, h, radius: t.barRadius });
1706
1895
  this.hits.add({
1707
1896
  _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) }] },
1897
+ tipX: x + barW / 2, tipY: y, index: i, label: cats[i], series: s, value: v, row: series[s],
1898
+ tooltip: { title: this._lab(cats[i]), rows: [{ color, name: series[s].name || 'Series ' + (s + 1), value: this._fmt(v) }] },
1710
1899
  });
1711
1900
  });
1712
1901
  }
@@ -1725,26 +1914,27 @@ class GroupedBarChart extends Chart {
1725
1914
 
1726
1915
 
1727
1916
  class MultiLineChart extends Chart {
1728
- get _kind() { return 'Multi line chart'; } // aria-label default (bundlers minify class names)
1917
+ static get meta() {
1918
+ return { id: 'multi-line', name: 'MultiLineChart', aria: 'Multi line chart', data: 'series', options: ['labelFormat', 'min', 'max', 'curve', 'xScale'], enums: { curve: ['smooth', 'linear', 'step'], xScale: ['category', 'time'] } };
1919
+ }
1729
1920
  constructor(container, options) {
1730
1921
  super(container, options);
1731
1922
  this._follow = true;
1732
1923
  }
1733
- _legend() { return this._seriesLegend(this.options.series || []); }
1924
+ _legend() { return this._seriesLegend(this.options.data?.series || []); }
1734
1925
  _render() {
1735
1926
  const o = this.options;
1736
- const series = o.series || [];
1737
- const cats = o.categories || [];
1927
+ const { categories: cats = [], series = [] } = o.data || {};
1738
1928
  if (!series.length || !cats.length) return this._empty();
1739
1929
  const e = this.engine;
1740
1930
  const t = this.theme;
1741
- const interp = o.interpolation || 'smooth';
1742
- const on = series.map((s, i) => i).filter((i) => !this._isOff(i) && !series[i].hidden);
1931
+ const interp = o.curve || 'smooth';
1932
+ const on = series.map((s, i) => i).filter((i) => !this._isOff(i));
1743
1933
 
1744
1934
  const [lo, hi] = range(on.map((s) => series[s].values), 0);
1745
- const [yLo, yHi] = M.niceDomain(lo, hi, 5);
1935
+ const [yLo, yHi] = valueDomain(o, lo, hi);
1746
1936
  const useTime = o.xScale === 'time';
1747
- const spec = { yMin: yLo, yMax: yHi, yTickFormat: o.yTickFormat, xTickFormat: o.xTickFormat };
1937
+ const spec = { yMin: yLo, yMax: yHi };
1748
1938
  if (useTime) spec.xTime = { min: +cats[0], max: +cats[cats.length - 1] };
1749
1939
  else spec.xLabels = cats;
1750
1940
  const ax = drawCartesian(this, spec);
@@ -1762,7 +1952,7 @@ class MultiLineChart extends Chart {
1762
1952
  all.push({ pts, color, s, ser });
1763
1953
  if (interp === 'smooth') e.smooth(pts, M.bezierControlPoints(pts), { stroke: color, lineWidth: lineW });
1764
1954
  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 });
1955
+ else e.step(pts, 'before', { stroke: color, lineWidth: lineW });
1766
1956
  if (pts.length <= 50) e.circles(pts, color, t.pointRadius);
1767
1957
  }
1768
1958
  e.unclip();
@@ -1785,9 +1975,9 @@ class MultiLineChart extends Chart {
1785
1975
  e.circle(p.x, p.y, t.pointRadius + 2, { fill: t.bg, stroke: sp.color, lineWidth: 2 });
1786
1976
  rows.push({ color: sp.color, name: sp.ser.name || 'Series ' + (sp.s + 1), value: this._fmt(+sp.ser.values[idx]) });
1787
1977
  }
1788
- const title = useTime ? new Date(+cats[idx]).toLocaleString() : String(cats[idx]);
1978
+ const title = this._lab(cats[idx], useTime);
1789
1979
  this._tipNow = { title, rows };
1790
- this._hover = { _id: idx, index: idx };
1980
+ this._hover = { _id: idx, index: idx, label: cats[idx] };
1791
1981
  this.tooltip.show(xPx, ax.py + 8, this._tipNow, t);
1792
1982
  } else if (this._cursor) {
1793
1983
  this.tooltip.hide();
@@ -1796,7 +1986,8 @@ class MultiLineChart extends Chart {
1796
1986
  }
1797
1987
 
1798
1988
  // ── src/charts/scatter.js
1799
- // ScatterChart — points batched per color/radius; `group` picks the color.
1989
+ // ScatterChart — points [{ x, y, size?, group?, label?, color? }], batched per
1990
+ // color/radius; `group` picks the color, `size` is the radius in px.
1800
1991
 
1801
1992
 
1802
1993
 
@@ -1812,7 +2003,9 @@ function xyExtent(data) {
1812
2003
  }
1813
2004
 
1814
2005
  class ScatterChart extends Chart {
1815
- get _kind() { return 'Scatter chart'; } // aria-label default (bundlers minify class names)
2006
+ static get meta() {
2007
+ return { id: 'scatter', name: 'ScatterChart', aria: 'Scatter chart', data: 'points', options: ['labelFormat', 'min', 'max'] };
2008
+ }
1816
2009
 
1817
2010
  _render() {
1818
2011
  const o = this.options;
@@ -1822,10 +2015,9 @@ class ScatterChart extends Chart {
1822
2015
  const t = this.theme;
1823
2016
  const [xMin, xMax, yMin, yMax] = xyExtent(data);
1824
2017
  const [xLo, xHi] = M.niceDomain(xMin, xMax, 6);
1825
- const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
2018
+ const [yLo, yHi] = valueDomain(o, yMin, yMax);
1826
2019
  const ax = drawCartesian(this, {
1827
2020
  yMin: yLo, yMax: yHi, xLinear: { min: xLo, max: xHi },
1828
- yTickFormat: o.yTickFormat, xTickFormat: o.xTickFormat,
1829
2021
  });
1830
2022
  const prog = this._anim.progress;
1831
2023
  const groups = new Map();
@@ -1837,7 +2029,7 @@ class ScatterChart extends Chart {
1837
2029
  const d = data[i];
1838
2030
  const cx = ax.xToPx(d.x);
1839
2031
  const cy = ax.yToPx(d.y);
1840
- const r = (d.r || defaultR) * prog;
2032
+ const r = (d.size || defaultR) * prog;
1841
2033
  const color = d.color || this._colorOf(d.group != null ? d.group : 0);
1842
2034
  const key = color + ':' + r.toFixed(1);
1843
2035
  if (!groups.has(key)) groups.set(key, { color, r, items: [] });
@@ -1845,8 +2037,8 @@ class ScatterChart extends Chart {
1845
2037
  if (hitsOn) {
1846
2038
  this.hits.add({
1847
2039
  _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}` : ''}`,
2040
+ tipX: cx, tipY: cy - r, index: i, label: d.label, value: d.y, row: d,
2041
+ tooltip: `${d.label ? d.label + ' ' : ''}(${this._xFmt(d.x)}, ${this._fmt(d.y)})`,
1850
2042
  });
1851
2043
  }
1852
2044
  }
@@ -1861,13 +2053,16 @@ class ScatterChart extends Chart {
1861
2053
  }
1862
2054
 
1863
2055
  // ── src/charts/bubble.js
1864
- // BubbleChart — scatter where `z` sets the bubble area.
2056
+ // BubbleChart — scatter where `size` (a data value) sets the bubble area,
2057
+ // scaled into the `radius: [min, max]` range in px.
1865
2058
 
1866
2059
 
1867
2060
 
1868
2061
 
1869
2062
  class BubbleChart extends Chart {
1870
- get _kind() { return 'Bubble chart'; } // aria-label default (bundlers minify class names)
2063
+ static get meta() {
2064
+ return { id: 'bubble', name: 'BubbleChart', aria: 'Bubble chart', data: 'points', options: ['labelFormat', 'min', 'max', 'radius'] };
2065
+ }
1871
2066
 
1872
2067
  _render() {
1873
2068
  const o = this.options;
@@ -1877,38 +2072,38 @@ class BubbleChart extends Chart {
1877
2072
  const t = this.theme;
1878
2073
  const [xMin, xMax, yMin, yMax] = xyExtent(data);
1879
2074
  let zMax = 0;
1880
- for (const d of data) if ((d.z || 0) > zMax) zMax = d.z;
2075
+ for (const d of data) if ((d.size || 0) > zMax) zMax = d.size;
1881
2076
  // Pad the domain so the biggest bubbles stay inside the plot.
1882
2077
  const px = (xMax - xMin || 1) * 0.08, py = (yMax - yMin || 1) * 0.1;
1883
2078
  const [xLo, xHi] = M.niceDomain(xMin - px, xMax + px, 6);
1884
- const [yLo, yHi] = M.niceDomain(yMin - py, yMax + py, 5);
2079
+ const [yLo, yHi] = valueDomain(o, yMin - py, yMax + py);
1885
2080
  const ax = drawCartesian(this, {
1886
2081
  yMin: yLo, yMax: yHi, xLinear: { min: xLo, max: xHi },
1887
- yTickFormat: o.yTickFormat, xTickFormat: o.xTickFormat,
1888
2082
  });
1889
2083
  const prog = this._anim.progress;
1890
- const maxR = o.maxRadius || 32, minR = o.minRadius || 4;
2084
+ const [minR, maxR] = Array.isArray(o.radius) ? o.radius : [4, 32];
1891
2085
 
1892
2086
  for (let i = 0; i < data.length; i++) {
1893
2087
  const d = data[i];
1894
2088
  const cx = ax.xToPx(d.x);
1895
2089
  const cy = ax.yToPx(d.y);
1896
- const r = (minR + Math.sqrt((d.z || 0) / Math.max(1e-9, zMax)) * (maxR - minR)) * prog;
2090
+ const r = (minR + Math.sqrt((d.size || 0) / Math.max(1e-9, zMax)) * (maxR - minR)) * prog;
1897
2091
  const color = d.color || this._colorOf(d.group != null ? d.group : i);
1898
2092
  const hover = this._hover && this._hover._id === i;
1899
2093
  e.circle(cx, cy, r, { fill: M.colorAlpha(color, hover ? 0.75 : 0.5), stroke: color, lineWidth: 1.5 });
1900
2094
  this.hits.add({
1901
2095
  _id: i, x: cx - r, y: cy - r, w: r * 2, h: r * 2,
1902
2096
  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)}`,
2097
+ tipX: cx, tipY: cy - r, index: i, label: d.label, value: d.y, row: d,
2098
+ tooltip: `${d.label ? d.label + ' ' : ''}(${this._xFmt(d.x)}, ${this._fmt(d.y)}) · ${this._fmt(d.size)}`,
1905
2099
  });
1906
2100
  }
1907
2101
  }
1908
2102
  }
1909
2103
 
1910
2104
  // ── src/charts/pie.js
1911
- // PieChart — `innerRadius` turns it into a donut with a center label.
2105
+ // PieChart — `innerRadius` (0–0.9 of the radius) turns it into a donut with
2106
+ // a center label (`center`: text, or false for none).
1912
2107
  // Slices are hit-tested by angle and radius, and toggle from the legend.
1913
2108
 
1914
2109
 
@@ -1925,11 +2120,13 @@ function inSector(cx, cy, rIn, rOut, a0, a1) {
1925
2120
  }
1926
2121
 
1927
2122
  class PieChart extends Chart {
1928
- get _kind() { return 'Pie chart'; } // aria-label default (bundlers minify class names)
2123
+ static get meta() {
2124
+ return { id: 'pie', name: 'PieChart', aria: 'Pie chart', data: 'rows', options: ['label', 'value', 'innerRadius', 'center'] };
2125
+ }
1929
2126
 
1930
2127
  _legend() {
1931
2128
  const o = this.options;
1932
- const labelOf = o.label || ((d) => d.label);
2129
+ const labelOf = this._fn('label') || ((d) => d.label);
1933
2130
  return toRows(o.data).map((d, i) => ({ label: String(labelOf(d, i)), color: this._colorOf(i, d) }));
1934
2131
  }
1935
2132
  _render() {
@@ -1938,16 +2135,20 @@ class PieChart extends Chart {
1938
2135
  if (!data.length) return this._empty();
1939
2136
  const e = this.engine;
1940
2137
  const t = this.theme;
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)));
2138
+ const labelOf = this._fn('label') || ((d) => d.label);
2139
+ const valueOf = this._fn('value') || ((d) => d.value);
2140
+ const raw = data.map((d, i) => +valueOf(d, i) || 0);
2141
+ const neg = raw.findIndex((v) => v < 0);
2142
+ if (neg >= 0) warn(`PieChart: values can't be negative — "${labelOf(data[neg], neg)}" is ${raw[neg]}. It is drawn as 0.`);
2143
+ const vals = raw.map((v, i) => (this._isOff(i) ? 0 : Math.max(0, v)));
1944
2144
  const total = vals.reduce((a, v) => a + v, 0);
1945
2145
  if (total <= 0) return this._empty();
1946
2146
 
1947
2147
  const cx = e.w / 2, cy = e.h / 2;
1948
2148
  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;
2149
+ const ir = +o.innerRadius || 0;
2150
+ if (ir > 0.9) warn(`PieChart: innerRadius is a ratio from 0 to 0.9 (0.6 = donut) — got ${ir}.`);
2151
+ const innerR = R * Math.min(0.9, Math.max(0, ir));
1951
2152
  const prog = this._anim.progress;
1952
2153
  let a0 = -Math.PI / 2;
1953
2154
  for (let i = 0; i < data.length; i++) {
@@ -1967,14 +2168,14 @@ class PieChart extends Chart {
1967
2168
  _id: i, x: cx - R, y: cy - R, w: R * 2, h: R * 2,
1968
2169
  test: inSector(cx, cy, innerR, R + off, a0, a1),
1969
2170
  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)}%)`,
2171
+ index: i, label: labelOf(data[i], i), value: v, row: data[i],
2172
+ tooltip: `${this._lab(labelOf(data[i], i))}: ${this._fmt(v)} (${((v / total) * 100).toFixed(1)}%)`,
1972
2173
  });
1973
2174
  a0 = a1;
1974
2175
  }
1975
- if (innerR > 0 && o.centerLabel !== false) {
2176
+ if (innerR > 0 && o.center !== false) {
1976
2177
  const h = this._hover;
1977
- const center = h ? `${((vals[h._id] / total) * 100).toFixed(0)}%` : o.totalLabel || this._fmt(total);
2178
+ const center = h ? `${((vals[h._id] / total) * 100).toFixed(0)}%` : o.center ?? this._fmt(total);
1978
2179
  e.text(center, cx, cy, {
1979
2180
  fill: t.text, font: `600 ${Math.max(12, Math.min(t.fontSize * 1.8, innerR / 2.2))}px ${t.font}`,
1980
2181
  align: 'center', baseline: 'middle',
@@ -1990,19 +2191,21 @@ class PieChart extends Chart {
1990
2191
 
1991
2192
 
1992
2193
  class RadarChart extends Chart {
1993
- get _kind() { return 'Radar chart'; } // aria-label default (bundlers minify class names)
2194
+ static get meta() {
2195
+ return { id: 'radar', name: 'RadarChart', aria: 'Radar chart', data: 'series', options: ['labelFormat', 'max'] };
2196
+ }
1994
2197
 
1995
- _legend() { return this._seriesLegend(this.options.series || []); }
2198
+ _legend() { return this._seriesLegend(this.options.data?.series || []); }
1996
2199
  _render() {
1997
2200
  const o = this.options;
1998
- const series = o.series || [];
1999
- const axes = o.axes || [];
2201
+ const { categories: axes = [], series = [] } = o.data || {};
2000
2202
  if (!series.length || !axes.length) return this._empty();
2001
2203
  const e = this.engine;
2002
2204
  const t = this.theme;
2003
2205
  const font = `${t.fontSize}px ${t.font}`;
2004
2206
  let labelW = 0;
2005
- for (const a of axes) labelW = Math.max(labelW, e.measure(String(a), font));
2207
+ const names = axes.map((a) => this._lab(a));
2208
+ for (const a of names) labelW = Math.max(labelW, e.measure(a, font));
2006
2209
  const cx = e.w / 2;
2007
2210
  const cy = e.h / 2;
2008
2211
  const R = Math.max(10, Math.min(e.w / 2 - labelW - 18, e.h / 2 - t.fontSize - 16));
@@ -2010,7 +2213,7 @@ class RadarChart extends Chart {
2010
2213
  const angle = (i) => -Math.PI / 2 + (i / N) * TAU;
2011
2214
  const on = series.map((s, i) => i).filter((i) => !this._isOff(i));
2012
2215
  const max = range(on.map((s) => series[s].values), 0)[1];
2013
- const niceMax = M.niceDomain(0, max, 5)[1] || 1;
2216
+ const niceMax = o.max ?? (M.niceDomain(0, max, 5)[1] || 1);
2014
2217
 
2015
2218
  const ctx = e.ctx;
2016
2219
  ctx.strokeStyle = t.grid;
@@ -2035,7 +2238,7 @@ class RadarChart extends Chart {
2035
2238
  ctx.stroke();
2036
2239
  for (let i = 0; i < N; i++) {
2037
2240
  const a = angle(i);
2038
- e.text(String(axes[i]), cx + Math.cos(a) * (R + 10), cy + Math.sin(a) * (R + 10), {
2241
+ e.text(names[i], cx + Math.cos(a) * (R + 10), cy + Math.sin(a) * (R + 10), {
2039
2242
  fill: t.muted, font,
2040
2243
  align: Math.cos(a) > 0.3 ? 'left' : Math.cos(a) < -0.3 ? 'right' : 'center',
2041
2244
  baseline: Math.sin(a) > 0.3 ? 'top' : Math.sin(a) < -0.3 ? 'bottom' : 'middle',
@@ -2048,14 +2251,14 @@ class RadarChart extends Chart {
2048
2251
  const pts = [];
2049
2252
  for (let i = 0; i < N; i++) {
2050
2253
  const v = +ser.values[i] || 0;
2051
- const r = ((v * prog) / niceMax) * R;
2254
+ const r = Math.min(1, (v * prog) / niceMax) * R;
2052
2255
  const a = angle(i);
2053
2256
  const p = { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r };
2054
2257
  pts.push(p);
2055
2258
  this.hits.add({
2056
2259
  _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) }] },
2260
+ tipX: p.x, tipY: p.y - 4, index: i, label: axes[i], series: s, value: v, row: ser,
2261
+ tooltip: { title: names[i], rows: [{ color, name: ser.name || 'Series ' + (s + 1), value: this._fmt(v) }] },
2059
2262
  });
2060
2263
  }
2061
2264
  e.poly(pts, { fill: M.colorAlpha(color, 0.2), stroke: color, lineWidth: 2, close: true, join: 'round' });
@@ -2074,7 +2277,9 @@ class RadarChart extends Chart {
2074
2277
 
2075
2278
 
2076
2279
  class PolarChart extends Chart {
2077
- get _kind() { return 'Polar chart'; } // aria-label default (bundlers minify class names)
2280
+ static get meta() {
2281
+ return { id: 'polar', name: 'PolarChart', aria: 'Polar chart', data: 'rows', options: ['label', 'value', 'scale'], enums: { scale: ['sqrt', 'linear'] } };
2282
+ }
2078
2283
 
2079
2284
  _render() {
2080
2285
  const o = this.options;
@@ -2082,8 +2287,8 @@ class PolarChart extends Chart {
2082
2287
  if (!data.length) return this._empty();
2083
2288
  const e = this.engine;
2084
2289
  const t = this.theme;
2085
- const labelOf = o.label || ((d) => d.label);
2086
- const valueOf = o.value || ((d) => d.value);
2290
+ const labelOf = this._fn('label') || ((d) => d.label);
2291
+ const valueOf = this._fn('value') || ((d) => d.value);
2087
2292
  const N = data.length;
2088
2293
  const font = `${t.fontSize}px ${t.font}`;
2089
2294
  const cx = e.w / 2, cy = e.h / 2;
@@ -2114,7 +2319,7 @@ class PolarChart extends Chart {
2114
2319
  _id: i, x: cx - R, y: cy - R, w: R * 2, h: R * 2,
2115
2320
  test: inSector(cx, cy, 0, Math.max(r, 12), a0, a1),
2116
2321
  tipX: cx + c * r * 0.7, tipY: cy + s * r * 0.7 - 6,
2117
- data: data[i], index: i, value: v,
2322
+ index: i, label: labelOf(data[i], i), value: v, row: data[i],
2118
2323
  tooltip: `${labelOf(data[i], i)}: ${this._fmt(v)}`,
2119
2324
  });
2120
2325
  }
@@ -2122,21 +2327,26 @@ class PolarChart extends Chart {
2122
2327
  }
2123
2328
 
2124
2329
  // ── src/charts/gauge.js
2125
- // GaugeChart — animated arc; setValue(v) tweens from the current value.
2330
+ // GaugeChart — animated arc; update({ data }) tweens from the shown value.
2126
2331
 
2127
2332
  class GaugeChart extends Chart {
2128
- get _kind() { return 'Gauge chart'; } // aria-label default (bundlers minify class names)
2129
- setValue(v) {
2130
- this._from = this._shown ?? 0;
2131
- return this.update({ value: v });
2333
+ static get meta() {
2334
+ return {
2335
+ id: 'gauge', name: 'GaugeChart', aria: 'Gauge', data: 'number', options: ['min', 'max', 'unit'],
2336
+ renamed: { value: 'data', label: 'title' },
2337
+ };
2338
+ }
2339
+ update(patch = {}) {
2340
+ if ('data' in patch) this._from = this._shown;
2341
+ return super.update(patch);
2132
2342
  }
2133
2343
  _render() {
2134
2344
  const e = this.engine;
2135
2345
  const t = this.theme;
2136
2346
  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));
2347
+ const min = o.min ?? 0;
2348
+ const max = o.max ?? 100;
2349
+ const target = Math.max(min, Math.min(max, +o.data || 0));
2140
2350
  const from = this._from ?? min;
2141
2351
  const val = from + (target - from) * this._anim.progress;
2142
2352
  this._shown = val;
@@ -2146,7 +2356,7 @@ class GaugeChart extends Chart {
2146
2356
  const r = R * 0.8;
2147
2357
  const a0 = Math.PI * 1.1;
2148
2358
  const a1 = Math.PI * 1.9;
2149
- const color = o.color || this.palette[0];
2359
+ const color = this.palette[0];
2150
2360
  e.arc(cx, cy, r, R, a0, a1, { fill: t.grid });
2151
2361
  const aEnd = a0 + ((a1 - a0) * (val - min)) / (max - min || 1);
2152
2362
  if (aEnd > a0) e.arc(cx, cy, r, R, a0, aEnd, { fill: color });
@@ -2154,66 +2364,70 @@ class GaugeChart extends Chart {
2154
2364
  e.text(txt, cx, cy - R * 0.08, {
2155
2365
  fill: t.text, font: `700 ${Math.max(12, R / 2.6)}px ${t.font}`, align: 'center', baseline: 'middle',
2156
2366
  });
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' });
2367
+ if (o.title) {
2368
+ e.text(o.title, cx, cy + R * 0.22, { fill: t.muted, font: `${t.fontSize}px ${t.font}`, align: 'center', baseline: 'top' });
2159
2369
  }
2160
- this.canvas.setAttribute('aria-label', `${o.ariaLabel || o.label || o.title || 'Gauge'}: ${txt}`);
2370
+ this.canvas.setAttribute('aria-label', `${o.title || 'Gauge'}: ${txt}`);
2161
2371
  }
2162
2372
  }
2163
2373
 
2164
2374
  // ── src/charts/heatmap.js
2165
- // HeatmapChart — cells colored between colorLow and colorHigh.
2375
+ // HeatmapChart — data { x: [...], y: [...], values: [[...]] } (values[y][x]),
2376
+ // cells colored between colors.low and colors.high.
2166
2377
 
2167
2378
 
2168
2379
  class HeatmapChart extends Chart {
2169
- get _kind() { return 'Heatmap chart'; } // aria-label default (bundlers minify class names)
2380
+ static get meta() {
2381
+ return { id: 'heatmap', name: 'HeatmapChart', aria: 'Heatmap', data: 'matrix', options: ['labelFormat'], colors: 'an object like { low: \'#eef\', high: \'#36c\' }' };
2382
+ }
2170
2383
 
2171
2384
  _render() {
2172
2385
  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();
2386
+ const { x: xLabels = [], y: yLabels = [], values = [] } = o.data || {};
2387
+ if (!values.length || !xLabels.length || !yLabels.length) return this._empty();
2177
2388
  const e = this.engine;
2178
2389
  const t = this.theme;
2179
2390
  const font = `${t.fontSize}px ${t.font}`;
2180
2391
  let labelW = 0;
2181
- for (const s of yLabels) labelW = Math.max(labelW, e.measure(String(s), font));
2392
+ const yText = Array.from(yLabels, (l) => this._lab(l));
2393
+ for (const s of yText) labelW = Math.max(labelW, e.measure(s, font));
2182
2394
  const px = Math.ceil(labelW) + 12, py = 8;
2183
2395
  const pw = Math.max(1, e.w - px - 8);
2184
2396
  const ph = Math.max(1, e.h - py - t.fontSize - 14);
2185
2397
 
2186
2398
  let vMax = -Infinity, vMin = Infinity;
2187
- for (const d of data) {
2188
- if (d.value > vMax) vMax = d.value;
2189
- if (d.value < vMin) vMin = d.value;
2399
+ for (const row of values) {
2400
+ for (const v of row || []) {
2401
+ if (v > vMax) vMax = v;
2402
+ if (v < vMin) vMin = v;
2403
+ }
2190
2404
  }
2191
2405
  const cellW = pw / xLabels.length;
2192
2406
  const cellH = ph / yLabels.length;
2193
- const lo = o.colorLow || M.colorAlpha(this.palette[0], 0.08);
2194
- const hi = o.colorHigh || this.palette[0];
2407
+ const c = o.colors || {};
2408
+ const hi = c.high || this.palette[0];
2409
+ const lo = c.low || M.colorAlpha(hi, 0.08);
2195
2410
  const gap = cellW > 6 && cellH > 6 ? 1 : 0;
2196
2411
  const prog = this._anim.progress;
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 });
2209
- this.hits.add({
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)}`,
2212
- });
2412
+ for (let cy = 0; cy < yLabels.length; cy++) {
2413
+ const row = values[cy] || [];
2414
+ for (let cx = 0; cx < xLabels.length; cx++) {
2415
+ const v = row[cx];
2416
+ if (v == null || v !== v) continue;
2417
+ const x = px + cx * cellW;
2418
+ const y = py + cy * cellH;
2419
+ const n = vMax > vMin ? (v - vMin) / (vMax - vMin) : 0.5;
2420
+ e.rect(x + gap, y + gap, cellW - gap * 2, cellH - gap * 2, { fill: M.colorMix(lo, hi, n * prog), radius: gap ? 2 : 0 });
2421
+ this.hits.add({
2422
+ _id: cy * xLabels.length + cx, x, y, w: cellW, h: cellH, tipX: x + cellW / 2, tipY: y,
2423
+ index: cx, series: cy, label: xLabels[cx], value: v, row: { x: xLabels[cx], y: yLabels[cy], value: v },
2424
+ tooltip: `${this._lab(xLabels[cx])} · ${yText[cy]}: ${this._fmt(v)}`,
2425
+ });
2426
+ }
2213
2427
  }
2214
2428
  let lastEnd = -Infinity;
2215
2429
  for (let i = 0; i < xLabels.length; i++) {
2216
- const txt = String(xLabels[i]);
2430
+ const txt = this._lab(xLabels[i]);
2217
2431
  const w = e.measure(txt, font);
2218
2432
  const x = px + (i + 0.5) * cellW;
2219
2433
  if (x - w / 2 < lastEnd + 4) continue;
@@ -2222,7 +2436,7 @@ class HeatmapChart extends Chart {
2222
2436
  }
2223
2437
  const step = Math.ceil((t.fontSize + 2) / cellH);
2224
2438
  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' });
2439
+ e.text(yText[i], px - 6, py + (i + 0.5) * cellH, { fill: t.muted, font, align: 'right', baseline: 'middle' });
2226
2440
  }
2227
2441
  if (this._hover) {
2228
2442
  const r = this._hover;
@@ -2237,7 +2451,9 @@ class HeatmapChart extends Chart {
2237
2451
 
2238
2452
 
2239
2453
  class BoxplotChart extends Chart {
2240
- get _kind() { return 'Boxplot chart'; } // aria-label default (bundlers minify class names)
2454
+ static get meta() {
2455
+ return { id: 'boxplot', name: 'BoxplotChart', aria: 'Box plot', data: 'boxes', options: ['labelFormat', 'min', 'max'] };
2456
+ }
2241
2457
 
2242
2458
  _render() {
2243
2459
  const o = this.options;
@@ -2253,8 +2469,8 @@ class BoxplotChart extends Chart {
2253
2469
  if (v < yMin) yMin = v;
2254
2470
  }
2255
2471
  }
2256
- const [yLo, yHi] = M.niceDomain(yMin, yMax, 5);
2257
- const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: data.map((d) => d.label), yTickFormat: o.yTickFormat });
2472
+ const [yLo, yHi] = valueDomain(o, yMin, yMax);
2473
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: data.map((d) => d.label) });
2258
2474
  const slot = ax.pw / data.length;
2259
2475
  const boxW = Math.min(40, slot * 0.5);
2260
2476
  const prog = this._anim.progress;
@@ -2277,9 +2493,9 @@ class BoxplotChart extends Chart {
2277
2493
  const top = ax.yToPx(d.max), bot = ax.yToPx(d.min);
2278
2494
  this.hits.add({
2279
2495
  _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,
2496
+ tipX: cx, tipY: ax.yToPx(d.q3), index: i, label: d.label, value: d.median, row: d,
2281
2497
  tooltip: {
2282
- title: String(d.label),
2498
+ title: this._lab(d.label),
2283
2499
  rows: [['max', d.max], ['Q3', d.q3], ['median', d.median], ['Q1', d.q1], ['min', d.min]]
2284
2500
  .map(([name, v]) => ({ name, value: this._fmt(v) })),
2285
2501
  },
@@ -2292,7 +2508,9 @@ class BoxplotChart extends Chart {
2292
2508
  // FunnelChart — conversion stages; each segment narrows to the next value.
2293
2509
 
2294
2510
  class FunnelChart extends Chart {
2295
- get _kind() { return 'Funnel chart'; } // aria-label default (bundlers minify class names)
2511
+ static get meta() {
2512
+ return { id: 'funnel', name: 'FunnelChart', aria: 'Funnel chart', data: 'rows', options: ['label', 'value', 'labelColor'] };
2513
+ }
2296
2514
 
2297
2515
  _render() {
2298
2516
  const o = this.options;
@@ -2300,8 +2518,8 @@ class FunnelChart extends Chart {
2300
2518
  if (!data.length) return this._empty();
2301
2519
  const e = this.engine;
2302
2520
  const t = this.theme;
2303
- const labelOf = o.label || ((d) => d.label);
2304
- const valueOf = o.value || ((d) => d.value);
2521
+ const labelOf = this._fn('label') || ((d) => d.label);
2522
+ const valueOf = this._fn('value') || ((d) => d.value);
2305
2523
  const N = data.length;
2306
2524
  const vals = data.map((d, i) => Math.max(0, +valueOf(d, i) || 0));
2307
2525
  const max = Math.max(1e-9, vals.reduce((a, v) => (v > a ? v : a), 0));
@@ -2334,7 +2552,7 @@ class FunnelChart extends Chart {
2334
2552
  });
2335
2553
  this.hits.add({
2336
2554
  _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,
2555
+ tipX: cx, tipY: yTop, index: i, label: labelOf(data[i], i), value: v, row: data[i],
2338
2556
  tooltip: `${labelOf(data[i], i)}: ${this._fmt(v)} (${((v / first) * 100).toFixed(1)}%)`,
2339
2557
  });
2340
2558
  }
@@ -2381,7 +2599,9 @@ function squarify(items, rect, place) {
2381
2599
  }
2382
2600
 
2383
2601
  class TreemapChart extends Chart {
2384
- get _kind() { return 'Treemap chart'; } // aria-label default (bundlers minify class names)
2602
+ static get meta() {
2603
+ return { id: 'treemap', name: 'TreemapChart', aria: 'Treemap', data: 'rows', options: ['label', 'value', 'labelColor'] };
2604
+ }
2385
2605
 
2386
2606
  _render() {
2387
2607
  const o = this.options;
@@ -2389,10 +2609,10 @@ class TreemapChart extends Chart {
2389
2609
  if (!data.length) return this._empty();
2390
2610
  const e = this.engine;
2391
2611
  const t = this.theme;
2392
- const labelOf = o.label || ((d) => d.label);
2393
- const valueOf = o.value || ((d) => d.value);
2612
+ const labelOf = this._fn('label') || ((d) => d.label);
2613
+ const valueOf = this._fn('value') || ((d) => d.value);
2394
2614
  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 }))
2615
+ .map((d, i) => ({ raw: labelOf(d, i), label: String(labelOf(d, i)), value: Math.max(0, +valueOf(d, i) || 0), color: this._colorOf(i, d), data: d, _id: i }))
2396
2616
  .filter((it) => it.value > 0)
2397
2617
  .sort((a, b) => b.value - a.value);
2398
2618
  if (!items.length) return this._empty();
@@ -2412,7 +2632,7 @@ class TreemapChart extends Chart {
2412
2632
  e.withAlpha(0.85, () => e.text(this._fmt(it.value), x + 6, y + 8 + t.fontSize, { fill: fg, font: `${t.fontSize}px ${t.font}`, align: 'left', baseline: 'top' }));
2413
2633
  }
2414
2634
  this.hits.add({
2415
- _id: it._id, x, y, w, h, tipX: x + w / 2, tipY: y, data: it.data, index: it._id, value: it.value,
2635
+ _id: it._id, x, y, w, h, tipX: x + w / 2, tipY: y, index: it._id, label: it.raw, value: it.value, row: it.data,
2416
2636
  tooltip: `${it.label}: ${this._fmt(it.value)}`,
2417
2637
  });
2418
2638
  });
@@ -2420,22 +2640,26 @@ class TreemapChart extends Chart {
2420
2640
  }
2421
2641
 
2422
2642
  // ── src/charts/sparkline.js
2423
- // Sparkline — tiny trend line, no axes. Accepts numbers or rows.
2643
+ // SparklineChart — tiny trend line, no axes. Same data as LineChart.
2424
2644
 
2425
2645
 
2426
2646
 
2427
- class Sparkline extends Chart {
2428
- get _kind() { return 'Sparkline'; } // aria-label default (bundlers minify class names)
2647
+ class SparklineChart extends Chart {
2648
+ static get meta() {
2649
+ return { id: 'sparkline', name: 'SparklineChart', aria: 'Sparkline', data: 'rows', options: ['label', 'value', 'min', 'max', 'fill', 'lastDot'] };
2650
+ }
2429
2651
 
2430
2652
  _render() {
2431
2653
  const o = this.options;
2432
- const data = o.data || [];
2654
+ const data = toRows(o.data);
2433
2655
  if (!data.length) return;
2434
2656
  const e = this.engine;
2435
2657
  const t = this.theme;
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]);
2658
+ const valueOf = this._fn('value') || ((d) => d.value);
2659
+ const labelOf = this._fn('label') || ((d) => d.label);
2660
+ const values = data.map((d, i) => +valueOf(d, i));
2661
+ const [lo, hi] = range([values]);
2662
+ const yMin = o.min ?? lo, yMax = o.max ?? hi;
2439
2663
  const yRange = yMax - yMin || 1;
2440
2664
  const pad = Math.max(2, t.pointRadius + 1);
2441
2665
  const w = e.w - pad * 2;
@@ -2444,7 +2668,7 @@ class Sparkline extends Chart {
2444
2668
  x: pad + (i / (values.length - 1 || 1)) * w,
2445
2669
  y: pad + h - ((v - yMin) / yRange) * h,
2446
2670
  }));
2447
- const color = o.color || this.palette[0];
2671
+ const color = this.palette[0];
2448
2672
  const cps = M.bezierControlPoints(points);
2449
2673
  e.clip(0, 0, e.w * this._anim.progress, e.h);
2450
2674
  if (o.fill !== false) {
@@ -2461,7 +2685,8 @@ class Sparkline extends Chart {
2461
2685
  points.forEach((p, i) => {
2462
2686
  const l = i ? (points[i - 1].x + p.x) / 2 : 0;
2463
2687
  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]) });
2688
+ const label = labelOf(data[i], i);
2689
+ this.hits.add({ _id: i, x: l, y: 0, w: r - l, h: e.h, tipX: p.x, tipY: p.y - 4, index: i, label, value: values[i], row: data[i], tooltip: `${label}: ${this._fmt(values[i])}` });
2465
2690
  });
2466
2691
  if (this._hover) {
2467
2692
  const p = points[this._hover._id];
@@ -2471,23 +2696,34 @@ class Sparkline extends Chart {
2471
2696
  }
2472
2697
 
2473
2698
  // ── src/charts/realtime.js
2474
- // RealtimeChart — streaming lines over a rolling window: push() a sample.
2475
-
2699
+ // RealtimeChart — streaming lines over a rolling window.
2700
+ // data: { series: [{ name, color? }] } then chart.push([v1, v2, …]) per sample
2476
2701
 
2477
2702
 
2478
2703
  class RealtimeChart extends Chart {
2479
- get _kind() { return 'Realtime chart'; } // aria-label default (bundlers minify class names)
2704
+ static get meta() {
2705
+ return { id: 'realtime', name: 'RealtimeChart', aria: 'Realtime chart', data: 'stream', options: ['min', 'max', 'window'] };
2706
+ }
2480
2707
  constructor(container, options = {}) {
2481
2708
  super(container, options);
2482
- this.options.windowSize = options.windowSize || 200;
2483
- this._series = (options.series || [{ name: 'Series' }]).map((s) => ({ name: s.name || 'Series', color: s.color, values: [] }));
2709
+ this._series = [];
2710
+ this._sync();
2484
2711
  this._follow = true;
2485
- this._streaming = true;
2712
+ }
2713
+ // (Re)build the series from data, keeping samples already received.
2714
+ _sync() {
2715
+ const list = this.options.data?.series || [{ name: 'Series' }];
2716
+ this._series = list.map((s, i) => ({ name: s.name || 'Series ' + (i + 1), color: s.color, values: this._series[i]?.values || [] }));
2717
+ }
2718
+ update(patch = {}) {
2719
+ super.update(patch);
2720
+ if ('data' in patch) this._sync();
2721
+ return this;
2486
2722
  }
2487
2723
  _legend() { return this._series.length > 1 ? this._seriesLegend(this._series) : null; }
2488
2724
  push(values) {
2489
2725
  const arr = Array.isArray(values) ? values : [values];
2490
- const N = this.options.windowSize;
2726
+ const N = this.options.window || 200;
2491
2727
  for (let i = 0; i < this._series.length; i++) {
2492
2728
  const v = this._series[i].values;
2493
2729
  v.push(arr[i] != null ? +arr[i] : NaN);
@@ -2496,13 +2732,13 @@ class RealtimeChart extends Chart {
2496
2732
  if (this._auto) { this._state = null; this._auto = false; }
2497
2733
  this._dirty = true;
2498
2734
  this._anim.progress = 1;
2499
- scheduler.schedule(this);
2735
+ schedule(this);
2500
2736
  return this;
2501
2737
  }
2502
2738
  clear() {
2503
2739
  for (const s of this._series) s.values.length = 0;
2504
2740
  this._dirty = true;
2505
- scheduler.schedule(this);
2741
+ schedule(this);
2506
2742
  return this;
2507
2743
  }
2508
2744
  _render() {
@@ -2512,16 +2748,16 @@ class RealtimeChart extends Chart {
2512
2748
  const on = this._series.map((s, i) => i).filter((i) => !this._isOff(i));
2513
2749
  const hasData = this._series.some((s) => s.values.length > 0);
2514
2750
  if (!hasData) {
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, {
2751
+ const ax = drawCartesian(this, { yMin: o.min ?? 0, yMax: o.max ?? 100 });
2752
+ e.text(o.emptyText || 'Waiting for data…', ax.px + ax.pw / 2, ax.py + ax.ph / 2, {
2517
2753
  fill: t.muted, font: `${t.fontSize}px ${t.font}`, align: 'center', baseline: 'middle',
2518
2754
  });
2519
2755
  return;
2520
2756
  }
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 });
2757
+ const [lo, hi] = range(on.map((s) => this._series[s].values.filter((v) => v === v)), 0);
2758
+ const [yLo, yHi] = valueDomain(o, lo, hi);
2759
+ const N = o.window || 200;
2760
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi });
2525
2761
  const xAt = (i, len) => ax.px + ((N - len + i) / (N - 1)) * ax.pw; // newest sample on the right edge
2526
2762
  const len = this._series[0].values.length;
2527
2763
  this._steps = [];
@@ -2554,41 +2790,45 @@ class RealtimeChart extends Chart {
2554
2790
  const x = this._steps[k];
2555
2791
  e.poly([{ x, y: ax.py }, { x, y: ax.py + ax.ph }], { stroke: t.muted, lineWidth: 1, dash: [3, 3] });
2556
2792
  this._tipNow = { title: `#${k + 1 - len}`, rows };
2793
+ this._hover = { _id: k, index: k, label: k + 1 - len };
2557
2794
  this.tooltip.show(x, ax.py + 8, this._tipNow, t);
2558
2795
  }
2559
2796
  }
2560
2797
  }
2561
2798
 
2562
2799
  // ── src/charts/waterfall.js
2563
- // WaterfallChart — running total; rows with isTotal reset the bar to zero.
2564
-
2800
+ // WaterfallChart — running total. A row with total: true is drawn from zero to
2801
+ // its value (a subtotal or the end result). Colors: { up, down, total }.
2565
2802
 
2566
2803
 
2567
2804
  class WaterfallChart extends Chart {
2568
- get _kind() { return 'Waterfall chart'; } // aria-label default (bundlers minify class names)
2805
+ static get meta() {
2806
+ return { id: 'waterfall', name: 'WaterfallChart', aria: 'Waterfall chart', data: 'steps', options: ['labelFormat', 'min', 'max'], colors: 'an object like { up, down, total }' };
2807
+ }
2569
2808
 
2570
2809
  _render() {
2571
2810
  const o = this.options;
2572
- const data = toRows(o.data);
2811
+ const data = Array.isArray(o.data) ? o.data : [];
2573
2812
  if (!data.length) return this._empty();
2574
2813
  const e = this.engine;
2575
2814
  const t = this.theme;
2576
2815
  let cum = 0;
2577
2816
  const segs = data.map((d) => {
2578
2817
  const v = +d.value || 0;
2579
- const seg = d.isTotal ? { from: 0, to: v } : { from: cum, to: cum + v };
2818
+ const seg = d.total ? { from: 0, to: v } : { from: cum, to: cum + v };
2580
2819
  cum = seg.to;
2581
- return { ...seg, value: v, isTotal: !!d.isTotal, label: d.label };
2820
+ return { ...seg, value: v, total: !!d.total, label: d.label };
2582
2821
  });
2583
2822
  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 });
2823
+ const [yLo, yHi] = valueDomain(o, lo, hi);
2824
+ const ax = drawCartesian(this, { yMin: yLo, yMax: yHi, xLabels: segs.map((s) => s.label) });
2586
2825
  const slot = ax.pw / segs.length;
2587
2826
  const barW = Math.max(1, Math.min(60, slot * 0.7));
2588
2827
  const prog = this._anim.progress;
2589
- const up = o.colorUp || '#10b981';
2590
- const down = o.colorDown || '#ef4444';
2591
- const total = o.colorTotal || '#6366f1';
2828
+ const c = o.colors || {};
2829
+ const up = c.up || '#10b981';
2830
+ const down = c.down || '#ef4444';
2831
+ const total = c.total || '#6366f1';
2592
2832
 
2593
2833
  for (let i = 0; i < segs.length; i++) {
2594
2834
  const s = segs[i];
@@ -2596,15 +2836,15 @@ class WaterfallChart extends Chart {
2596
2836
  const y0 = ax.yToPx(s.from);
2597
2837
  const h = Math.abs(ax.yToPx(s.to) - y0) * prog;
2598
2838
  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 });
2839
+ e.rect(x, top, barW, Math.max(h, 1), { fill: s.total ? total : s.value >= 0 ? up : down, radius: t.barRadius });
2600
2840
  if (i < segs.length - 1 && prog === 1) {
2601
2841
  const yEnd = ax.yToPx(s.to);
2602
2842
  e.poly([{ x: x + barW, y: yEnd }, { x: x + slot, y: yEnd }], { stroke: t.muted, lineWidth: 1, dash: [3, 3] });
2603
2843
  }
2604
2844
  this.hits.add({
2605
2845
  _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)}`,
2846
+ index: i, label: s.label, value: s.value, row: data[i],
2847
+ tooltip: `${this._lab(s.label)}: ${s.value > 0 && !s.total ? '+' : ''}${this._fmt(s.value)}`,
2608
2848
  });
2609
2849
  }
2610
2850
  if (this._hover) {
@@ -2615,39 +2855,47 @@ class WaterfallChart extends Chart {
2615
2855
  }
2616
2856
 
2617
2857
  // ── 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').
2858
+ // CompositeChart — bars, areas, lines and dots on one category axis, with an
2859
+ // optional right y axis.
2620
2860
  //
2621
- // Layer: { type: 'bar'|'line'|'area'|'scatter', name, values, axis?, color?, radius? }
2861
+ // data: { categories, series: [{ type: 'bar'|'line'|'area'|'scatter', name,
2862
+ // values, axis?: 'right', format?, color? }] }
2863
+ // scatter values line up with categories like the others (null = no dot).
2622
2864
 
2623
2865
 
2624
2866
 
2867
+ const TYPES = ['bar', 'line', 'area', 'scatter'];
2868
+
2625
2869
  class CompositeChart extends Chart {
2626
- get _kind() { return 'Composite chart'; } // aria-label default (bundlers minify class names)
2870
+ static get meta() {
2871
+ return { id: 'composite', name: 'CompositeChart', aria: 'Composite chart', data: 'series', options: ['labelFormat', 'min', 'max', 'xScale'], enums: { xScale: ['category', 'time'] } };
2872
+ }
2627
2873
  constructor(container, options) {
2628
2874
  super(container, options);
2629
2875
  this._follow = true;
2630
2876
  }
2631
2877
  _legend() {
2632
- return (this.options.layers || []).map((l, i) => ({
2878
+ return (this.options.data?.series || []).map((l, i) => ({
2633
2879
  label: (l.name || l.type) + (l.axis === 'right' ? ' (R)' : ''),
2634
2880
  color: this._colorOf(i, l),
2635
2881
  }));
2636
2882
  }
2637
2883
  _render() {
2638
2884
  const o = this.options;
2639
- const all = o.layers || [];
2640
- const cats = o.categories || [];
2885
+ const { categories: cats = [], series: all = [] } = o.data || {};
2641
2886
  if (!all.length || !cats.length) return this._empty();
2642
2887
  const e = this.engine;
2643
2888
  const t = this.theme;
2889
+ all.forEach((l, i) => {
2890
+ if (!TYPES.includes(l.type)) warn(`CompositeChart: series[${i}] type must be ${TYPES.map((x) => `'${x}'`).join(' | ')} — got ${JSON.stringify(l.type)}.`);
2891
+ });
2644
2892
  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);
2646
2893
 
2647
2894
  const domain = (ll) => {
2648
2895
  let mn = 0, mx = -Infinity;
2649
2896
  for (const l of ll) for (const v of l.values) {
2650
- const y = yOf(l, v);
2897
+ if (v == null) continue;
2898
+ const y = +v;
2651
2899
  if (y < mn) mn = y;
2652
2900
  if (y > mx) mx = y;
2653
2901
  }
@@ -2655,15 +2903,18 @@ class CompositeChart extends Chart {
2655
2903
  };
2656
2904
  const left = layers.filter((l) => l.axis !== 'right');
2657
2905
  const rightL = layers.filter((l) => l.axis === 'right');
2658
- const [lLo, lHi] = domain(left);
2906
+ // min / max set the left axis; each axis uses the format of its first
2907
+ // series that has one.
2908
+ let [lLo, lHi] = domain(left);
2909
+ if (o.min != null || o.max != null) [lLo, lHi] = valueDomain(o, o.min ?? lLo, o.max ?? lHi);
2910
+ const fmtOf = (ll) => ll.find((l) => l.format)?.format;
2659
2911
  const useTime = o.xScale === 'time';
2660
2912
  const ax = drawCartesian(this, {
2661
2913
  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,
2914
+ yFormat: fmtOf(left),
2915
+ right: rightL.length ? (([a, b]) => ({ yMin: a, yMax: b, yFormat: fmtOf(rightL) }))(domain(rightL)) : null,
2664
2916
  xLabels: useTime ? null : cats,
2665
2917
  xTime: useTime ? { min: +cats[0], max: +cats[cats.length - 1] } : null,
2666
- xTickFormat: o.xTickFormat,
2667
2918
  });
2668
2919
  const yPx = (l) => (l.axis === 'right' && ax.yRToPx ? ax.yRToPx : ax.yToPx);
2669
2920
  const xAt = useTime
@@ -2708,8 +2959,9 @@ class CompositeChart extends Chart {
2708
2959
  e.unclip();
2709
2960
  for (const l of layers.filter((x) => x.type === 'scatter')) {
2710
2961
  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));
2962
+ const items = [];
2963
+ l.values.forEach((v, i) => { if (v != null) items.push({ x: xAt(i), y: toPx(+v) }); });
2964
+ e.withAlpha(0.8, () => e.circles(items, l.color, t.pointRadius + 1));
2713
2965
  }
2714
2966
 
2715
2967
  // Shared tooltip for the category under the pointer.
@@ -2722,16 +2974,12 @@ class CompositeChart extends Chart {
2722
2974
  e.withAlpha(0.06, () => e.rect(x - slot / 2, ax.py, slot, ax.ph, { fill: t.text }));
2723
2975
  const rows = [];
2724
2976
  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) });
2977
+ const v = l.values[idx];
2978
+ if (v == null) continue;
2979
+ rows.push({ color: l.color, name: l.name || l.type, value: M.formatNumber(+v, l.format ?? o.format) });
2732
2980
  }
2733
- this._tipNow = { title: useTime ? new Date(+cats[idx]).toLocaleString() : String(cats[idx]), rows };
2734
- this._hover = { _id: idx, index: idx };
2981
+ this._tipNow = { title: this._lab(cats[idx], useTime), rows };
2982
+ this._hover = { _id: idx, index: idx, label: cats[idx] };
2735
2983
  this.tooltip.show(x, ax.py + 8, this._tipNow, t);
2736
2984
  } else if (this._cursor) {
2737
2985
  this.tooltip.hide();
@@ -2740,11 +2988,10 @@ class CompositeChart extends Chart {
2740
2988
  }
2741
2989
 
2742
2990
  // ── 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).
2991
+ // @aeriz/chart 2.0 — public API.
2745
2992
  //
2746
2993
  // Every chart type is its own module, so bundlers keep only the ones you
2747
2994
  // import. Without a bundler, import one type directly:
2748
2995
  // import { BarChart } from '@aeriz/chart/bar';
2749
2996
 
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 };
2997
+ export { Chart, PALETTE, BarChart, HBarChart, LineChart, AreaChart, StackedBarChart, GroupedBarChart, MultiLineChart, ScatterChart, BubbleChart, PieChart, RadarChart, PolarChart, GaugeChart, HeatmapChart, BoxplotChart, FunnelChart, TreemapChart, SparklineChart, RealtimeChart, WaterfallChart, CompositeChart };