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