@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.
Files changed (143) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +137 -39
  3. package/dist/GridFooter.svelte +164 -0
  4. package/dist/GridFooter.svelte.d.ts +29 -0
  5. package/dist/GridMenus.svelte +570 -0
  6. package/dist/GridMenus.svelte.d.ts +31 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +429 -0
  8. package/dist/SvGrid.controller.svelte.js +1732 -0
  9. package/dist/SvGrid.css +1709 -0
  10. package/dist/SvGrid.helpers.d.ts +35 -0
  11. package/dist/SvGrid.helpers.js +160 -0
  12. package/dist/SvGrid.svelte +344 -7043
  13. package/dist/SvGrid.svelte.d.ts +4 -357
  14. package/dist/SvGrid.types.d.ts +436 -0
  15. package/dist/SvGrid.types.js +1 -0
  16. package/dist/SvGridChart.svelte +1060 -23
  17. package/dist/SvGridChart.svelte.d.ts +17 -0
  18. package/dist/build-api.d.ts +5 -0
  19. package/dist/build-api.js +527 -0
  20. package/dist/cell-render.d.ts +15 -0
  21. package/dist/cell-render.js +246 -0
  22. package/dist/cell-values.d.ts +28 -0
  23. package/dist/cell-values.js +89 -0
  24. package/dist/chart.d.ts +370 -3
  25. package/dist/chart.js +1135 -42
  26. package/dist/clipboard.d.ts +15 -0
  27. package/dist/clipboard.js +356 -0
  28. package/dist/columns.d.ts +30 -0
  29. package/dist/columns.js +277 -0
  30. package/dist/core.d.ts +1 -1
  31. package/dist/css.d.ts +3 -0
  32. package/dist/editing.d.ts +24 -0
  33. package/dist/editing.js +343 -0
  34. package/dist/editors/cell-editors.d.ts +1 -1
  35. package/dist/facet-buckets.d.ts +13 -0
  36. package/dist/facet-buckets.js +54 -0
  37. package/dist/features.d.ts +5 -0
  38. package/dist/features.js +30 -0
  39. package/dist/filter-operators.d.ts +16 -0
  40. package/dist/filter-operators.js +69 -0
  41. package/dist/hyperformula-adapter.d.ts +82 -0
  42. package/dist/hyperformula-adapter.js +73 -0
  43. package/dist/index.d.ts +5 -2
  44. package/dist/index.js +5 -2
  45. package/dist/keyboard-handlers.d.ts +7 -0
  46. package/dist/keyboard-handlers.js +197 -0
  47. package/dist/menus.d.ts +40 -0
  48. package/dist/menus.js +389 -0
  49. package/dist/named-views.d.ts +27 -0
  50. package/dist/named-views.js +39 -0
  51. package/dist/row-resize.d.ts +43 -0
  52. package/dist/row-resize.js +158 -0
  53. package/dist/scroll-sync.d.ts +9 -0
  54. package/dist/scroll-sync.js +86 -0
  55. package/dist/selection.d.ts +26 -0
  56. package/dist/selection.js +387 -0
  57. package/dist/spreadsheet.d.ts +80 -0
  58. package/dist/spreadsheet.js +194 -0
  59. package/dist/summaries.d.ts +12 -0
  60. package/dist/summaries.js +65 -0
  61. package/dist/svgrid-wrapper.types.d.ts +12 -1
  62. package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
  63. package/dist/svgrid.comments-autocomplete.test.js +96 -0
  64. package/dist/svgrid.context-menu.test.d.ts +1 -0
  65. package/dist/svgrid.context-menu.test.js +102 -0
  66. package/dist/svgrid.new-features.wrapper.test.js +30 -4
  67. package/dist/svgrid.wrapper.test.js +27 -1
  68. package/dist/virtualization/column-virtualizer.d.ts +2 -0
  69. package/dist/virtualization/scroll-scaling.d.ts +28 -0
  70. package/dist/virtualization/scroll-scaling.js +64 -0
  71. package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
  72. package/dist/virtualization/scroll-scaling.test.js +86 -0
  73. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
  74. package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
  75. package/dist/virtualization/virtualizer.d.ts +7 -0
  76. package/dist/virtualization/virtualizer.js +30 -0
  77. package/package.json +1 -1
  78. package/src/GridFooter.svelte +164 -0
  79. package/src/GridMenus.svelte +570 -0
  80. package/src/SvGrid.controller.svelte.ts +2195 -0
  81. package/src/SvGrid.css +1747 -0
  82. package/src/SvGrid.helpers.test.ts +415 -0
  83. package/src/SvGrid.helpers.ts +185 -0
  84. package/src/SvGrid.svelte +348 -7043
  85. package/src/SvGrid.types.ts +456 -0
  86. package/src/SvGridChart.svelte +1060 -23
  87. package/src/build-api.coverage.test.ts +532 -0
  88. package/src/build-api.ts +663 -0
  89. package/src/cell-render.test.ts +451 -0
  90. package/src/cell-render.ts +426 -0
  91. package/src/cell-values.ts +114 -0
  92. package/src/chart-export.test.ts +370 -0
  93. package/src/chart.coverage.test.ts +814 -0
  94. package/src/chart.ts +1352 -47
  95. package/src/clipboard.test.ts +731 -0
  96. package/src/clipboard.ts +524 -0
  97. package/src/collaboration.coverage.test.ts +220 -0
  98. package/src/columns.test.ts +702 -0
  99. package/src/columns.ts +419 -0
  100. package/src/core.ts +8 -0
  101. package/src/css.d.ts +3 -0
  102. package/src/editing.test.ts +837 -0
  103. package/src/editing.ts +513 -0
  104. package/src/editors/cell-editors.coverage.test.ts +156 -0
  105. package/src/editors/cell-editors.ts +1 -0
  106. package/src/facet-buckets.test.ts +353 -0
  107. package/src/facet-buckets.ts +67 -0
  108. package/src/features.ts +128 -0
  109. package/src/filter-operators.test.ts +174 -0
  110. package/src/filter-operators.ts +87 -0
  111. package/src/hyperformula-adapter.test.ts +256 -0
  112. package/src/hyperformula-adapter.ts +124 -0
  113. package/src/index.ts +37 -0
  114. package/src/keyboard-handlers.coverage.test.ts +560 -0
  115. package/src/keyboard-handlers.ts +353 -0
  116. package/src/keyboard.ts +97 -97
  117. package/src/menus.test.ts +620 -0
  118. package/src/menus.ts +554 -0
  119. package/src/named-views.coverage.test.ts +210 -0
  120. package/src/named-views.ts +48 -0
  121. package/src/row-resize.test.ts +369 -0
  122. package/src/row-resize.ts +171 -0
  123. package/src/scroll-sync.test.ts +330 -0
  124. package/src/scroll-sync.ts +216 -0
  125. package/src/selection.test.ts +722 -0
  126. package/src/selection.ts +545 -0
  127. package/src/server-data-source.coverage.test.ts +180 -0
  128. package/src/spreadsheet.test.ts +445 -0
  129. package/src/spreadsheet.ts +246 -0
  130. package/src/summaries.ts +204 -0
  131. package/src/sv-grid-scrollbar.ts +13 -1
  132. package/src/svgrid-wrapper.types.ts +12 -1
  133. package/src/svgrid.behavior.test.ts +22 -0
  134. package/src/svgrid.comments-autocomplete.test.ts +112 -0
  135. package/src/svgrid.context-menu.test.ts +126 -0
  136. package/src/svgrid.interaction.test.ts +30 -0
  137. package/src/svgrid.new-features.wrapper.test.ts +65 -4
  138. package/src/svgrid.wrapper.test.ts +27 -1
  139. package/src/test-setup.ts +9 -6
  140. package/src/virtualization/scroll-scaling.test.ts +148 -0
  141. package/src/virtualization/scroll-scaling.ts +121 -0
  142. package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
  143. 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
- function axisDomain(list, categories, stacked, extra = []) {
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
- if (stackable.length) {
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
- const yOf = (dom, v) => round(padT + plotH - ((v - dom.min) / (dom.max - dom.min || 1)) * plotH);
422
- const yLeft = (v) => yOf(leftDom, v);
423
- const yRight = (v) => yOf(rightDom ?? leftDom, v);
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 base = yOf(dom, Math.min(Math.max(0, dom.min), dom.max));
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 yA = (v) => yOf(dom, v);
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 in segments, breaking at gaps (undefined points).
564
- let path = '';
565
- let pen = false;
566
- for (const p of pts) {
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 = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
579
- const back = baselinePts.slice().reverse().map((p) => `L${p.x},${p.y}`).join(' ');
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 = run.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
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
- lines.push({ path, areaPath, color: s.color, label: s.label, points: pts });
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 dom = ref.axis === 'right' ? (rightDom ?? leftDom) : leftDom;
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) => ({ label: e.label, values: order.map((i) => e.values[i]) }));
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,