@svgrid/grid 1.0.2 → 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 +1 -1
- package/README.md +137 -39
- package/dist/GridFooter.svelte +164 -0
- package/dist/GridFooter.svelte.d.ts +29 -0
- package/dist/GridMenus.svelte +570 -0
- package/dist/GridMenus.svelte.d.ts +31 -0
- package/dist/SvGrid.controller.svelte.d.ts +429 -0
- package/dist/SvGrid.controller.svelte.js +1732 -0
- package/dist/SvGrid.css +1709 -0
- package/dist/SvGrid.helpers.d.ts +35 -0
- package/dist/SvGrid.helpers.js +160 -0
- package/dist/SvGrid.svelte +344 -7043
- package/dist/SvGrid.svelte.d.ts +4 -357
- package/dist/SvGrid.types.d.ts +436 -0
- package/dist/SvGrid.types.js +1 -0
- package/dist/SvGridChart.svelte +1060 -23
- package/dist/SvGridChart.svelte.d.ts +17 -0
- package/dist/build-api.d.ts +5 -0
- package/dist/build-api.js +527 -0
- package/dist/cell-render.d.ts +15 -0
- package/dist/cell-render.js +246 -0
- package/dist/cell-values.d.ts +28 -0
- package/dist/cell-values.js +89 -0
- package/dist/chart.d.ts +370 -3
- package/dist/chart.js +1135 -42
- package/dist/clipboard.d.ts +15 -0
- package/dist/clipboard.js +356 -0
- package/dist/columns.d.ts +30 -0
- package/dist/columns.js +277 -0
- package/dist/core.d.ts +1 -1
- package/dist/css.d.ts +3 -0
- package/dist/editing.d.ts +24 -0
- package/dist/editing.js +343 -0
- package/dist/editors/cell-editors.d.ts +1 -1
- package/dist/facet-buckets.d.ts +13 -0
- package/dist/facet-buckets.js +54 -0
- package/dist/features.d.ts +5 -0
- package/dist/features.js +30 -0
- package/dist/filter-operators.d.ts +16 -0
- package/dist/filter-operators.js +69 -0
- package/dist/hyperformula-adapter.d.ts +82 -0
- package/dist/hyperformula-adapter.js +73 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/keyboard-handlers.d.ts +7 -0
- package/dist/keyboard-handlers.js +197 -0
- package/dist/menus.d.ts +40 -0
- package/dist/menus.js +389 -0
- package/dist/named-views.d.ts +27 -0
- package/dist/named-views.js +39 -0
- package/dist/row-resize.d.ts +43 -0
- package/dist/row-resize.js +158 -0
- package/dist/scroll-sync.d.ts +9 -0
- package/dist/scroll-sync.js +86 -0
- package/dist/selection.d.ts +26 -0
- package/dist/selection.js +387 -0
- package/dist/spreadsheet.d.ts +80 -0
- package/dist/spreadsheet.js +194 -0
- package/dist/summaries.d.ts +12 -0
- package/dist/summaries.js +65 -0
- package/dist/svgrid-wrapper.types.d.ts +12 -1
- package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
- package/dist/svgrid.comments-autocomplete.test.js +96 -0
- package/dist/svgrid.context-menu.test.d.ts +1 -0
- package/dist/svgrid.context-menu.test.js +102 -0
- package/dist/svgrid.new-features.wrapper.test.js +30 -4
- package/dist/svgrid.wrapper.test.js +27 -1
- package/dist/virtualization/column-virtualizer.d.ts +2 -0
- package/dist/virtualization/scroll-scaling.d.ts +28 -0
- package/dist/virtualization/scroll-scaling.js +64 -0
- package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
- package/dist/virtualization/scroll-scaling.test.js +86 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
- package/dist/virtualization/virtualizer.d.ts +7 -0
- package/dist/virtualization/virtualizer.js +30 -0
- package/package.json +1 -1
- package/src/GridFooter.svelte +164 -0
- package/src/GridMenus.svelte +570 -0
- package/src/SvGrid.controller.svelte.ts +2195 -0
- package/src/SvGrid.css +1747 -0
- package/src/SvGrid.helpers.test.ts +415 -0
- package/src/SvGrid.helpers.ts +185 -0
- package/src/SvGrid.svelte +348 -7043
- package/src/SvGrid.types.ts +456 -0
- package/src/SvGridChart.svelte +1060 -23
- package/src/build-api.coverage.test.ts +532 -0
- package/src/build-api.ts +663 -0
- package/src/cell-render.test.ts +451 -0
- package/src/cell-render.ts +426 -0
- package/src/cell-values.ts +114 -0
- package/src/chart-export.test.ts +370 -0
- package/src/chart.coverage.test.ts +814 -0
- package/src/chart.ts +1352 -47
- package/src/clipboard.test.ts +731 -0
- package/src/clipboard.ts +524 -0
- package/src/collaboration.coverage.test.ts +220 -0
- package/src/columns.test.ts +702 -0
- package/src/columns.ts +419 -0
- package/src/core.ts +8 -0
- package/src/css.d.ts +3 -0
- package/src/editing.test.ts +837 -0
- package/src/editing.ts +513 -0
- package/src/editors/cell-editors.coverage.test.ts +156 -0
- package/src/editors/cell-editors.ts +1 -0
- package/src/facet-buckets.test.ts +353 -0
- package/src/facet-buckets.ts +67 -0
- package/src/features.ts +128 -0
- package/src/filter-operators.test.ts +174 -0
- package/src/filter-operators.ts +87 -0
- package/src/hyperformula-adapter.test.ts +256 -0
- package/src/hyperformula-adapter.ts +124 -0
- package/src/index.ts +37 -0
- package/src/keyboard-handlers.coverage.test.ts +560 -0
- package/src/keyboard-handlers.ts +353 -0
- package/src/keyboard.ts +97 -97
- package/src/menus.test.ts +620 -0
- package/src/menus.ts +554 -0
- package/src/named-views.coverage.test.ts +210 -0
- package/src/named-views.ts +48 -0
- package/src/row-resize.test.ts +369 -0
- package/src/row-resize.ts +171 -0
- package/src/scroll-sync.test.ts +330 -0
- package/src/scroll-sync.ts +216 -0
- package/src/selection.test.ts +722 -0
- package/src/selection.ts +545 -0
- package/src/server-data-source.coverage.test.ts +180 -0
- package/src/spreadsheet.test.ts +445 -0
- package/src/spreadsheet.ts +246 -0
- package/src/summaries.ts +204 -0
- package/src/sv-grid-scrollbar.ts +13 -1
- package/src/svgrid-wrapper.types.ts +12 -1
- package/src/svgrid.behavior.test.ts +22 -0
- package/src/svgrid.comments-autocomplete.test.ts +112 -0
- package/src/svgrid.context-menu.test.ts +126 -0
- package/src/svgrid.interaction.test.ts +30 -0
- package/src/svgrid.new-features.wrapper.test.ts +65 -4
- package/src/svgrid.wrapper.test.ts +27 -1
- package/src/test-setup.ts +9 -6
- package/src/virtualization/scroll-scaling.test.ts +148 -0
- package/src/virtualization/scroll-scaling.ts +121 -0
- package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
- package/src/virtualization/virtualizer.ts +26 -0
package/dist/chart.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Cycle used when `ChartSpec.patternFallback` is true and a series has no
|
|
2
|
+
* explicit `pattern` set. Skips `'solid'` so every series gets a texture. */
|
|
3
|
+
const PATTERN_CYCLE = ['stripe', 'crosshatch', 'dots', 'diagonal'];
|
|
1
4
|
export const DEFAULT_PALETTE = [
|
|
2
5
|
'#2563eb',
|
|
3
6
|
'#16a34a',
|
|
@@ -23,6 +26,261 @@ function niceNum(range, roundIt) {
|
|
|
23
26
|
nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10;
|
|
24
27
|
return nf * Math.pow(10, exp);
|
|
25
28
|
}
|
|
29
|
+
// ---- Color helpers for heatmap / pattern fills ----------------------
|
|
30
|
+
/** Built-in sequential ramp (light cyan -> deep blue), perception-friendly. */
|
|
31
|
+
const SEQUENTIAL_STOPS = ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1e3a8a'];
|
|
32
|
+
/** Built-in diverging ramp (red -> neutral -> blue). Use for signed data. */
|
|
33
|
+
const DIVERGING_STOPS = ['#b91c1c', '#fca5a5', '#f1f5f9', '#93c5fd', '#1d4ed8'];
|
|
34
|
+
/** Dark-theme ramps. The low (sequential) / neutral (diverging) end sits just
|
|
35
|
+
* above the dark grid surface instead of near-white, so empty / low cells read
|
|
36
|
+
* as "cold" rather than as glaring white rectangles. */
|
|
37
|
+
const SEQUENTIAL_STOPS_DARK = ['#1c2c4d', '#1d4ed8', '#3b82f6', '#60a5fa', '#bae6fd'];
|
|
38
|
+
const DIVERGING_STOPS_DARK = ['#f87171', '#b91c1c', '#222b3d', '#1d4ed8', '#60a5fa'];
|
|
39
|
+
function resolveColorScale(scale, vMin, vMax, theme = 'light') {
|
|
40
|
+
if (Array.isArray(scale) && scale.length >= 2)
|
|
41
|
+
return scale;
|
|
42
|
+
const dark = theme === 'dark';
|
|
43
|
+
if (scale === 'diverging' || (scale == null && vMin < 0 && vMax > 0)) {
|
|
44
|
+
return dark ? DIVERGING_STOPS_DARK : DIVERGING_STOPS;
|
|
45
|
+
}
|
|
46
|
+
return dark ? SEQUENTIAL_STOPS_DARK : SEQUENTIAL_STOPS;
|
|
47
|
+
}
|
|
48
|
+
/** Sample a hex color from an array of hex stops at fractional position t.
|
|
49
|
+
* Linearly interpolates between the two nearest stops in RGB space. */
|
|
50
|
+
export function sampleGradient(stops, t) {
|
|
51
|
+
if (!stops.length)
|
|
52
|
+
return '#888';
|
|
53
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
54
|
+
if (stops.length === 1)
|
|
55
|
+
return stops[0];
|
|
56
|
+
const pos = clamped * (stops.length - 1);
|
|
57
|
+
const i = Math.floor(pos);
|
|
58
|
+
const frac = pos - i;
|
|
59
|
+
const a = hexToRgb(stops[i]);
|
|
60
|
+
const b = hexToRgb(stops[Math.min(stops.length - 1, i + 1)]);
|
|
61
|
+
if (!a || !b)
|
|
62
|
+
return stops[i] ?? '#888';
|
|
63
|
+
const lerp = (x, y) => Math.round(x + (y - x) * frac);
|
|
64
|
+
const toHex = (n) => n.toString(16).padStart(2, '0');
|
|
65
|
+
return '#' + toHex(lerp(a.r, b.r)) + toHex(lerp(a.g, b.g)) + toHex(lerp(a.b, b.b));
|
|
66
|
+
}
|
|
67
|
+
function hexToRgb(hex) {
|
|
68
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
69
|
+
if (!m)
|
|
70
|
+
return null;
|
|
71
|
+
const n = parseInt(m[1], 16);
|
|
72
|
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
73
|
+
}
|
|
74
|
+
/** Pick a black or white text color that has the better contrast against
|
|
75
|
+
* the given background. Uses the WCAG relative-luminance heuristic. */
|
|
76
|
+
export function pickContrastText(bgHex) {
|
|
77
|
+
const rgb = hexToRgb(bgHex);
|
|
78
|
+
if (!rgb)
|
|
79
|
+
return '#0f172a';
|
|
80
|
+
const lin = (c) => {
|
|
81
|
+
const s = c / 255;
|
|
82
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
83
|
+
};
|
|
84
|
+
const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
|
|
85
|
+
return L > 0.5 ? '#0f172a' : '#ffffff';
|
|
86
|
+
}
|
|
87
|
+
/** Pick the largest power of 10 that fits at the bottom of [min,max], and
|
|
88
|
+
* the smallest that covers the top, then enumerate decade boundaries. Used
|
|
89
|
+
* by log-scale axes (yScale: 'log'). */
|
|
90
|
+
export function niceLogScale(min, max) {
|
|
91
|
+
// Only positive values are plottable on a log scale; callers should
|
|
92
|
+
// strip non-positive values before passing them in.
|
|
93
|
+
if (!Number.isFinite(min) || min <= 0)
|
|
94
|
+
min = 1;
|
|
95
|
+
if (!Number.isFinite(max) || max <= min)
|
|
96
|
+
max = min * 10;
|
|
97
|
+
const lo = Math.floor(Math.log10(min));
|
|
98
|
+
const hi = Math.ceil(Math.log10(max));
|
|
99
|
+
const ticks = [];
|
|
100
|
+
for (let p = lo; p <= hi; p += 1)
|
|
101
|
+
ticks.push(Math.pow(10, p));
|
|
102
|
+
return { min: Math.pow(10, lo), max: Math.pow(10, hi), step: 10, ticks };
|
|
103
|
+
}
|
|
104
|
+
/** Map a value to a fractional position [0..1] across the axis domain.
|
|
105
|
+
* Pass the appropriate fn into projection code so linear / log share the
|
|
106
|
+
* same plumbing. Returns null for non-positive values on log. */
|
|
107
|
+
function project(value, min, max, isLog) {
|
|
108
|
+
if (!Number.isFinite(value))
|
|
109
|
+
return null;
|
|
110
|
+
if (isLog) {
|
|
111
|
+
if (value <= 0 || min <= 0)
|
|
112
|
+
return null;
|
|
113
|
+
return (Math.log10(value) - Math.log10(min)) / (Math.log10(max) - Math.log10(min));
|
|
114
|
+
}
|
|
115
|
+
return (value - min) / (max - min);
|
|
116
|
+
}
|
|
117
|
+
// ---- Overlay math: trendline + moving averages -----------------------
|
|
118
|
+
/** Ordinary least-squares regression on (i, values[i]) pairs (i = x index).
|
|
119
|
+
* Returns the fitted value at each x index, or NaN where the source value
|
|
120
|
+
* was non-finite. */
|
|
121
|
+
/** Build an SVG path from a list of (x,y) pairs, optionally smoothed via
|
|
122
|
+
* monotone cubic interpolation (preserves local extrema - no overshoots).
|
|
123
|
+
* Breaks the path at `defined === false` gaps. */
|
|
124
|
+
export function buildLinePath(pts, smooth) {
|
|
125
|
+
if (!smooth) {
|
|
126
|
+
let path = '';
|
|
127
|
+
let pen = false;
|
|
128
|
+
for (const p of pts) {
|
|
129
|
+
if (!p.defined) {
|
|
130
|
+
pen = false;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
path += `${pen ? 'L' : 'M'}${p.x},${p.y} `;
|
|
134
|
+
pen = true;
|
|
135
|
+
}
|
|
136
|
+
return path.trim();
|
|
137
|
+
}
|
|
138
|
+
// Group defined-only runs; each run is smoothed independently.
|
|
139
|
+
const runs = [];
|
|
140
|
+
let cur = [];
|
|
141
|
+
for (const p of pts) {
|
|
142
|
+
if (p.defined)
|
|
143
|
+
cur.push({ x: p.x, y: p.y });
|
|
144
|
+
else if (cur.length) {
|
|
145
|
+
runs.push(cur);
|
|
146
|
+
cur = [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (cur.length)
|
|
150
|
+
runs.push(cur);
|
|
151
|
+
return runs.map(monotoneCubicPath).filter(Boolean).join(' ');
|
|
152
|
+
}
|
|
153
|
+
/** Fritsch-Carlson monotone cubic interpolation -> cubic-Bezier path.
|
|
154
|
+
* Slope at each point chosen so the curve passes through every (xi, yi)
|
|
155
|
+
* AND stays monotonic between them; control points sit 1/3 of the way
|
|
156
|
+
* to the neighbours along that tangent. */
|
|
157
|
+
function monotoneCubicPath(pts) {
|
|
158
|
+
const n = pts.length;
|
|
159
|
+
if (n === 0)
|
|
160
|
+
return '';
|
|
161
|
+
if (n === 1)
|
|
162
|
+
return `M${pts[0].x},${pts[0].y}`;
|
|
163
|
+
if (n === 2)
|
|
164
|
+
return `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y}`;
|
|
165
|
+
// Secant slopes between adjacent points.
|
|
166
|
+
const dx = new Array(n - 1);
|
|
167
|
+
const m = new Array(n - 1);
|
|
168
|
+
for (let i = 0; i < n - 1; i += 1) {
|
|
169
|
+
const d = pts[i + 1].x - pts[i].x;
|
|
170
|
+
dx[i] = d;
|
|
171
|
+
m[i] = d === 0 ? 0 : (pts[i + 1].y - pts[i].y) / d;
|
|
172
|
+
}
|
|
173
|
+
// Tangent at each point: average of neighbouring slopes, with sign
|
|
174
|
+
// checks that flatten the tangent when slopes change sign.
|
|
175
|
+
const tan = new Array(n);
|
|
176
|
+
tan[0] = m[0];
|
|
177
|
+
tan[n - 1] = m[n - 2];
|
|
178
|
+
for (let i = 1; i < n - 1; i += 1) {
|
|
179
|
+
if (m[i - 1] * m[i] <= 0)
|
|
180
|
+
tan[i] = 0;
|
|
181
|
+
else
|
|
182
|
+
tan[i] = (m[i - 1] + m[i]) / 2;
|
|
183
|
+
}
|
|
184
|
+
// Fritsch-Carlson correction: ensure |tan / m| <= 3 to stay monotonic.
|
|
185
|
+
for (let i = 0; i < n - 1; i += 1) {
|
|
186
|
+
if (m[i] === 0) {
|
|
187
|
+
tan[i] = 0;
|
|
188
|
+
tan[i + 1] = 0;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const a = tan[i] / m[i];
|
|
192
|
+
const b = tan[i + 1] / m[i];
|
|
193
|
+
const h = Math.hypot(a, b);
|
|
194
|
+
if (h > 3) {
|
|
195
|
+
tan[i] = (3 / h) * a * m[i];
|
|
196
|
+
tan[i + 1] = (3 / h) * b * m[i];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Build the Bezier path. Each segment: control points at 1/3 of dx.
|
|
200
|
+
let path = `M${pts[0].x},${pts[0].y}`;
|
|
201
|
+
for (let i = 0; i < n - 1; i += 1) {
|
|
202
|
+
const h = dx[i];
|
|
203
|
+
const c1x = pts[i].x + h / 3;
|
|
204
|
+
const c1y = pts[i].y + (tan[i] * h) / 3;
|
|
205
|
+
const c2x = pts[i + 1].x - h / 3;
|
|
206
|
+
const c2y = pts[i + 1].y - (tan[i + 1] * h) / 3;
|
|
207
|
+
path += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1].x},${pts[i + 1].y}`;
|
|
208
|
+
}
|
|
209
|
+
return path;
|
|
210
|
+
}
|
|
211
|
+
export function linearTrend(values) {
|
|
212
|
+
let n = 0, sumX = 0, sumY = 0, sumXX = 0, sumXY = 0;
|
|
213
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
214
|
+
const y = values[i];
|
|
215
|
+
if (!Number.isFinite(y))
|
|
216
|
+
continue;
|
|
217
|
+
n += 1;
|
|
218
|
+
sumX += i;
|
|
219
|
+
sumY += y;
|
|
220
|
+
sumXX += i * i;
|
|
221
|
+
sumXY += i * y;
|
|
222
|
+
}
|
|
223
|
+
if (n < 2)
|
|
224
|
+
return values.map(() => NaN);
|
|
225
|
+
const denom = n * sumXX - sumX * sumX;
|
|
226
|
+
if (denom === 0)
|
|
227
|
+
return values.map(() => sumY / n);
|
|
228
|
+
const slope = (n * sumXY - sumX * sumY) / denom;
|
|
229
|
+
const intercept = (sumY - slope * sumX) / n;
|
|
230
|
+
return values.map((_, i) => slope * i + intercept);
|
|
231
|
+
}
|
|
232
|
+
/** Simple moving average over a window of `period` values. Window centres
|
|
233
|
+
* trail to the right (typical for time-series). NaN for points before the
|
|
234
|
+
* window is full. */
|
|
235
|
+
export function simpleMovingAverage(values, period) {
|
|
236
|
+
if (period < 1)
|
|
237
|
+
return values.slice();
|
|
238
|
+
const out = new Array(values.length).fill(NaN);
|
|
239
|
+
let sum = 0, count = 0;
|
|
240
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
241
|
+
const v = values[i];
|
|
242
|
+
if (Number.isFinite(v)) {
|
|
243
|
+
sum += v;
|
|
244
|
+
count += 1;
|
|
245
|
+
}
|
|
246
|
+
if (i >= period) {
|
|
247
|
+
const drop = values[i - period];
|
|
248
|
+
if (Number.isFinite(drop)) {
|
|
249
|
+
sum -= drop;
|
|
250
|
+
count -= 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (i >= period - 1 && count > 0)
|
|
254
|
+
out[i] = sum / count;
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
/** Exponential moving average. Smoothing factor alpha = 2 / (period + 1). */
|
|
259
|
+
export function exponentialMovingAverage(values, period) {
|
|
260
|
+
const alpha = 2 / (Math.max(1, period) + 1);
|
|
261
|
+
const out = new Array(values.length).fill(NaN);
|
|
262
|
+
let prev = null;
|
|
263
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
264
|
+
const v = values[i];
|
|
265
|
+
if (!Number.isFinite(v)) {
|
|
266
|
+
out[i] = prev ?? NaN;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
prev = prev == null ? v : alpha * v + (1 - alpha) * prev;
|
|
270
|
+
out[i] = prev;
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
/** Compute overlay values for a series spec like 'sma:7' / 'ema:14' / 'linear'. */
|
|
275
|
+
export function computeOverlay(values, spec) {
|
|
276
|
+
if (spec === 'linear')
|
|
277
|
+
return linearTrend(values);
|
|
278
|
+
const m = /^(sma|ema):(\d+)$/.exec(spec);
|
|
279
|
+
if (!m)
|
|
280
|
+
return values.map(() => NaN);
|
|
281
|
+
const period = Number(m[2]);
|
|
282
|
+
return m[1] === 'ema' ? exponentialMovingAverage(values, period) : simpleMovingAverage(values, period);
|
|
283
|
+
}
|
|
26
284
|
/** Round a [min,max] domain out to nice tick boundaries. */
|
|
27
285
|
export function niceScale(min, max, tickCount = 4) {
|
|
28
286
|
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
@@ -76,13 +334,17 @@ function fmtDate(t, span) {
|
|
|
76
334
|
return d.toLocaleDateString(undefined, { month: 'short', year: '2-digit' });
|
|
77
335
|
return String(d.getFullYear());
|
|
78
336
|
}
|
|
79
|
-
/** Data domain for one axis, honoring stacking of its bar/area series.
|
|
80
|
-
|
|
337
|
+
/** Data domain for one axis, honoring stacking of its bar/area series.
|
|
338
|
+
* When `isLog` is true, non-positive values are discarded (log undefined)
|
|
339
|
+
* and the domain is rounded to decade boundaries instead of nice steps. */
|
|
340
|
+
function axisDomain(list, categories, stacked, extra = [], isLog = false) {
|
|
81
341
|
let dMin = Infinity;
|
|
82
342
|
let dMax = -Infinity;
|
|
83
343
|
const note = (v) => {
|
|
84
344
|
if (!Number.isFinite(v))
|
|
85
345
|
return;
|
|
346
|
+
if (isLog && v <= 0)
|
|
347
|
+
return;
|
|
86
348
|
if (v < dMin)
|
|
87
349
|
dMin = v;
|
|
88
350
|
if (v > dMax)
|
|
@@ -116,17 +378,18 @@ function axisDomain(list, categories, stacked, extra = []) {
|
|
|
116
378
|
for (const v of s.values)
|
|
117
379
|
note(v);
|
|
118
380
|
if (dMin === Infinity) {
|
|
119
|
-
dMin = 0;
|
|
120
|
-
dMax = 1;
|
|
381
|
+
dMin = isLog ? 1 : 0;
|
|
382
|
+
dMax = isLog ? 10 : 1;
|
|
121
383
|
}
|
|
122
|
-
// Bar / area charts read against a zero baseline, so always include 0
|
|
123
|
-
|
|
384
|
+
// Bar / area charts read against a zero baseline, so always include 0
|
|
385
|
+
// - but only on linear axes (0 is invalid in log).
|
|
386
|
+
if (stackable.length && !isLog) {
|
|
124
387
|
dMin = Math.min(dMin, 0);
|
|
125
388
|
dMax = Math.max(dMax, 0);
|
|
126
389
|
}
|
|
127
|
-
return niceScale(dMin, dMax);
|
|
390
|
+
return isLog ? niceLogScale(dMin, dMax) : niceScale(dMin, dMax);
|
|
128
391
|
}
|
|
129
|
-
export function buildChart(spec) {
|
|
392
|
+
export function buildChart(spec, theme = 'light') {
|
|
130
393
|
const width = spec.width ?? 520;
|
|
131
394
|
const height = spec.height ?? 300;
|
|
132
395
|
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
@@ -159,7 +422,705 @@ export function buildChart(spec) {
|
|
|
159
422
|
valueTicks: [],
|
|
160
423
|
catTicks: [],
|
|
161
424
|
referenceLinesV: [],
|
|
425
|
+
overlays: [],
|
|
426
|
+
annotations: [],
|
|
427
|
+
heatmapCells: [],
|
|
428
|
+
heatmapRowTicks: [],
|
|
429
|
+
heatmapColTicks: [],
|
|
430
|
+
heatmapLegend: [],
|
|
431
|
+
funnelSegments: [],
|
|
432
|
+
radarRings: [],
|
|
433
|
+
radarAxes: [],
|
|
434
|
+
radarSeries: [],
|
|
435
|
+
radarCenter: null,
|
|
436
|
+
treemapCells: [],
|
|
437
|
+
calendarCells: [],
|
|
438
|
+
calendarMonthTicks: [],
|
|
439
|
+
calendarLegend: [],
|
|
440
|
+
gauge: null,
|
|
441
|
+
sankeyNodes: [],
|
|
442
|
+
sankeyLinks: [],
|
|
162
443
|
};
|
|
444
|
+
// ---- Waterfall ----------------------------------------------------
|
|
445
|
+
// First series provides the values. Each non-total bar starts at the
|
|
446
|
+
// running cumulative sum; total bars (waterfallTotals[i]) reset and
|
|
447
|
+
// span from 0 to that sum. Color is derived from sign + total flag, with
|
|
448
|
+
// optional palette overrides via spec.waterfallColors.
|
|
449
|
+
if (spec.type === 'waterfall') {
|
|
450
|
+
const src = series[0];
|
|
451
|
+
if (!src)
|
|
452
|
+
return { ...empty };
|
|
453
|
+
const colors = spec.waterfallColors ?? {};
|
|
454
|
+
const positive = colors.positive ?? '#16a34a';
|
|
455
|
+
const negative = colors.negative ?? '#ef4444';
|
|
456
|
+
const total = colors.total ?? '#475569';
|
|
457
|
+
const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0);
|
|
458
|
+
const xLabelRotated = spec.categories.length > 8 || maxLabel > 9;
|
|
459
|
+
const padL = 48 + (spec.yAxisTitle ? 16 : 0);
|
|
460
|
+
const padR = 12;
|
|
461
|
+
const padT = 10;
|
|
462
|
+
const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0);
|
|
463
|
+
const plotW = Math.max(1, width - padL - padR);
|
|
464
|
+
const plotH = Math.max(1, height - padT - padB);
|
|
465
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
466
|
+
// Compute the running cumulative + per-bar (from, to) pairs.
|
|
467
|
+
const totals = spec.waterfallTotals ?? [];
|
|
468
|
+
const pairs = [];
|
|
469
|
+
let cum = 0;
|
|
470
|
+
spec.categories.forEach((_, i) => {
|
|
471
|
+
const v = src.values[i] ?? 0;
|
|
472
|
+
const isTotal = !!totals[i];
|
|
473
|
+
if (isTotal) {
|
|
474
|
+
pairs.push({ from: 0, to: cum, value: cum, isTotal: true });
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
pairs.push({ from: cum, to: cum + v, value: v, isTotal: false });
|
|
478
|
+
cum += v;
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
// Y-axis domain spans every visited level (including 0).
|
|
482
|
+
let dMin = 0, dMax = 0;
|
|
483
|
+
for (const p of pairs) {
|
|
484
|
+
if (p.from < dMin)
|
|
485
|
+
dMin = p.from;
|
|
486
|
+
if (p.to < dMin)
|
|
487
|
+
dMin = p.to;
|
|
488
|
+
if (p.from > dMax)
|
|
489
|
+
dMax = p.from;
|
|
490
|
+
if (p.to > dMax)
|
|
491
|
+
dMax = p.to;
|
|
492
|
+
}
|
|
493
|
+
const dom = niceScale(dMin, dMax);
|
|
494
|
+
const yOfW = (v) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH);
|
|
495
|
+
const slotW = plotW / Math.max(1, spec.categories.length);
|
|
496
|
+
const barPad = slotW * 0.2;
|
|
497
|
+
const barW = Math.max(1, slotW - barPad);
|
|
498
|
+
const bars = pairs.map((p, i) => {
|
|
499
|
+
const yTop = yOfW(Math.max(p.from, p.to));
|
|
500
|
+
const yBot = yOfW(Math.min(p.from, p.to));
|
|
501
|
+
const x = padL + slotW * i + barPad / 2;
|
|
502
|
+
const color = p.isTotal ? total : p.value >= 0 ? positive : negative;
|
|
503
|
+
return {
|
|
504
|
+
x: round(x), y: yTop, w: round(barW), h: Math.max(1, yBot - yTop),
|
|
505
|
+
color, label: spec.categories[i] ?? String(i), series: src.label, value: p.value,
|
|
506
|
+
};
|
|
507
|
+
});
|
|
508
|
+
// Thin connector lines between bar tops -> running total reads cleanly.
|
|
509
|
+
const connectors = [{
|
|
510
|
+
path: pairs
|
|
511
|
+
.map((p, i) => {
|
|
512
|
+
const x0 = padL + slotW * i + barPad / 2 + barW;
|
|
513
|
+
const y = yOfW(p.to);
|
|
514
|
+
const x1 = padL + slotW * (i + 1) + barPad / 2;
|
|
515
|
+
// Skip the final connector beyond the last bar.
|
|
516
|
+
return i < pairs.length - 1 ? `M${x0},${y} L${x1},${y}` : '';
|
|
517
|
+
})
|
|
518
|
+
.filter(Boolean)
|
|
519
|
+
.join(' '),
|
|
520
|
+
areaPath: '',
|
|
521
|
+
color: 'var(--sg-muted, #94a3b8)',
|
|
522
|
+
label: '',
|
|
523
|
+
points: [],
|
|
524
|
+
}];
|
|
525
|
+
const xTicks = spec.categories.map((label, i) => ({
|
|
526
|
+
label,
|
|
527
|
+
x: round(padL + slotW * i + slotW / 2),
|
|
528
|
+
}));
|
|
529
|
+
const yTicks = dom.ticks.map((value) => ({
|
|
530
|
+
value, y: yOfW(value), label: fmtTick(value),
|
|
531
|
+
}));
|
|
532
|
+
return {
|
|
533
|
+
...empty,
|
|
534
|
+
plot,
|
|
535
|
+
bars,
|
|
536
|
+
lines: connectors,
|
|
537
|
+
yTicks,
|
|
538
|
+
xTicks,
|
|
539
|
+
xLabelRotated,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
// ---- Funnel -------------------------------------------------------
|
|
543
|
+
// One series of strictly-decreasing values gets rendered as a stack
|
|
544
|
+
// of horizontal trapezoids: each level's width is proportional to its
|
|
545
|
+
// value relative to the largest, slope automatically links level N+1
|
|
546
|
+
// narrower than level N. Labels show value, conversion vs. top, and
|
|
547
|
+
// step drop-off.
|
|
548
|
+
if (spec.type === 'funnel') {
|
|
549
|
+
const src = series[0];
|
|
550
|
+
if (!src || !src.values.length)
|
|
551
|
+
return { ...empty };
|
|
552
|
+
const padL = 20, padR = 20, padT = 16, padB = 16;
|
|
553
|
+
const plotW = Math.max(1, width - padL - padR);
|
|
554
|
+
const plotH = Math.max(1, height - padT - padB);
|
|
555
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
556
|
+
const n = src.values.length;
|
|
557
|
+
const stepH = plotH / n;
|
|
558
|
+
const valMax = Math.max(...src.values.map((v) => (Number.isFinite(v) ? v : 0)));
|
|
559
|
+
const top = src.values[0] ?? 0;
|
|
560
|
+
const widthAt = (v) => (valMax > 0 ? (v / valMax) * plotW : 0);
|
|
561
|
+
const cx = padL + plotW / 2;
|
|
562
|
+
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
563
|
+
const segments = src.values.map((v, i) => {
|
|
564
|
+
const next = src.values[i + 1] ?? v * 0.8; // taper to a point on the last level
|
|
565
|
+
const w0 = widthAt(v);
|
|
566
|
+
const w1 = widthAt(next);
|
|
567
|
+
const y0 = padT + stepH * i;
|
|
568
|
+
const y1 = y0 + stepH;
|
|
569
|
+
const path = `M${cx - w0 / 2},${y0} L${cx + w0 / 2},${y0} L${cx + w1 / 2},${y1} L${cx - w1 / 2},${y1} Z`;
|
|
570
|
+
const color = src.color ?? palette[i % palette.length];
|
|
571
|
+
return {
|
|
572
|
+
path, color,
|
|
573
|
+
label: spec.categories[i] ?? src.label,
|
|
574
|
+
value: v,
|
|
575
|
+
conversion: top > 0 ? v / top : 0,
|
|
576
|
+
dropoff: i === 0 ? 0 : (src.values[i - 1] ?? v) > 0 ? 1 - v / (src.values[i - 1] ?? v) : 0,
|
|
577
|
+
cx,
|
|
578
|
+
cy: (y0 + y1) / 2,
|
|
579
|
+
textColor: pickContrastText(color),
|
|
580
|
+
};
|
|
581
|
+
});
|
|
582
|
+
return {
|
|
583
|
+
...empty,
|
|
584
|
+
plot,
|
|
585
|
+
funnelSegments: segments,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
// ---- Radar --------------------------------------------------------
|
|
589
|
+
// Polar coordinates: each `category` is a spoke (axis); each `series`
|
|
590
|
+
// contributes a polygon connecting its values across the spokes. All
|
|
591
|
+
// series share the same scale (max across every value). Concentric
|
|
592
|
+
// ring count derived from data, capped at 5 for legibility.
|
|
593
|
+
if (spec.type === 'radar') {
|
|
594
|
+
if (!series.length || !spec.categories.length)
|
|
595
|
+
return { ...empty };
|
|
596
|
+
const padL = 30, padR = 30, padT = 24, padB = 24;
|
|
597
|
+
const plotW = Math.max(1, width - padL - padR);
|
|
598
|
+
const plotH = Math.max(1, height - padT - padB);
|
|
599
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
600
|
+
const cx = padL + plotW / 2;
|
|
601
|
+
const cy = padT + plotH / 2;
|
|
602
|
+
const r = Math.max(20, Math.min(plotW, plotH) / 2 - 20);
|
|
603
|
+
const axes = spec.categories;
|
|
604
|
+
const k = axes.length;
|
|
605
|
+
let vMax = 0;
|
|
606
|
+
for (const s of series)
|
|
607
|
+
for (const v of s.values) {
|
|
608
|
+
if (Number.isFinite(v) && v > vMax)
|
|
609
|
+
vMax = v;
|
|
610
|
+
}
|
|
611
|
+
if (vMax === 0)
|
|
612
|
+
vMax = 1;
|
|
613
|
+
const ringCount = 5;
|
|
614
|
+
const ringValues = Array.from({ length: ringCount }, (_, i) => ((i + 1) / ringCount) * vMax);
|
|
615
|
+
/** Convert (axis index, value) to (x, y). Angles start at 12 o'clock,
|
|
616
|
+
* proceed clockwise so axes lay out left-to-right when k <= 4. */
|
|
617
|
+
const angleAt = (i) => -Math.PI / 2 + (i / k) * Math.PI * 2;
|
|
618
|
+
const pointAt = (i, v) => {
|
|
619
|
+
const t = v / vMax;
|
|
620
|
+
const a = angleAt(i);
|
|
621
|
+
return { x: round(cx + r * t * Math.cos(a)), y: round(cy + r * t * Math.sin(a)) };
|
|
622
|
+
};
|
|
623
|
+
const radarAxes = axes.map((label, i) => {
|
|
624
|
+
const p = pointAt(i, vMax);
|
|
625
|
+
return { label, x: p.x, y: p.y };
|
|
626
|
+
});
|
|
627
|
+
const radarSeriesGeo = series.map((s, si) => {
|
|
628
|
+
const pts = s.values.map((v, i) => {
|
|
629
|
+
const safe = Number.isFinite(v) ? v : 0;
|
|
630
|
+
const p = pointAt(i, safe);
|
|
631
|
+
return { x: p.x, y: p.y, value: v, axis: axes[i] ?? '' };
|
|
632
|
+
});
|
|
633
|
+
const path = pts.length
|
|
634
|
+
? pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + ' Z'
|
|
635
|
+
: '';
|
|
636
|
+
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
637
|
+
return { label: s.label, color: s.color ?? palette[si % palette.length], path, points: pts };
|
|
638
|
+
});
|
|
639
|
+
return {
|
|
640
|
+
...empty,
|
|
641
|
+
plot,
|
|
642
|
+
radarRings: ringValues,
|
|
643
|
+
radarAxes,
|
|
644
|
+
radarSeries: radarSeriesGeo,
|
|
645
|
+
radarCenter: { cx, cy, r },
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
// ---- Calendar heatmap --------------------------------------------
|
|
649
|
+
// GitHub-style year-of-days view: 7 rows (Sun..Sat) x N weeks. Each
|
|
650
|
+
// cell is a small square shaded by `calendarValues[i].value` via the
|
|
651
|
+
// sequential color scale. Days with no value render blank (border only)
|
|
652
|
+
// so missing data is visually obvious.
|
|
653
|
+
if (spec.type === 'calendar') {
|
|
654
|
+
const values = spec.calendarValues ?? [];
|
|
655
|
+
if (!values.length && !spec.calendarStart)
|
|
656
|
+
return { ...empty };
|
|
657
|
+
// Build a value lookup + figure out the date range.
|
|
658
|
+
const valueByDate = new Map();
|
|
659
|
+
let vMin = Infinity, vMax = -Infinity;
|
|
660
|
+
for (const v of values) {
|
|
661
|
+
valueByDate.set(v.date, v.value);
|
|
662
|
+
if (Number.isFinite(v.value)) {
|
|
663
|
+
if (v.value < vMin)
|
|
664
|
+
vMin = v.value;
|
|
665
|
+
if (v.value > vMax)
|
|
666
|
+
vMax = v.value;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (vMin === Infinity) {
|
|
670
|
+
vMin = 0;
|
|
671
|
+
vMax = 1;
|
|
672
|
+
}
|
|
673
|
+
if (vMin === vMax)
|
|
674
|
+
vMax = vMin + 1;
|
|
675
|
+
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme);
|
|
676
|
+
const colorAt = (v) => sampleGradient(stops, (v - vMin) / (vMax - vMin));
|
|
677
|
+
// Determine date range. If calendarStart/End set, use them, otherwise
|
|
678
|
+
// span the data + round to whole weeks (Sun..Sat).
|
|
679
|
+
const sorted = values.map((v) => v.date).sort();
|
|
680
|
+
const startStr = spec.calendarStart ?? sorted[0] ?? '2026-01-01';
|
|
681
|
+
const endStr = spec.calendarEnd ?? sorted[sorted.length - 1] ?? startStr;
|
|
682
|
+
const start = new Date(startStr + 'T00:00:00Z');
|
|
683
|
+
const end = new Date(endStr + 'T00:00:00Z');
|
|
684
|
+
// Roll start back to its Sunday, end forward to its Saturday.
|
|
685
|
+
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
|
|
686
|
+
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
|
|
687
|
+
const totalDays = Math.round((end.getTime() - start.getTime()) / 86400000) + 1;
|
|
688
|
+
const weeks = Math.ceil(totalDays / 7);
|
|
689
|
+
const padL = 36, padR = 80, padT = 26, padB = 16;
|
|
690
|
+
const plotW = Math.max(1, width - padL - padR);
|
|
691
|
+
const plotH = Math.max(1, height - padT - padB);
|
|
692
|
+
// Cell size: fit weeks across width, 7 rows down height.
|
|
693
|
+
const cellW = Math.floor(plotW / weeks);
|
|
694
|
+
const cellH = Math.floor(plotH / 7);
|
|
695
|
+
const cellSize = Math.max(6, Math.min(cellW, cellH));
|
|
696
|
+
const plot = { x: padL, y: padT, w: cellSize * weeks, h: cellSize * 7 };
|
|
697
|
+
const cells = [];
|
|
698
|
+
let lastMonth = -1;
|
|
699
|
+
const monthTicks = [];
|
|
700
|
+
for (let i = 0; i < totalDays; i += 1) {
|
|
701
|
+
const day = new Date(start.getTime() + i * 86400000);
|
|
702
|
+
const col = Math.floor(i / 7);
|
|
703
|
+
const row = i % 7;
|
|
704
|
+
const date = day.toISOString().slice(0, 10);
|
|
705
|
+
const has = valueByDate.has(date);
|
|
706
|
+
const v = valueByDate.get(date) ?? 0;
|
|
707
|
+
cells.push({
|
|
708
|
+
x: padL + col * cellSize,
|
|
709
|
+
y: padT + row * cellSize,
|
|
710
|
+
size: cellSize,
|
|
711
|
+
date, value: v,
|
|
712
|
+
defined: has,
|
|
713
|
+
color: has ? colorAt(v) : 'transparent',
|
|
714
|
+
});
|
|
715
|
+
if (day.getUTCDate() === 1 && day.getUTCMonth() !== lastMonth) {
|
|
716
|
+
lastMonth = day.getUTCMonth();
|
|
717
|
+
monthTicks.push({
|
|
718
|
+
label: day.toLocaleDateString(undefined, { month: 'short' }),
|
|
719
|
+
x: padL + col * cellSize,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const legend = Array.from({ length: 5 }, (_, i) => {
|
|
724
|
+
const t = i / 4;
|
|
725
|
+
const value = vMin + (vMax - vMin) * t;
|
|
726
|
+
return { value, color: colorAt(value), label: fmtTick(value) };
|
|
727
|
+
});
|
|
728
|
+
return {
|
|
729
|
+
...empty,
|
|
730
|
+
plot,
|
|
731
|
+
calendarCells: cells,
|
|
732
|
+
calendarMonthTicks: monthTicks,
|
|
733
|
+
calendarLegend: legend,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
// ---- Gauge --------------------------------------------------------
|
|
737
|
+
// Semicircle dial: track arc + value arc + optional colored range bands
|
|
738
|
+
// + optional target tick. Reads spec.gaugeValue / gaugeMin / gaugeMax.
|
|
739
|
+
if (spec.type === 'gauge') {
|
|
740
|
+
const min = spec.gaugeMin ?? 0;
|
|
741
|
+
const max = spec.gaugeMax ?? 100;
|
|
742
|
+
const value = Math.max(min, Math.min(max, spec.gaugeValue ?? 0));
|
|
743
|
+
const target = spec.gaugeTarget;
|
|
744
|
+
const cx = width / 2;
|
|
745
|
+
const cy = height * 0.78;
|
|
746
|
+
const r = Math.min(width * 0.42, height * 0.65);
|
|
747
|
+
// Start angle 180deg, end 360deg (drawn clockwise from 9 o'clock to 3).
|
|
748
|
+
const A0 = Math.PI;
|
|
749
|
+
const A1 = 2 * Math.PI;
|
|
750
|
+
const angleAt = (v) => A0 + ((v - min) / (max - min || 1)) * (A1 - A0);
|
|
751
|
+
const arc = (a0, a1, radius) => {
|
|
752
|
+
const x1 = cx + radius * Math.cos(a0);
|
|
753
|
+
const y1 = cy + radius * Math.sin(a0);
|
|
754
|
+
const x2 = cx + radius * Math.cos(a1);
|
|
755
|
+
const y2 = cy + radius * Math.sin(a1);
|
|
756
|
+
const large = a1 - a0 > Math.PI ? 1 : 0;
|
|
757
|
+
return `M${x1},${y1} A${radius},${radius} 0 ${large} 1 ${x2},${y2}`;
|
|
758
|
+
};
|
|
759
|
+
const trackPath = arc(A0, A1, r);
|
|
760
|
+
const valuePath = arc(A0, angleAt(value), r);
|
|
761
|
+
const rangePaths = (spec.gaugeRanges ?? []).map((band) => ({
|
|
762
|
+
path: arc(angleAt(band.from), angleAt(band.to), r - 9),
|
|
763
|
+
color: band.color, from: band.from, to: band.to,
|
|
764
|
+
}));
|
|
765
|
+
let targetPx = null;
|
|
766
|
+
if (target != null && Number.isFinite(target)) {
|
|
767
|
+
const a = angleAt(Math.max(min, Math.min(max, target)));
|
|
768
|
+
const inner = r - 12;
|
|
769
|
+
const outer = r + 4;
|
|
770
|
+
targetPx = {
|
|
771
|
+
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
772
|
+
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
// Tick marks just outside the track: a major tick every 1/4 of the scale,
|
|
776
|
+
// with 4 minor ticks between each. Gives the dial a measured, instrument feel.
|
|
777
|
+
const ticks = [];
|
|
778
|
+
const TICK_MAJOR = 4, TICK_MINOR = 5, TICK_TOTAL = TICK_MAJOR * TICK_MINOR;
|
|
779
|
+
for (let i = 0; i <= TICK_TOTAL; i++) {
|
|
780
|
+
const a = A0 + (i / TICK_TOTAL) * (A1 - A0);
|
|
781
|
+
const major = i % TICK_MINOR === 0;
|
|
782
|
+
const inner = r + 9;
|
|
783
|
+
const outer = r + (major ? 17 : 13);
|
|
784
|
+
ticks.push({
|
|
785
|
+
x1: cx + inner * Math.cos(a), y1: cy + inner * Math.sin(a),
|
|
786
|
+
x2: cx + outer * Math.cos(a), y2: cy + outer * Math.sin(a),
|
|
787
|
+
major,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
// Pointer needle: a kite (long tip toward the value, short counterweight
|
|
791
|
+
// tail) pivoting on a center hub.
|
|
792
|
+
const aV = angleAt(value);
|
|
793
|
+
const tipR = r - 16, tailR = 18, baseR = 6;
|
|
794
|
+
const aPerp = aV + Math.PI / 2;
|
|
795
|
+
const pt = (rad, ang) => `${round(cx + rad * Math.cos(ang))},${round(cy + rad * Math.sin(ang))}`;
|
|
796
|
+
const needlePath = `M${pt(baseR, aPerp)} L${pt(tipR, aV)} L${pt(baseR, aPerp + Math.PI)} L${pt(tailR, aV + Math.PI)} Z`;
|
|
797
|
+
// Color the value arc by the band the value currently sits in.
|
|
798
|
+
let valueColor = null;
|
|
799
|
+
for (const band of spec.gaugeRanges ?? []) {
|
|
800
|
+
if (value >= band.from && value <= band.to)
|
|
801
|
+
valueColor = band.color;
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
...empty,
|
|
805
|
+
plot: { x: 0, y: 0, w: width, h: height },
|
|
806
|
+
gauge: {
|
|
807
|
+
cx, cy, r, trackPath, valuePath, rangePaths, target: targetPx,
|
|
808
|
+
ticks, needle: { path: needlePath, hubR: 7 }, valueColor,
|
|
809
|
+
minLabel: { x: cx - r, y: cy + 20 },
|
|
810
|
+
maxLabel: { x: cx + r, y: cy + 20 },
|
|
811
|
+
value, min, max, unit: spec.gaugeUnit ?? '',
|
|
812
|
+
},
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
// ---- Tree-map -----------------------------------------------------
|
|
816
|
+
// Squarified tree-map (Bruls et al. 2000): each level recursively
|
|
817
|
+
// partitions its rectangle in proportion to its children, picking the
|
|
818
|
+
// split orientation that keeps aspect ratios closest to 1.
|
|
819
|
+
if (spec.type === 'treemap') {
|
|
820
|
+
const root = spec.treemap;
|
|
821
|
+
if (!root)
|
|
822
|
+
return { ...empty };
|
|
823
|
+
const padL = 4, padR = 4, padT = 4, padB = 4;
|
|
824
|
+
const plotW = Math.max(1, width - padL - padR);
|
|
825
|
+
const plotH = Math.max(1, height - padT - padB);
|
|
826
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
827
|
+
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
828
|
+
const cells = [];
|
|
829
|
+
function totalOf(n) {
|
|
830
|
+
if (n.children?.length)
|
|
831
|
+
return n.children.reduce((s, c) => s + totalOf(c), 0);
|
|
832
|
+
return Math.max(0, n.value ?? 0);
|
|
833
|
+
}
|
|
834
|
+
function squarify(items, x, y, w, h, depth) {
|
|
835
|
+
if (!items.length || w <= 0 || h <= 0)
|
|
836
|
+
return;
|
|
837
|
+
const totals = items.map(totalOf);
|
|
838
|
+
const sum = totals.reduce((a, b) => a + b, 0);
|
|
839
|
+
if (sum <= 0)
|
|
840
|
+
return;
|
|
841
|
+
// Process largest-first so big items dominate the first row.
|
|
842
|
+
const ordered = items
|
|
843
|
+
.map((n, i) => ({ node: n, value: totals[i] }))
|
|
844
|
+
.sort((a, b) => b.value - a.value);
|
|
845
|
+
let cx = x, cy = y, cw = w, ch = h, remaining = sum;
|
|
846
|
+
let row = [];
|
|
847
|
+
const worstRatio = (vals, shortSide, rowSum, scale) => {
|
|
848
|
+
if (rowSum <= 0)
|
|
849
|
+
return Infinity;
|
|
850
|
+
const rowArea = rowSum * scale;
|
|
851
|
+
const rowSide = rowArea / shortSide;
|
|
852
|
+
let worst = 0;
|
|
853
|
+
for (const v of vals) {
|
|
854
|
+
const cell = v * scale;
|
|
855
|
+
const long = cell / rowSide;
|
|
856
|
+
const r = Math.max(shortSide / long, long / shortSide);
|
|
857
|
+
if (r > worst)
|
|
858
|
+
worst = r;
|
|
859
|
+
}
|
|
860
|
+
return worst;
|
|
861
|
+
};
|
|
862
|
+
function flushRow() {
|
|
863
|
+
if (!row.length)
|
|
864
|
+
return;
|
|
865
|
+
const rowSum = row.reduce((a, b) => a + b.value, 0);
|
|
866
|
+
const scale = (cw * ch) / remaining;
|
|
867
|
+
const horizontal = cw >= ch;
|
|
868
|
+
const shortSide = horizontal ? ch : cw;
|
|
869
|
+
const rowSide = (rowSum * scale) / shortSide;
|
|
870
|
+
let offset = 0;
|
|
871
|
+
for (const it of row) {
|
|
872
|
+
const cellSize = (it.value * scale) / rowSide;
|
|
873
|
+
const cx2 = horizontal ? cx : cx + offset;
|
|
874
|
+
const cy2 = horizontal ? cy + offset : cy;
|
|
875
|
+
const ww = horizontal ? rowSide : cellSize;
|
|
876
|
+
const hh = horizontal ? cellSize : rowSide;
|
|
877
|
+
const color = it.node.color ?? palette[(depth + cells.length) % palette.length];
|
|
878
|
+
// Leaf: emit a cell. Branch: recurse into the rect minus a label gutter.
|
|
879
|
+
if (it.node.children?.length) {
|
|
880
|
+
cells.push({
|
|
881
|
+
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
882
|
+
color, textColor: pickContrastText(color),
|
|
883
|
+
name: it.node.name, value: it.value, depth,
|
|
884
|
+
});
|
|
885
|
+
const labelH = Math.min(18, hh * 0.25);
|
|
886
|
+
squarify(it.node.children, cx2 + 1, cy2 + labelH, ww - 2, hh - labelH - 1, depth + 1);
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
cells.push({
|
|
890
|
+
x: round(cx2), y: round(cy2), w: round(ww), h: round(hh),
|
|
891
|
+
color, textColor: pickContrastText(color),
|
|
892
|
+
name: it.node.name, value: it.value, depth,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
offset += cellSize;
|
|
896
|
+
}
|
|
897
|
+
// Shrink the remaining strip.
|
|
898
|
+
if (horizontal) {
|
|
899
|
+
cx += rowSide;
|
|
900
|
+
cw -= rowSide;
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
cy += rowSide;
|
|
904
|
+
ch -= rowSide;
|
|
905
|
+
}
|
|
906
|
+
remaining -= rowSum;
|
|
907
|
+
row = [];
|
|
908
|
+
}
|
|
909
|
+
for (const it of ordered) {
|
|
910
|
+
const scale = (cw * ch) / remaining;
|
|
911
|
+
const shortSide = Math.min(cw, ch);
|
|
912
|
+
const rowSum = row.reduce((a, b) => a + b.value, 0);
|
|
913
|
+
const currWorst = worstRatio(row.map((r) => r.value), shortSide, rowSum, scale);
|
|
914
|
+
const nextWorst = worstRatio([...row.map((r) => r.value), it.value], shortSide, rowSum + it.value, scale);
|
|
915
|
+
if (row.length && nextWorst > currWorst) {
|
|
916
|
+
flushRow();
|
|
917
|
+
}
|
|
918
|
+
row.push(it);
|
|
919
|
+
}
|
|
920
|
+
flushRow();
|
|
921
|
+
}
|
|
922
|
+
const seedItems = root.children ?? [root];
|
|
923
|
+
squarify(seedItems, padL, padT, plotW, plotH, 0);
|
|
924
|
+
return { ...empty, plot, treemapCells: cells };
|
|
925
|
+
}
|
|
926
|
+
// ---- Sankey -------------------------------------------------------
|
|
927
|
+
// Multi-column flow layout. Each node assigned to a column by longest
|
|
928
|
+
// path from any source. Within a column, nodes are stacked vertically;
|
|
929
|
+
// height proportional to max(totalIn, totalOut). Links render as
|
|
930
|
+
// bezier ribbons whose width is the link value (in pixels).
|
|
931
|
+
if (spec.type === 'sankey') {
|
|
932
|
+
const nodes = spec.sankeyNodes ?? [];
|
|
933
|
+
const links = spec.sankeyLinks ?? [];
|
|
934
|
+
if (!nodes.length || !links.length)
|
|
935
|
+
return { ...empty };
|
|
936
|
+
const padL = 10, padR = 10, padT = 14, padB = 14;
|
|
937
|
+
const plotW = Math.max(1, width - padL - padR);
|
|
938
|
+
const plotH = Math.max(1, height - padT - padB);
|
|
939
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
940
|
+
const palette = spec.palette ?? DEFAULT_PALETTE;
|
|
941
|
+
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
|
942
|
+
// Column = longest path from any node with no incoming edges.
|
|
943
|
+
const targets = new Set(links.map((l) => l.target));
|
|
944
|
+
const sources = nodes.filter((n) => !targets.has(n.id));
|
|
945
|
+
const column = new Map();
|
|
946
|
+
function visit(id, depth, seen) {
|
|
947
|
+
if (seen.has(id))
|
|
948
|
+
return;
|
|
949
|
+
seen.add(id);
|
|
950
|
+
const cur = column.get(id) ?? 0;
|
|
951
|
+
if (depth > cur || !column.has(id))
|
|
952
|
+
column.set(id, depth);
|
|
953
|
+
for (const l of links)
|
|
954
|
+
if (l.source === id)
|
|
955
|
+
visit(l.target, depth + 1, seen);
|
|
956
|
+
seen.delete(id);
|
|
957
|
+
}
|
|
958
|
+
for (const s of sources)
|
|
959
|
+
visit(s.id, 0, new Set());
|
|
960
|
+
// Cover any nodes with no path from a source (orphan rings).
|
|
961
|
+
for (const n of nodes)
|
|
962
|
+
if (!column.has(n.id))
|
|
963
|
+
column.set(n.id, 0);
|
|
964
|
+
const maxCol = Math.max(...column.values());
|
|
965
|
+
const cols = maxCol + 1;
|
|
966
|
+
const nodeW = 14;
|
|
967
|
+
const gapBetweenColumns = cols > 1 ? (plotW - nodeW * cols) / (cols - 1) : 0;
|
|
968
|
+
// Totals per node.
|
|
969
|
+
const totalIn = new Map();
|
|
970
|
+
const totalOut = new Map();
|
|
971
|
+
for (const l of links) {
|
|
972
|
+
totalIn.set(l.target, (totalIn.get(l.target) ?? 0) + l.value);
|
|
973
|
+
totalOut.set(l.source, (totalOut.get(l.source) ?? 0) + l.value);
|
|
974
|
+
}
|
|
975
|
+
// Per-column groups + max total in that column.
|
|
976
|
+
const byCol = new Map();
|
|
977
|
+
for (const n of nodes) {
|
|
978
|
+
const c = column.get(n.id) ?? 0;
|
|
979
|
+
const arr = byCol.get(c) ?? [];
|
|
980
|
+
arr.push(n.id);
|
|
981
|
+
byCol.set(c, arr);
|
|
982
|
+
}
|
|
983
|
+
// Per-column total height + node height scale.
|
|
984
|
+
let maxColTotal = 0;
|
|
985
|
+
for (const ids of byCol.values()) {
|
|
986
|
+
const t = ids.reduce((s, id) => s + Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0), 0);
|
|
987
|
+
if (t > maxColTotal)
|
|
988
|
+
maxColTotal = t;
|
|
989
|
+
}
|
|
990
|
+
if (maxColTotal === 0)
|
|
991
|
+
return { ...empty, plot };
|
|
992
|
+
const nodeGapPx = 8;
|
|
993
|
+
const heightScale = (plotH - nodeGapPx * 8) / maxColTotal; // leave gap room
|
|
994
|
+
const placed = [];
|
|
995
|
+
for (const [c, ids] of byCol) {
|
|
996
|
+
const heights = ids.map((id) => Math.max(8, Math.max(totalIn.get(id) ?? 0, totalOut.get(id) ?? 0) * heightScale));
|
|
997
|
+
const totalH = heights.reduce((s, h) => s + h, 0) + nodeGapPx * (ids.length - 1);
|
|
998
|
+
let yCursor = padT + (plotH - totalH) / 2;
|
|
999
|
+
const xCol = padL + c * (nodeW + gapBetweenColumns);
|
|
1000
|
+
ids.forEach((id, idx) => {
|
|
1001
|
+
const node = nodeById.get(id);
|
|
1002
|
+
const h = heights[idx];
|
|
1003
|
+
placed.push({
|
|
1004
|
+
id,
|
|
1005
|
+
label: node.label ?? id,
|
|
1006
|
+
color: node.color ?? palette[(placed.length) % palette.length],
|
|
1007
|
+
x: xCol, y: yCursor, w: nodeW, h,
|
|
1008
|
+
column: c,
|
|
1009
|
+
totalIn: totalIn.get(id) ?? 0,
|
|
1010
|
+
totalOut: totalOut.get(id) ?? 0,
|
|
1011
|
+
});
|
|
1012
|
+
yCursor += h + nodeGapPx;
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
const placedById = new Map(placed.map((n) => [n.id, n]));
|
|
1016
|
+
// Per-node sub-cursor so multiple links from one node stack vertically.
|
|
1017
|
+
const inCursor = new Map();
|
|
1018
|
+
const outCursor = new Map();
|
|
1019
|
+
const builtLinks = [];
|
|
1020
|
+
// Sort links so wider ribbons render first (so thin ribbons stack on top).
|
|
1021
|
+
const sortedLinks = links.slice().sort((a, b) => b.value - a.value);
|
|
1022
|
+
for (const link of sortedLinks) {
|
|
1023
|
+
const a = placedById.get(link.source);
|
|
1024
|
+
const b = placedById.get(link.target);
|
|
1025
|
+
if (!a || !b)
|
|
1026
|
+
continue;
|
|
1027
|
+
const linkH = Math.max(1, link.value * heightScale);
|
|
1028
|
+
const aY = a.y + (outCursor.get(a.id) ?? 0) + linkH / 2;
|
|
1029
|
+
const bY = b.y + (inCursor.get(b.id) ?? 0) + linkH / 2;
|
|
1030
|
+
outCursor.set(a.id, (outCursor.get(a.id) ?? 0) + linkH);
|
|
1031
|
+
inCursor.set(b.id, (inCursor.get(b.id) ?? 0) + linkH);
|
|
1032
|
+
const x0 = a.x + a.w;
|
|
1033
|
+
const x1 = b.x;
|
|
1034
|
+
const mid = (x0 + x1) / 2;
|
|
1035
|
+
const path = `M${x0},${aY} C${mid},${aY} ${mid},${bY} ${x1},${bY}`;
|
|
1036
|
+
builtLinks.push({
|
|
1037
|
+
path, color: link.color ?? a.color, width: linkH,
|
|
1038
|
+
source: link.source, target: link.target, value: link.value,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
return { ...empty, plot, sankeyNodes: placed, sankeyLinks: builtLinks };
|
|
1042
|
+
}
|
|
1043
|
+
// ---- Heatmap ------------------------------------------------------
|
|
1044
|
+
// Each series is one row, series.values are the cells across categories.
|
|
1045
|
+
// Color comes from a sequential/diverging/custom palette mapped to the
|
|
1046
|
+
// global value range. Cell text contrasts black/white against the cell.
|
|
1047
|
+
if (spec.type === 'heatmap') {
|
|
1048
|
+
if (!series.length || !spec.categories.length)
|
|
1049
|
+
return { ...empty, plot: { x: 0, y: 0, w: width, h: height } };
|
|
1050
|
+
// Layout: left gutter for row labels, bottom for column labels.
|
|
1051
|
+
const maxRowLabel = series.reduce((m, s) => Math.max(m, s.label.length), 0);
|
|
1052
|
+
const padL = 12 + Math.min(180, Math.max(60, maxRowLabel * 7));
|
|
1053
|
+
const padR = 64; // room for the right-side legend bar
|
|
1054
|
+
const padT = 12;
|
|
1055
|
+
const padB = 32;
|
|
1056
|
+
const plotW = Math.max(1, width - padL - padR);
|
|
1057
|
+
const plotH = Math.max(1, height - padT - padB);
|
|
1058
|
+
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
1059
|
+
const cellW = plotW / spec.categories.length;
|
|
1060
|
+
const cellH = plotH / series.length;
|
|
1061
|
+
// Resolve value range across the whole matrix.
|
|
1062
|
+
let vMin = Infinity, vMax = -Infinity;
|
|
1063
|
+
for (const s of series)
|
|
1064
|
+
for (const v of s.values) {
|
|
1065
|
+
if (!Number.isFinite(v))
|
|
1066
|
+
continue;
|
|
1067
|
+
if (v < vMin)
|
|
1068
|
+
vMin = v;
|
|
1069
|
+
if (v > vMax)
|
|
1070
|
+
vMax = v;
|
|
1071
|
+
}
|
|
1072
|
+
if (vMin === Infinity) {
|
|
1073
|
+
vMin = 0;
|
|
1074
|
+
vMax = 1;
|
|
1075
|
+
}
|
|
1076
|
+
if (vMin === vMax)
|
|
1077
|
+
vMax = vMin + 1;
|
|
1078
|
+
// Pick the palette stops.
|
|
1079
|
+
const stops = resolveColorScale(spec.colorScale, vMin, vMax, theme);
|
|
1080
|
+
const colorAt = (v) => sampleGradient(stops, (v - vMin) / (vMax - vMin));
|
|
1081
|
+
const heatmapCells = [];
|
|
1082
|
+
series.forEach((s, ri) => {
|
|
1083
|
+
s.values.forEach((v, ci) => {
|
|
1084
|
+
if (!Number.isFinite(v))
|
|
1085
|
+
return;
|
|
1086
|
+
const color = colorAt(v);
|
|
1087
|
+
heatmapCells.push({
|
|
1088
|
+
x: round(padL + cellW * ci),
|
|
1089
|
+
y: round(padT + cellH * ri),
|
|
1090
|
+
w: round(cellW),
|
|
1091
|
+
h: round(cellH),
|
|
1092
|
+
color,
|
|
1093
|
+
textColor: pickContrastText(color),
|
|
1094
|
+
value: v,
|
|
1095
|
+
rowLabel: s.label,
|
|
1096
|
+
colLabel: spec.categories[ci] ?? '',
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
1100
|
+
const heatmapRowTicks = series.map((s, i) => ({
|
|
1101
|
+
value: i,
|
|
1102
|
+
y: round(padT + cellH * i + cellH / 2),
|
|
1103
|
+
label: s.label,
|
|
1104
|
+
}));
|
|
1105
|
+
const heatmapColTicks = spec.categories.map((label, i) => ({
|
|
1106
|
+
label,
|
|
1107
|
+
x: round(padL + cellW * i + cellW / 2),
|
|
1108
|
+
}));
|
|
1109
|
+
// Legend: sample 5 stops across the range.
|
|
1110
|
+
const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
|
|
1111
|
+
const t = i / 4;
|
|
1112
|
+
const value = vMin + (vMax - vMin) * t;
|
|
1113
|
+
return { value, color: colorAt(value), label: fmtTick(value) };
|
|
1114
|
+
});
|
|
1115
|
+
return {
|
|
1116
|
+
...empty,
|
|
1117
|
+
plot,
|
|
1118
|
+
heatmapCells,
|
|
1119
|
+
heatmapRowTicks,
|
|
1120
|
+
heatmapColTicks,
|
|
1121
|
+
heatmapLegend,
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
163
1124
|
if (spec.type === 'pie') {
|
|
164
1125
|
const s = series[0];
|
|
165
1126
|
if (!s)
|
|
@@ -410,18 +1371,28 @@ export function buildChart(spec) {
|
|
|
410
1371
|
const plot = { x: padL, y: padT, w: plotW, h: plotH };
|
|
411
1372
|
const refsLeft = (spec.referenceLines ?? []).filter((r) => r.axis !== 'right').map((r) => r.value);
|
|
412
1373
|
const refsRight = (spec.referenceLines ?? []).filter((r) => r.axis === 'right').map((r) => r.value);
|
|
1374
|
+
const leftLog = spec.yScale === 'log';
|
|
1375
|
+
const rightLog = spec.y2Scale === 'log';
|
|
413
1376
|
const leftDom = spec.stacked100
|
|
414
1377
|
? niceScale(0, 100)
|
|
415
|
-
: axisDomain(leftSeries, spec.categories, stacked, refsLeft);
|
|
1378
|
+
: axisDomain(leftSeries, spec.categories, stacked, refsLeft, leftLog);
|
|
416
1379
|
const rightDom = hasRightAxis
|
|
417
1380
|
? spec.stacked100
|
|
418
1381
|
? niceScale(0, 100)
|
|
419
|
-
: axisDomain(rightSeries, spec.categories, stacked, refsRight)
|
|
1382
|
+
: axisDomain(rightSeries, spec.categories, stacked, refsRight, rightLog)
|
|
420
1383
|
: null;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
1384
|
+
/** Map a data value to a y pixel. Returns NaN for non-positive values on
|
|
1385
|
+
* a log axis so callers can drop the point (line gap / missing bar). */
|
|
1386
|
+
const yOf = (dom, v, isLog = false) => {
|
|
1387
|
+
const t = project(v, dom.min, dom.max, isLog);
|
|
1388
|
+
if (t === null)
|
|
1389
|
+
return NaN;
|
|
1390
|
+
return round(padT + plotH - t * plotH);
|
|
1391
|
+
};
|
|
1392
|
+
const yLeft = (v) => yOf(leftDom, v, leftLog);
|
|
1393
|
+
const yRight = (v) => yOf(rightDom ?? leftDom, v, rightLog);
|
|
424
1394
|
const domOf = (s) => (s.axis === 'right' ? rightDom ?? leftDom : leftDom);
|
|
1395
|
+
const isLogOf = (s) => (s.axis === 'right' ? rightLog : leftLog);
|
|
425
1396
|
const n = spec.categories.length;
|
|
426
1397
|
const slot = plotW / Math.max(1, n);
|
|
427
1398
|
// X positions. A time axis spaces points by actual time (irregular gaps);
|
|
@@ -494,12 +1465,17 @@ export function buildChart(spec) {
|
|
|
494
1465
|
const barW = inner / barSeries.length;
|
|
495
1466
|
barSeries.forEach((s, bi) => {
|
|
496
1467
|
const dom = domOf(s);
|
|
497
|
-
const
|
|
1468
|
+
const log = isLogOf(s);
|
|
1469
|
+
// Log axis: bars grow from the axis floor (dom.min) up to v rather
|
|
1470
|
+
// than from 0, since 0 is invalid in log space.
|
|
1471
|
+
const base = log ? yOf(dom, dom.min, log) : yOf(dom, Math.min(Math.max(0, dom.min), dom.max), log);
|
|
498
1472
|
s.values.forEach((v, i) => {
|
|
499
1473
|
if (!Number.isFinite(v))
|
|
500
1474
|
return;
|
|
1475
|
+
if (log && v <= 0)
|
|
1476
|
+
return;
|
|
501
1477
|
const x = padL + slot * i + groupPad / 2 + barW * bi;
|
|
502
|
-
const yV = yOf(dom, v);
|
|
1478
|
+
const yV = yOf(dom, v, log);
|
|
503
1479
|
bars.push({
|
|
504
1480
|
x: round(x),
|
|
505
1481
|
y: Math.min(yV, base),
|
|
@@ -534,7 +1510,8 @@ export function buildChart(spec) {
|
|
|
534
1510
|
if (s.kind === 'bar')
|
|
535
1511
|
continue;
|
|
536
1512
|
const dom = domOf(s);
|
|
537
|
-
const
|
|
1513
|
+
const log = isLogOf(s);
|
|
1514
|
+
const yA = (v) => yOf(dom, v, log);
|
|
538
1515
|
const isStackedArea = stacked && s.kind === 'area';
|
|
539
1516
|
const px = (i) => xCenter(i);
|
|
540
1517
|
let pts;
|
|
@@ -560,23 +1537,20 @@ export function buildChart(spec) {
|
|
|
560
1537
|
return { x: px(i), y: ok ? yA(v) : NaN, label: spec.categories[i] ?? String(i), value: v, defined: ok };
|
|
561
1538
|
});
|
|
562
1539
|
}
|
|
563
|
-
// Build the line
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
if (!p.defined) {
|
|
568
|
-
pen = false;
|
|
569
|
-
continue;
|
|
570
|
-
}
|
|
571
|
-
path += `${pen ? 'L' : 'M'}${p.x},${p.y} `;
|
|
572
|
-
pen = true;
|
|
573
|
-
}
|
|
574
|
-
path = path.trim();
|
|
1540
|
+
// Build the line - smoothed via monotone cubic when requested, else
|
|
1541
|
+
// straight polylines. Either way, gaps break the path cleanly.
|
|
1542
|
+
const smooth = !!s.smooth;
|
|
1543
|
+
const path = buildLinePath(pts, smooth);
|
|
575
1544
|
let areaPath = '';
|
|
576
1545
|
if (s.kind === 'area' && pts.length) {
|
|
577
1546
|
if (baselinePts) {
|
|
578
|
-
const top =
|
|
579
|
-
|
|
1547
|
+
const top = smooth
|
|
1548
|
+
? monotoneCubicPath(pts.map((p) => ({ x: p.x, y: p.y })))
|
|
1549
|
+
: pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
1550
|
+
const back = smooth
|
|
1551
|
+
? `L${baselinePts[baselinePts.length - 1].x},${baselinePts[baselinePts.length - 1].y} ` +
|
|
1552
|
+
monotoneCubicPath(baselinePts.slice().reverse()).replace(/^M[^ ]+ /, '')
|
|
1553
|
+
: baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ');
|
|
580
1554
|
areaPath = `${top} ${back} Z`;
|
|
581
1555
|
}
|
|
582
1556
|
else {
|
|
@@ -596,35 +1570,139 @@ export function buildChart(spec) {
|
|
|
596
1570
|
runs.push(cur);
|
|
597
1571
|
areaPath = runs
|
|
598
1572
|
.map((run) => {
|
|
599
|
-
const top =
|
|
1573
|
+
const top = smooth
|
|
1574
|
+
? monotoneCubicPath(run.map((p) => ({ x: p.x, y: p.y })))
|
|
1575
|
+
: run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
600
1576
|
return `${top} L${run[run.length - 1].x},${baseY} L${run[0].x},${baseY} Z`;
|
|
601
1577
|
})
|
|
602
1578
|
.join(' ');
|
|
603
1579
|
}
|
|
604
1580
|
}
|
|
605
|
-
|
|
1581
|
+
// Confidence band: shaded envelope between upperValues / lowerValues.
|
|
1582
|
+
// Both arrays must be present and aligned to the value array.
|
|
1583
|
+
let bandPath = '';
|
|
1584
|
+
if (s.upperValues?.length === s.values.length && s.lowerValues?.length === s.values.length) {
|
|
1585
|
+
const upperPts = [];
|
|
1586
|
+
const lowerPts = [];
|
|
1587
|
+
for (let i = 0; i < s.values.length; i += 1) {
|
|
1588
|
+
const u = s.upperValues[i];
|
|
1589
|
+
const lo = s.lowerValues[i];
|
|
1590
|
+
if (!Number.isFinite(u) || !Number.isFinite(lo))
|
|
1591
|
+
continue;
|
|
1592
|
+
if (log && (u <= 0 || lo <= 0))
|
|
1593
|
+
continue;
|
|
1594
|
+
upperPts.push({ x: px(i), y: yA(u) });
|
|
1595
|
+
lowerPts.push({ x: px(i), y: yA(lo) });
|
|
1596
|
+
}
|
|
1597
|
+
if (upperPts.length >= 2) {
|
|
1598
|
+
const top = smooth
|
|
1599
|
+
? monotoneCubicPath(upperPts)
|
|
1600
|
+
: upperPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
|
|
1601
|
+
const back = smooth
|
|
1602
|
+
? `L${lowerPts[lowerPts.length - 1].x},${lowerPts[lowerPts.length - 1].y} ` +
|
|
1603
|
+
monotoneCubicPath(lowerPts.slice().reverse()).replace(/^M[^ ]+ /, '')
|
|
1604
|
+
: lowerPts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ');
|
|
1605
|
+
bandPath = `${top} ${back} Z`;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
lines.push({ path, areaPath, color: s.color, label: s.label, points: pts, bandPath });
|
|
606
1609
|
}
|
|
607
|
-
const tickFor = (dom) => dom.ticks.map((value) => ({ value, y: yOf(dom, value), label: fmtTick(value) }));
|
|
1610
|
+
const tickFor = (dom, log) => dom.ticks.map((value) => ({ value, y: yOf(dom, value, log), label: fmtTick(value) }));
|
|
608
1611
|
const referenceLines = (spec.referenceLines ?? []).map((ref) => {
|
|
609
|
-
const
|
|
1612
|
+
const onRight = ref.axis === 'right';
|
|
1613
|
+
const dom = onRight ? (rightDom ?? leftDom) : leftDom;
|
|
1614
|
+
const log = onRight ? rightLog : leftLog;
|
|
610
1615
|
return {
|
|
611
|
-
y: yOf(dom, ref.value),
|
|
1616
|
+
y: yOf(dom, ref.value, log),
|
|
612
1617
|
label: ref.label ?? fmtTick(ref.value),
|
|
613
1618
|
color: ref.color ?? '#ef4444',
|
|
614
1619
|
dashed: ref.dashed !== false,
|
|
615
1620
|
};
|
|
616
1621
|
});
|
|
1622
|
+
// ---- Overlays: trendline / moving average ------------------------
|
|
1623
|
+
// For every series with an `overlay`, compute the smoothed values and
|
|
1624
|
+
// render as a dashed line in the source series' color (or overlayColor).
|
|
1625
|
+
const overlays = [];
|
|
1626
|
+
for (const s of series) {
|
|
1627
|
+
if (!s.overlay)
|
|
1628
|
+
continue;
|
|
1629
|
+
const dom = domOf(s);
|
|
1630
|
+
const log = isLogOf(s);
|
|
1631
|
+
const overlayVals = computeOverlay(s.values, s.overlay);
|
|
1632
|
+
const color = s.overlayColor ?? s.color;
|
|
1633
|
+
const pts = overlayVals.map((v, i) => {
|
|
1634
|
+
const ok = Number.isFinite(v) && (!log || v > 0);
|
|
1635
|
+
return {
|
|
1636
|
+
x: xCenter(i),
|
|
1637
|
+
y: ok ? yOf(dom, v, log) : NaN,
|
|
1638
|
+
label: spec.categories[i] ?? String(i),
|
|
1639
|
+
value: v,
|
|
1640
|
+
defined: ok,
|
|
1641
|
+
};
|
|
1642
|
+
});
|
|
1643
|
+
const path = buildLinePath(pts, !!s.smooth);
|
|
1644
|
+
overlays.push({
|
|
1645
|
+
path,
|
|
1646
|
+
areaPath: '',
|
|
1647
|
+
color,
|
|
1648
|
+
label: `${s.label} (${s.overlay})`,
|
|
1649
|
+
points: pts,
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
// ---- Annotations: resolve data-space anchors to pixel coords ------
|
|
1653
|
+
const annotations = [];
|
|
1654
|
+
for (const a of (spec.annotations ?? [])) {
|
|
1655
|
+
let ax = null;
|
|
1656
|
+
let ay = null;
|
|
1657
|
+
if ('category' in a.at) {
|
|
1658
|
+
const ci = spec.categories.indexOf(a.at.category);
|
|
1659
|
+
if (ci < 0)
|
|
1660
|
+
continue;
|
|
1661
|
+
ax = xCenter(ci);
|
|
1662
|
+
// Anchor to the named series' value at that category, else just
|
|
1663
|
+
// mid-plot. Picks the first matching series if `series` is set.
|
|
1664
|
+
const seriesName = a.at.series;
|
|
1665
|
+
const s = seriesName ? series.find((x) => x.label === seriesName) : series[0];
|
|
1666
|
+
if (s) {
|
|
1667
|
+
const v = s.values[ci];
|
|
1668
|
+
if (Number.isFinite(v))
|
|
1669
|
+
ay = yOf(domOf(s), v, isLogOf(s));
|
|
1670
|
+
}
|
|
1671
|
+
if (ay == null)
|
|
1672
|
+
ay = padT + plotH / 2;
|
|
1673
|
+
}
|
|
1674
|
+
else {
|
|
1675
|
+
// Raw x/y in data space (x ignored for category x-axis; takes the
|
|
1676
|
+
// mid-plot in that case). y projects through the left axis.
|
|
1677
|
+
ax = padL + plotW / 2;
|
|
1678
|
+
if (Number.isFinite(a.at.y))
|
|
1679
|
+
ay = yOf(leftDom, a.at.y, leftLog);
|
|
1680
|
+
else
|
|
1681
|
+
ay = padT + plotH / 2;
|
|
1682
|
+
}
|
|
1683
|
+
if (ax != null && ay != null && Number.isFinite(ay)) {
|
|
1684
|
+
annotations.push({
|
|
1685
|
+
x: ax,
|
|
1686
|
+
y: ay,
|
|
1687
|
+
label: a.label,
|
|
1688
|
+
color: a.color ?? '#0f172a',
|
|
1689
|
+
placement: a.placement ?? 'top',
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
617
1693
|
return {
|
|
618
1694
|
...empty,
|
|
619
1695
|
plot,
|
|
620
1696
|
bars,
|
|
621
1697
|
lines,
|
|
622
|
-
yTicks: tickFor(leftDom),
|
|
623
|
-
y2Ticks: rightDom ? tickFor(rightDom) : [],
|
|
1698
|
+
yTicks: tickFor(leftDom, leftLog),
|
|
1699
|
+
y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
|
|
624
1700
|
hasRightAxis,
|
|
625
1701
|
xTicks,
|
|
626
1702
|
xLabelRotated: timeOk ? false : xLabelRotated,
|
|
627
1703
|
referenceLines,
|
|
1704
|
+
overlays,
|
|
1705
|
+
annotations,
|
|
628
1706
|
};
|
|
629
1707
|
}
|
|
630
1708
|
/**
|
|
@@ -650,7 +1728,6 @@ export function rowsToChartSpec(rows, opts) {
|
|
|
650
1728
|
}
|
|
651
1729
|
return idx;
|
|
652
1730
|
};
|
|
653
|
-
// Series keyed by name -> per-category {sum,count}.
|
|
654
1731
|
const seriesMap = new Map();
|
|
655
1732
|
const ensureSeries = (name) => {
|
|
656
1733
|
let arr = seriesMap.get(name);
|
|
@@ -660,27 +1737,33 @@ export function rowsToChartSpec(rows, opts) {
|
|
|
660
1737
|
}
|
|
661
1738
|
return arr;
|
|
662
1739
|
};
|
|
1740
|
+
const trackIds = opts.idField !== undefined;
|
|
663
1741
|
for (const row of rows) {
|
|
664
1742
|
const cat = String(row[opts.category] ?? '');
|
|
665
1743
|
const ci = ensureCat(cat);
|
|
1744
|
+
const rowId = trackIds ? row[opts.idField] : undefined;
|
|
666
1745
|
if (opts.series) {
|
|
667
1746
|
const sName = String(row[opts.series] ?? '');
|
|
668
1747
|
const arr = ensureSeries(sName);
|
|
669
1748
|
const num = Number(row[valueFields[0]]);
|
|
670
|
-
const cell = (arr[ci] ?? (arr[ci] = { sum: 0, count: 0 }));
|
|
1749
|
+
const cell = (arr[ci] ?? (arr[ci] = { sum: 0, count: 0, rowIds: [] }));
|
|
671
1750
|
if (Number.isFinite(num)) {
|
|
672
1751
|
cell.sum += num;
|
|
673
1752
|
cell.count += 1;
|
|
1753
|
+
if (rowId !== undefined)
|
|
1754
|
+
cell.rowIds.push(rowId);
|
|
674
1755
|
}
|
|
675
1756
|
}
|
|
676
1757
|
else {
|
|
677
1758
|
for (const vf of valueFields) {
|
|
678
1759
|
const arr = ensureSeries(vf);
|
|
679
1760
|
const num = Number(row[vf]);
|
|
680
|
-
const cell = (arr[ci] ?? (arr[ci] = { sum: 0, count: 0 }));
|
|
1761
|
+
const cell = (arr[ci] ?? (arr[ci] = { sum: 0, count: 0, rowIds: [] }));
|
|
681
1762
|
if (Number.isFinite(num)) {
|
|
682
1763
|
cell.sum += num;
|
|
683
1764
|
cell.count += 1;
|
|
1765
|
+
if (rowId !== undefined)
|
|
1766
|
+
cell.rowIds.push(rowId);
|
|
684
1767
|
}
|
|
685
1768
|
}
|
|
686
1769
|
}
|
|
@@ -688,9 +1771,12 @@ export function rowsToChartSpec(rows, opts) {
|
|
|
688
1771
|
const entries = [...seriesMap.entries()].map(([name, arr]) => ({
|
|
689
1772
|
label: opts.series ? name : opts.seriesLabel && valueFields.length === 1 ? opts.seriesLabel : name,
|
|
690
1773
|
values: categories.map((_, i) => {
|
|
691
|
-
const cell = arr[i] ?? { sum: 0, count: 0 };
|
|
1774
|
+
const cell = arr[i] ?? { sum: 0, count: 0, rowIds: [] };
|
|
692
1775
|
return reduceCell(cell.sum, cell.count);
|
|
693
1776
|
}),
|
|
1777
|
+
rowIds: trackIds
|
|
1778
|
+
? categories.map((_, i) => (arr[i]?.rowIds ?? []).slice())
|
|
1779
|
+
: undefined,
|
|
694
1780
|
}));
|
|
695
1781
|
// ---- Sort + top-N -----------------------------------------------------
|
|
696
1782
|
const totals = categories.map((_, i) => entries.reduce((sum, e) => sum + (Number.isFinite(e.values[i]) ? e.values[i] : 0), 0));
|
|
@@ -713,11 +1799,18 @@ export function rowsToChartSpec(rows, opts) {
|
|
|
713
1799
|
values: keep
|
|
714
1800
|
.map((i) => e.values[i])
|
|
715
1801
|
.concat(rest.reduce((sum, i) => sum + (Number.isFinite(e.values[i]) ? e.values[i] : 0), 0)),
|
|
1802
|
+
rowIds: e.rowIds
|
|
1803
|
+
? keep.map((i) => e.rowIds[i]).concat([rest.flatMap((i) => e.rowIds[i] ?? [])])
|
|
1804
|
+
: undefined,
|
|
716
1805
|
}));
|
|
717
1806
|
}
|
|
718
1807
|
else {
|
|
719
1808
|
finalCategories = order.map((i) => categories[i]);
|
|
720
|
-
finalSeries = entries.map((e) => ({
|
|
1809
|
+
finalSeries = entries.map((e) => ({
|
|
1810
|
+
label: e.label,
|
|
1811
|
+
values: order.map((i) => e.values[i]),
|
|
1812
|
+
rowIds: e.rowIds ? order.map((i) => e.rowIds[i]) : undefined,
|
|
1813
|
+
}));
|
|
721
1814
|
}
|
|
722
1815
|
return {
|
|
723
1816
|
type: opts.type,
|