@perspective-dev/viewer-charts 4.5.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/cdn/perspective-viewer-charts.js +2 -2
  2. package/dist/cdn/perspective-viewer-charts.js.map +3 -3
  3. package/dist/esm/charts/candlestick/candlestick.d.ts +8 -10
  4. package/dist/esm/charts/chart-base.d.ts +58 -4
  5. package/dist/esm/charts/chart.d.ts +45 -9
  6. package/dist/esm/charts/series/glyphs/draw-areas.d.ts +3 -2
  7. package/dist/esm/charts/series/glyphs/draw-bars.d.ts +8 -1
  8. package/dist/esm/charts/series/glyphs/draw-lines.d.ts +3 -2
  9. package/dist/esm/charts/series/glyphs/draw-scatter.d.ts +5 -2
  10. package/dist/esm/charts/series/series-build.d.ts +11 -0
  11. package/dist/esm/charts/series/series-interact.d.ts +10 -0
  12. package/dist/esm/charts/series/series-render.d.ts +17 -1
  13. package/dist/esm/charts/series/series.d.ts +53 -10
  14. package/dist/esm/config.d.ts +19 -0
  15. package/dist/esm/data/view-reader.d.ts +10 -1
  16. package/dist/esm/event-detail.d.ts +7 -0
  17. package/dist/esm/layout/ticks.d.ts +5 -0
  18. package/dist/esm/perspective-viewer-charts.js +2 -2
  19. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  20. package/dist/esm/plugin/charts.d.ts +6 -3
  21. package/dist/esm/plugin/plugin.d.ts +46 -4
  22. package/dist/esm/render/scheduler.d.ts +34 -8
  23. package/dist/esm/transport/protocol.d.ts +31 -2
  24. package/dist/esm/transport/renderer-transport.d.ts +74 -3
  25. package/dist/esm/webgl/context-manager.d.ts +100 -117
  26. package/dist/esm/webgl/context-pool.d.ts +40 -0
  27. package/dist/esm/webgl/gl-context.d.ts +54 -0
  28. package/dist/esm/webgl/instanced-attrs.d.ts +7 -1
  29. package/dist/esm/worker/renderer.worker.d.ts +12 -0
  30. package/package.json +1 -1
  31. package/src/ts/charts/candlestick/candlestick-render.ts +4 -1
  32. package/src/ts/charts/candlestick/candlestick.ts +12 -17
  33. package/src/ts/charts/cartesian/cartesian-render.ts +80 -63
  34. package/src/ts/charts/chart-base.ts +85 -5
  35. package/src/ts/charts/chart.ts +47 -9
  36. package/src/ts/charts/common/category-axis-resolver.ts +5 -1
  37. package/src/ts/charts/common/tree-data.ts +61 -16
  38. package/src/ts/charts/heatmap/heatmap-render.ts +6 -2
  39. package/src/ts/charts/series/glyphs/draw-areas.ts +10 -1
  40. package/src/ts/charts/series/glyphs/draw-bars.ts +30 -9
  41. package/src/ts/charts/series/glyphs/draw-lines.ts +10 -1
  42. package/src/ts/charts/series/glyphs/draw-scatter.ts +56 -3
  43. package/src/ts/charts/series/series-build.ts +21 -3
  44. package/src/ts/charts/series/series-interact.ts +86 -13
  45. package/src/ts/charts/series/series-render.ts +660 -17
  46. package/src/ts/charts/series/series.ts +96 -26
  47. package/src/ts/charts/sunburst/sunburst-interact.ts +2 -3
  48. package/src/ts/charts/sunburst/sunburst-render.ts +3 -1
  49. package/src/ts/charts/treemap/treemap-interact.ts +2 -5
  50. package/src/ts/charts/treemap/treemap-render.ts +3 -1
  51. package/src/ts/charts/treemap/treemap.ts +6 -1
  52. package/src/ts/config.ts +21 -1
  53. package/src/ts/data/view-reader.ts +22 -2
  54. package/src/ts/event-detail.ts +8 -0
  55. package/src/ts/index.ts +13 -7
  56. package/src/ts/interaction/raw-event-forwarder.ts +7 -0
  57. package/src/ts/interaction/zoom-controller.ts +8 -0
  58. package/src/ts/layout/ticks.ts +9 -0
  59. package/src/ts/plugin/charts.ts +20 -4
  60. package/src/ts/plugin/plugin.ts +157 -23
  61. package/src/ts/render/scheduler.ts +164 -55
  62. package/src/ts/transport/protocol.ts +33 -0
  63. package/src/ts/transport/renderer-transport.ts +185 -9
  64. package/src/ts/webgl/context-manager.ts +195 -189
  65. package/src/ts/webgl/context-pool.ts +78 -0
  66. package/src/ts/webgl/gl-context.ts +191 -0
  67. package/src/ts/webgl/instanced-attrs.ts +15 -1
  68. package/src/ts/worker/dispatch.ts +4 -1
  69. package/src/ts/worker/renderer.worker.ts +94 -22
  70. package/src/ts/worker/session-host.ts +29 -0
