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