@@ -156,6 +156,33 @@ function insertRow(
156
156
  }
157
157
  }
158
158
 
159
+ /**
160
+ * Arrow validity-bitmap test. Columns without a bitmap are all-valid.
161
+ */
162
+ function isValidCell(col: ColumnData, rowIdx: number): boolean {
163
+ const v = col.valid;
164
+ return !v || !!((v[rowIdx >> 3] >> (rowIdx & 7)) & 1);
165
+ }
166
+
167
+ /**
168
+ * Read a size cell, honoring the validity bitmap: the bytes under an
169
+ * invalid slot are UNDEFINED (DuckDB's Arrow leaves NaN where the
170
+ * engine zero-fills), and a NaN here would poison every ancestor sum
171
+ * in `finalizeTree`. Invalid → 0 (no contribution); no size column at
172
+ * all → 1 (count semantics).
173
+ */
174
+ function readSize(col: ColumnData | null | undefined, rowIdx: number): number {
175
+ if (!col?.values) {
176
+ return 1;
177
+ }
178
+
179
+ if (!isValidCell(col, rowIdx)) {
180
+ return 0;
181
+ }
182
+
183
+ return col.values[rowIdx] as number;
184
+ }
185
+
159
186
  function readColor(
160
187
  chart: TreeChartBase,
161
188
  colorCol: ColumnData | null | undefined,
@@ -163,7 +190,7 @@ function readColor(
163
190
  ): { colorValue: number; colorLabel: string } {
164
191
  let colorValue = NaN;
165
192
  let colorLabel = "";
166
- if (!colorCol) {
193
+ if (!colorCol || !isValidCell(colorCol, rowIdx)) {
167
194
  return { colorValue, colorLabel };
168
195
  }
169
196
 
@@ -261,7 +288,11 @@ export function processTreeChunk(
261
288
  chart: TreeChartBase,
262
289
  columns: ColumnDataMap,
263
290
  ): void {
264
- const rpCols: { indices: Int32Array; dictionary: string[] }[] = [];
291
+ const rpCols: {
292
+ indices: Int32Array;
293
+ dictionary: string[];
294
+ valid?: Uint8Array;
295
+ }[] = [];
265
296
  for (let n = 0; ; n++) {
266
297
  const rp = columns.get(`__ROW_PATH_${n}__`);
267
298
  if (!rp) {
@@ -269,7 +300,11 @@ export function processTreeChunk(
269
300
  }
270
301
 
271
302
  if (rp.type === "string" && rp.indices && rp.dictionary) {
272
- rpCols.push({ indices: rp.indices, dictionary: rp.dictionary });
303
+ rpCols.push({
304
+ indices: rp.indices,
305
+ dictionary: rp.dictionary,
306
+ valid: rp.valid,
307
+ });
273
308
  } else if (rp.values) {
274
309
  // Non-string group_by (integer / float / date / datetime /
275
310
  // boolean) — synthesize a string-indexed dictionary so the
@@ -397,9 +432,7 @@ export function processTreeChunk(
397
432
  // Pass the signed value through; `insertRow` stores
398
433
  // `|size|` and `sizeSign` separately so the
399
434
  // render pass can dim negative leaves.
400
- const sizeValue = src.sizeCol?.values
401
- ? (src.sizeCol.values[i] as number)
402
- : 1;
435
+ const sizeValue = readSize(src.sizeCol, i);
403
436
  const { colorValue, colorLabel } = readColor(
404
437
  chart,
405
438
  src.colorCol,
@@ -416,9 +449,7 @@ export function processTreeChunk(
416
449
  );
417
450
  }
418
451
  } else {
419
- const sizeValue = sizeCol?.values
420
- ? (sizeCol.values[i] as number)
421
- : 1;
452
+ const sizeValue = readSize(sizeCol, i);
422
453
  const { colorValue, colorLabel } = readColor(
423
454
  chart,
424
455
  colorCol,
@@ -449,6 +480,19 @@ export function processTreeChunk(
449
480
  let pathLen = 0;
450
481
  for (let d = 0; d < rpCols.length; d++) {
451
482
  const rp = rpCols[d];
483
+
484
+ // In `"rollup"` mode the view emits total / subtotal rows
485
+ // whose deeper row-path levels are INVALID — index slot 0,
486
+ // which decodes to a REAL dictionary value (the engine's
487
+ // string row-path dictionaries carry no `""` sentinel), so
488
+ // the validity bitmap is the only truthful signal. Truncate
489
+ // the path at the first invalid level; the `!label` check
490
+ // below still covers synthesized non-string levels, whose
491
+ // dictionary index 0 IS the `""` sentinel.
492
+ if (rp.valid && !((rp.valid[i >> 3] >> (i & 7)) & 1)) {
493
+ break;
494
+ }
495
+
452
496
  const label = rp.dictionary[rp.indices[i]];
453
497
  if (!label && label !== "0") {
454
498
  break;
@@ -465,9 +509,7 @@ export function processTreeChunk(
465
509
  for (const src of splitSources!) {
466
510
  pathScratch[0] = src.prefix;
467
511
  const rowPath = pathScratch.slice(0, pathLen + 1);
468
- const sizeValue = src.sizeCol?.values
469
- ? (src.sizeCol.values[i] as number)
470
- : 1;
512
+ const sizeValue = readSize(src.sizeCol, i);
471
513
  const { colorValue, colorLabel } = readColor(
472
514
  chart,
473
515
  src.colorCol,
@@ -485,9 +527,7 @@ export function processTreeChunk(
485
527
  }
486
528
  } else {
487
529
  const rowPath = pathScratch.slice(0, pathLen);
488
- const sizeValue = sizeCol?.values
489
- ? (sizeCol.values[i] as number)
490
- : 1;
530
+ const sizeValue = readSize(sizeCol, i);
491
531
  const { colorValue, colorLabel } = readColor(chart, colorCol, i);
492
532
  insertRow(
493
533
  chart,
@@ -556,7 +596,12 @@ export function finalizeTree(chart: TreeChartBase): void {
556
596
  }
557
597
  } else {
558
598
  if (firstChild[id] === NULL_NODE) {
559
- value[id] = Math.max(0, size[id]);
599
+ // NOT `Math.max(0, x)`: that maps NaN → NaN, and one NaN
600
+ // leaf would poison every ancestor sum (dropping whole
601
+ // subtrees in `squarify`'s `value > 0` filter). The
602
+ // comparison form maps NaN → 0.
603
+ const s = size[id];
604
+ value[id] = s > 0 ? s : 0;
560
605
  } else {
561
606
  let sum = 0;
562
607
  for (
@@ -225,7 +225,9 @@ export function renderHeatmapFrame(
225
225
  drawCellsInstanced(chart, gl, glManager, 0, chart._uploadedCells);
226
226
  });
227
227
 
228
- renderHeatmapChromeOverlay(chart);
228
+ // Deferred past the GPU fence (see `_defer2D`) so the chrome canvas
229
+ // doesn't present ahead of the GL cells on resize.
230
+ chart._defer2D(() => renderHeatmapChromeOverlay(chart));
229
231
  }
230
232
 
231
233
  function ensureProgram(
@@ -650,7 +652,9 @@ function renderFacetedHeatmap(
650
652
  });
651
653
  }
652
654
 
653
- renderHeatmapChromeOverlay(chart);
655
+ // Deferred past the GPU fence (see `_defer2D`) so the chrome canvas
656
+ // doesn't present ahead of the GL cells on resize.
657
+ chart._defer2D(() => renderHeatmapChromeOverlay(chart));
654
658
  }
655
659
 
656
660
  /**
@@ -187,7 +187,8 @@ export class AreaGlyph {
187
187
 
188
188
  /**
189
189
  * Bind persistent strip buffers and dispatch one TRIANGLE_STRIP per
190
- * series-run. Skips hidden series.
190
+ * series-run. Skips hidden series. `splitFilter` (faceted frames)
191
+ * draws only the series whose `splitIdx` matches.
191
192
  */
192
193
  draw(
193
194
  chart: SeriesChart,
@@ -196,6 +197,7 @@ export class AreaGlyph {
196
197
  projLeft: Float32Array,
197
198
  projRight: Float32Array,
198
199
  opacity: number,
200
+ splitFilter?: number,
199
201
  ): void {
200
202
  const buf = this._buffers;
201
203
  const cache = this._program;
@@ -212,6 +214,13 @@ export class AreaGlyph {
212
214
  continue;
213
215
  }
214
216
 
217
+ if (
218
+ splitFilter !== undefined &&
219
+ chart._series[s.seriesId].splitIdx !== splitFilter
220
+ ) {
221
+ continue;
222
+ }
223
+
215
224
  gl.uniformMatrix4fv(
216
225
  cache.u_projection,
217
226
  false,
@@ -30,13 +30,20 @@ type GL = WebGL2RenderingContext | WebGLRenderingContext;
30
30
  /**
31
31
  * Draw the bar-typed subset of `chart._bars` as instanced quads. Assumes
32
32
  * the caller has already `useProgram`'d the bar shader and set uniforms.
33
+ *
34
+ * `range` draws a contiguous instance sub-range instead of the full
35
+ * uploaded set — the faceted renderer uploads instances split-major
36
+ * (see `uploadBarInstances`) and dispatches one range per facet.
33
37
  */
34
38
  export function drawBars(
35
39
  chart: SeriesChart,
36
40
  gl: GL,
37
41
  glManager: WebGLContextManager,
42
+ range?: { start: number; count: number },
38
43
  ): void {
39
- if (chart._uploadedBars === 0) {
44
+ const first = range?.start ?? 0;
45
+ const count = range?.count ?? chart._uploadedBars;
46
+ if (chart._uploadedBars === 0 || count === 0) {
40
47
  return;
41
48
  }
42
49
 
@@ -62,6 +69,7 @@ export function drawBars(
62
69
  loc.a_x_center,
63
70
  "bar_x",
64
71
  1,
72
+ first,
65
73
  ) &&
66
74
  bindInstancedFloatAttr(
67
75
  glManager,
@@ -69,15 +77,31 @@ export function drawBars(
69
77
  loc.a_half_width,
70
78
  "bar_hw",
71
79
  1,
80
+ first,
81
+ ) &&
82
+ bindInstancedFloatAttr(
83
+ glManager,
84
+ instancing,
85
+ loc.a_y0,
86
+ "bar_y0",
87
+ 1,
88
+ first,
89
+ ) &&
90
+ bindInstancedFloatAttr(
91
+ glManager,
92
+ instancing,
93
+ loc.a_y1,
94
+ "bar_y1",
95
+ 1,
96
+ first,
72
97
  ) &&
73
- bindInstancedFloatAttr(glManager, instancing, loc.a_y0, "bar_y0", 1) &&
74
- bindInstancedFloatAttr(glManager, instancing, loc.a_y1, "bar_y1", 1) &&
75
98
  bindInstancedFloatAttr(
76
99
  glManager,
77
100
  instancing,
78
101
  loc.a_color,
79
102
  "bar_color",
80
103
  3,
104
+ first,
81
105
  ) &&
82
106
  bindInstancedFloatAttr(
83
107
  glManager,
@@ -85,6 +109,7 @@ export function drawBars(
85
109
  loc.a_series_id,
86
110
  "bar_sid",
87
111
  1,
112
+ first,
88
113
  ) &&
89
114
  bindInstancedFloatAttr(
90
115
  glManager,
@@ -92,15 +117,11 @@ export function drawBars(
92
117
  loc.a_axis,
93
118
  "bar_axis",
94
119
  1,
120
+ first,
95
121
  );
96
122
 
97
123
  if (ok) {
98
- instancing.drawArraysInstanced(
99
- gl.TRIANGLE_STRIP,
100
- 0,
101
- 4,
102
- chart._uploadedBars,
103
- );
124
+ instancing.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count);
104
125
  }
105
126
 
106
127
  setDivisor(loc.a_x_center, 0);
@@ -271,7 +271,8 @@ export class LineGlyph {
271
271
  * Bind the persistent vertex buffers and dispatch one instanced draw
272
272
  * per series. Skips hidden series via `_hiddenSeries`. Gap /
273
273
  * transparency rendering is governed by `u_interp_alpha`, set per
274
- * series.
274
+ * series. `splitFilter` (faceted frames) draws only the series
275
+ * whose `splitIdx` matches — one call per facet.
275
276
  */
276
277
  draw(
277
278
  chart: SeriesChart,
@@ -279,6 +280,7 @@ export class LineGlyph {
279
280
  glManager: WebGLContextManager,
280
281
  projLeft: Float32Array,
281
282
  projRight: Float32Array,
283
+ splitFilter?: number,
282
284
  ): void {
283
285
  const buf = this._buffers;
284
286
  const cache = this._program;
@@ -310,6 +312,13 @@ export class LineGlyph {
310
312
  continue;
311
313
  }
312
314
 
315
+ if (
316
+ splitFilter !== undefined &&
317
+ chart._series[s.seriesId].splitIdx !== splitFilter
318
+ ) {
319
+ continue;
320
+ }
321
+
313
322
  gl.uniformMatrix4fv(
314
323
  cache.u_projection,
315
324
  false,
@@ -34,10 +34,22 @@ interface ScatterProgramCache {
34
34
  * Persistent scatter glyph state — left/right axis position + color
35
35
  * buffers built once at data load. Pan/zoom redraws rebind without
36
36
  * uploading.
37
+ *
38
+ * `seriesRanges` records each visible series' contiguous run within
39
+ * its axis bucket (`first` is bucket-relative). The fill loop appends
40
+ * series in order, so runs are contiguous by construction; the
41
+ * faceted draw path uses them to dispatch per-facet sub-ranges via
42
+ * `drawArrays(POINTS, first, count)`.
37
43
  */
38
44
  interface ScatterBuffers {
39
45
  leftCount: number;
40
46
  rightCount: number;
47
+ seriesRanges: {
48
+ seriesId: number;
49
+ axis: 0 | 1;
50
+ first: number;
51
+ count: number;
52
+ }[];
41
53
  }
42
54
 
43
55
  /**
@@ -170,6 +182,9 @@ export class ScatterGlyph {
170
182
 
171
183
  // Fill left bucket from `[0, leftCount)`, right bucket from
172
184
  // `[leftCount, total)` — single typed-array allocation each.
185
+ // Series runs are contiguous within their bucket; record each
186
+ // run's bucket-relative `[first, count)` for the faceted draw.
187
+ const seriesRanges: ScatterBuffers["seriesRanges"] = [];
173
188
  let leftWrite = 0;
174
189
  let rightWrite = leftCount;
175
190
  for (const s of scatterSeries) {
@@ -177,6 +192,7 @@ export class ScatterGlyph {
177
192
  continue;
178
193
  }
179
194
 
195
+ const runFirst = s.axis === 1 ? rightWrite - leftCount : leftWrite;
180
196
  const r = s.color[0];
181
197
  const g = s.color[1];
182
198
  const b = s.color[2];
@@ -204,6 +220,16 @@ export class ScatterGlyph {
204
220
  leftWrite++;
205
221
  }
206
222
  }
223
+
224
+ const runEnd = s.axis === 1 ? rightWrite - leftCount : leftWrite;
225
+ if (runEnd > runFirst) {
226
+ seriesRanges.push({
227
+ seriesId: s.seriesId,
228
+ axis: s.axis,
229
+ first: runFirst,
230
+ count: runEnd - runFirst,
231
+ });
232
+ }
207
233
  }
208
234
 
209
235
  if (leftCount > 0) {
@@ -236,12 +262,15 @@ export class ScatterGlyph {
236
262
  );
237
263
  }
238
264
 
239
- this._buffers = { leftCount, rightCount };
265
+ this._buffers = { leftCount, rightCount, seriesRanges };
240
266
  }
241
267
 
242
268
  /**
243
269
  * Bind the persistent left/right buffers and issue up to two draw
244
- * calls. No per-frame allocations or buffer uploads.
270
+ * calls. No per-frame allocations or buffer uploads. `splitFilter`
271
+ * (faceted frames) instead draws each matching series' contiguous
272
+ * bucket sub-range — one `drawArrays(POINTS, first, count)` per
273
+ * series of the facet.
245
274
  */
246
275
  draw(
247
276
  chart: SeriesChart,
@@ -249,6 +278,7 @@ export class ScatterGlyph {
249
278
  glManager: WebGLContextManager,
250
279
  projLeft: Float32Array,
251
280
  projRight: Float32Array,
281
+ splitFilter?: number,
252
282
  ): void {
253
283
  const buf = this._buffers;
254
284
  const cache = this._program;
@@ -267,6 +297,28 @@ export class ScatterGlyph {
267
297
  chart._pluginConfig.point_size_px * dpr,
268
298
  );
269
299
 
300
+ if (splitFilter !== undefined) {
301
+ for (const r of buf.seriesRanges) {
302
+ if (chart._series[r.seriesId].splitIdx !== splitFilter) {
303
+ continue;
304
+ }
305
+
306
+ drawBucket(
307
+ gl,
308
+ cache,
309
+ r.axis === 1 ? cache.posRightBuffer : cache.posLeftBuffer,
310
+ r.axis === 1
311
+ ? cache.colorRightBuffer
312
+ : cache.colorLeftBuffer,
313
+ r.count,
314
+ r.axis === 1 ? projRight : projLeft,
315
+ r.first,
316
+ );
317
+ }
318
+
319
+ return;
320
+ }
321
+
270
322
  drawBucket(
271
323
  gl,
272
324
  cache,
@@ -309,6 +361,7 @@ function drawBucket(
309
361
  colBuf: WebGLBuffer,
310
362
  count: number,
311
363
  proj: Float32Array,
364
+ first = 0,
312
365
  ): void {
313
366
  if (count === 0) {
314
367
  return;
@@ -324,5 +377,5 @@ function drawBucket(
324
377
  gl.enableVertexAttribArray(cache.a_color);
325
378
  gl.vertexAttribPointer(cache.a_color, 3, gl.FLOAT, false, 0, 0);
326
379
 
327
- gl.drawArrays(gl.POINTS, 0, count);
380
+ gl.drawArrays(gl.POINTS, first, count);
328
381
  }
@@ -285,6 +285,18 @@ export interface SeriesPipelineInput {
285
285
  */
286
286
  includeZero: boolean;
287
287
 
288
+ /**
289
+ * `facet_mode: "grid"` geometry: key the stack ladder by
290
+ * `(catIdx, aggIdx, splitIdx)` instead of `(catIdx, aggIdx)`, so
291
+ * splits never stack on each other — each facet's bars / areas
292
+ * grow from their own zero baseline. Slot geometry (`xCenter` /
293
+ * `halfWidth`) is unchanged: facets separate splits by projection
294
+ * + scissor, not by X offset, so every split's records share the
295
+ * same band-slot coordinates. Value extents still accumulate
296
+ * across ALL splits — the facet grid renders a shared value axis.
297
+ */
298
+ facetSplits?: boolean;
299
+
288
300
  /**
289
301
  * Reusable scratch — pipeline writes records into these in place
290
302
  * and zero-fills the stack ladder. Pass the previous build's
@@ -404,6 +416,7 @@ export function buildSeriesPipeline(
404
416
  bandInnerFrac,
405
417
  barInnerPad,
406
418
  includeZero,
419
+ facetSplits,
407
420
  scratchBars,
408
421
  scratchPosStack,
409
422
  scratchNegStack,
@@ -529,9 +542,12 @@ export function buildSeriesPipeline(
529
542
  const N = numCategories;
530
543
  const S = series.length;
531
544
 
532
- // Stacking ladder, keyed by (catIdx, aggIdx). Reuse chart-owned
545
+ // Stacking ladder, keyed by (catIdx, aggIdx) — or per-split
546
+ // (catIdx, aggIdx, splitIdx) in facet-grid mode, where each split
547
+ // renders in its own facet and cross-split stacking would lift
548
+ // every facet after the first off its baseline. Reuse chart-owned
533
549
  // scratch when sized; else allocate. Active prefix is zero-filled.
534
- const stackLen = N * M;
550
+ const stackLen = facetSplits ? N * M * P : N * M;
535
551
  const posStack = ensureFloat64Scratch(scratchPosStack ?? null, stackLen);
536
552
  const negStack = ensureFloat64Scratch(scratchNegStack ?? null, stackLen);
537
553
  posStack.fill(0, 0, stackLen);
@@ -922,7 +938,9 @@ export function buildSeriesPipeline(
922
938
  }
923
939
  }
924
940
 
925
- const stackIdx = catI * M + k;
941
+ const stackIdx = facetSplits
942
+ ? (catI * M + k) * P + p
943
+ : catI * M + k;
926
944
  let y0: number;
927
945
  let y1: number;
928
946
  if (v >= 0) {