@perspective-dev/viewer-charts 4.5.2 → 5.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 (61) 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 +8 -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 +8 -2
  9. package/dist/esm/charts/series/glyphs/draw-scatter.d.ts +8 -2
  10. package/dist/esm/charts/series/series-build.d.ts +11 -0
  11. package/dist/esm/charts/series/series-interact.d.ts +13 -2
  12. package/dist/esm/charts/series/series-render.d.ts +17 -1
  13. package/dist/esm/charts/series/series.d.ts +104 -10
  14. package/dist/esm/data/view-reader.d.ts +10 -1
  15. package/dist/esm/event-detail.d.ts +7 -0
  16. package/dist/esm/layout/ticks.d.ts +5 -0
  17. package/dist/esm/perspective-viewer-charts.js +2 -2
  18. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  19. package/dist/esm/plugin/charts.d.ts +6 -3
  20. package/dist/esm/plugin/plugin.d.ts +40 -2
  21. package/dist/esm/render/scheduler.d.ts +11 -8
  22. package/dist/esm/transport/protocol.d.ts +39 -2
  23. package/dist/esm/transport/renderer-transport.d.ts +75 -3
  24. package/dist/esm/webgl/instanced-attrs.d.ts +7 -1
  25. package/dist/esm/worker/renderer.worker.d.ts +12 -0
  26. package/package.json +1 -1
  27. package/src/ts/charts/candlestick/candlestick-render.ts +4 -1
  28. package/src/ts/charts/candlestick/candlestick.ts +12 -17
  29. package/src/ts/charts/cartesian/cartesian-render.ts +80 -63
  30. package/src/ts/charts/chart-base.ts +85 -5
  31. package/src/ts/charts/chart.ts +47 -9
  32. package/src/ts/charts/common/category-axis-resolver.ts +5 -1
  33. package/src/ts/charts/common/tree-data.ts +61 -16
  34. package/src/ts/charts/heatmap/heatmap-render.ts +6 -2
  35. package/src/ts/charts/series/glyphs/draw-areas.ts +21 -1
  36. package/src/ts/charts/series/glyphs/draw-bars.ts +30 -9
  37. package/src/ts/charts/series/glyphs/draw-lines.ts +21 -1
  38. package/src/ts/charts/series/glyphs/draw-scatter.ts +68 -3
  39. package/src/ts/charts/series/series-build.ts +21 -3
  40. package/src/ts/charts/series/series-interact.ts +210 -78
  41. package/src/ts/charts/series/series-render.ts +779 -49
  42. package/src/ts/charts/series/series.ts +209 -26
  43. package/src/ts/charts/sunburst/sunburst-interact.ts +2 -3
  44. package/src/ts/charts/sunburst/sunburst-render.ts +3 -1
  45. package/src/ts/charts/treemap/treemap-interact.ts +2 -5
  46. package/src/ts/charts/treemap/treemap-render.ts +3 -1
  47. package/src/ts/charts/treemap/treemap.ts +6 -1
  48. package/src/ts/data/view-reader.ts +22 -2
  49. package/src/ts/event-detail.ts +8 -0
  50. package/src/ts/index.ts +13 -7
  51. package/src/ts/interaction/raw-event-forwarder.ts +7 -0
  52. package/src/ts/interaction/zoom-controller.ts +8 -0
  53. package/src/ts/layout/ticks.ts +9 -0
  54. package/src/ts/plugin/charts.ts +20 -4
  55. package/src/ts/plugin/plugin.ts +99 -17
  56. package/src/ts/render/scheduler.ts +39 -13
  57. package/src/ts/transport/protocol.ts +42 -0
  58. package/src/ts/transport/renderer-transport.ts +198 -9
  59. package/src/ts/webgl/instanced-attrs.ts +15 -1
  60. package/src/ts/worker/dispatch.ts +4 -1
  61. package/src/ts/worker/renderer.worker.ts +35 -3
@@ -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) and/or `aggRange` (mixed glyph-run frames)
272
+ * instead draw each matching series' contiguous bucket sub-range —
273
+ * one `drawArrays(POINTS, first, count)` per matching series.
245
274
  */
246
275
  draw(
247
276
  chart: SeriesChart,
@@ -249,6 +278,8 @@ export class ScatterGlyph {
249
278
  glManager: WebGLContextManager,
250
279
  projLeft: Float32Array,
251
280
  projRight: Float32Array,
281
+ splitFilter?: number,
282
+ aggRange?: { start: number; end: number },
252
283
  ): void {
253
284
  const buf = this._buffers;
254
285
  const cache = this._program;
@@ -267,6 +298,39 @@ export class ScatterGlyph {
267
298
  chart._pluginConfig.point_size_px * dpr,
268
299
  );
269
300
 
301
+ if (splitFilter !== undefined || aggRange !== undefined) {
302
+ for (const r of buf.seriesRanges) {
303
+ if (
304
+ splitFilter !== undefined &&
305
+ chart._series[r.seriesId].splitIdx !== splitFilter
306
+ ) {
307
+ continue;
308
+ }
309
+
310
+ const aggIdx = chart._series[r.seriesId].aggIdx;
311
+ if (
312
+ aggRange !== undefined &&
313
+ (aggIdx < aggRange.start || aggIdx > aggRange.end)
314
+ ) {
315
+ continue;
316
+ }
317
+
318
+ drawBucket(
319
+ gl,
320
+ cache,
321
+ r.axis === 1 ? cache.posRightBuffer : cache.posLeftBuffer,
322
+ r.axis === 1
323
+ ? cache.colorRightBuffer
324
+ : cache.colorLeftBuffer,
325
+ r.count,
326
+ r.axis === 1 ? projRight : projLeft,
327
+ r.first,
328
+ );
329
+ }
330
+
331
+ return;
332
+ }
333
+
270
334
  drawBucket(
271
335
  gl,
272
336
  cache,
@@ -309,6 +373,7 @@ function drawBucket(
309
373
  colBuf: WebGLBuffer,
310
374
  count: number,
311
375
  proj: Float32Array,
376
+ first = 0,
312
377
  ): void {
313
378
  if (count === 0) {
314
379
  return;
@@ -324,5 +389,5 @@ function drawBucket(
324
389
  gl.enableVertexAttribArray(cache.a_color);
325
390
  gl.vertexAttribPointer(cache.a_color, 3, gl.FLOAT, false, 0, 0);
326
391
 
327
- gl.drawArrays(gl.POINTS, 0, count);
392
+ gl.drawArrays(gl.POINTS, first, count);
328
393
  }
@@ -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) {
@@ -18,10 +18,10 @@ import {
18
18
  type SeriesChartRecord,
19
19
  } from "./series-build";
20
20
  import {
21
- renderBarFrame,
22
21
  uploadBarInstances,
23
22
  rebuildGlyphBuffers,
24
23
  rightAxisDataToPixel,
24
+ layoutForRecord,
25
25
  } from "./series-render";
26
26
 
27
27
  const POINT_HIT_RADIUS_PX = 10;
@@ -47,9 +47,16 @@ export function getHoveredBar(chart: SeriesChart): SeriesChartRecord | null {
47
47
  }
48
48
 
49
49
  /**
50
- * Handle mouse-move across all glyph types. Tests (in reverse paint order
51
- * so top glyphs win): scatter points → line points → bars → areas.
50
+ * Handle mouse-move across all glyph types. Tests glyph runs in reverse
51
+ * paint order so top glyphs win — paint order is the `columns`
52
+ * declaration order (see `_glyphRuns` / `drawGlyphRuns`).
52
53
  * Updates `_hoveredBarIdx` or `_hoveredSample` and re-renders on change.
54
+ *
55
+ * Faceted frames first resolve the cell under the cursor: that cell's
56
+ * layout supplies the pixel→data mapping and its index becomes the
57
+ * `splitFilter` — records of other splits share the same band-slot
58
+ * coordinates by construction, so an unfiltered hit-test would match
59
+ * a bar from a different facet.
53
60
  */
54
61
  export function handleBarHover(
55
62
  chart: SeriesChart,
@@ -60,7 +67,33 @@ export function handleBarHover(
60
67
  return;
61
68
  }
62
69
 
63
- const layout = chart._lastLayout;
70
+ let layout = chart._lastLayout;
71
+ let splitFilter: number | undefined;
72
+ if (chart._facetGrid) {
73
+ const cells = chart._facetGrid.cells;
74
+ let found = -1;
75
+ for (let i = 0; i < cells.length; i++) {
76
+ const p = cells[i].layout.plotRect;
77
+ if (
78
+ mx >= p.x &&
79
+ mx <= p.x + p.width &&
80
+ my >= p.y &&
81
+ my <= p.y + p.height
82
+ ) {
83
+ found = i;
84
+ break;
85
+ }
86
+ }
87
+
88
+ if (found < 0) {
89
+ clearHover(chart);
90
+ return;
91
+ }
92
+
93
+ layout = cells[found].layout;
94
+ splitFilter = found;
95
+ }
96
+
64
97
  const plot = layout.plotRect;
65
98
 
66
99
  if (
@@ -121,83 +154,131 @@ export function handleBarHover(
121
154
  let nextBarIdx = -1;
122
155
  let nextSample: SeriesChartRecord | null = null;
123
156
 
124
- // 1. Scatter (top).
125
- nextSample = hitTestPoints(
126
- chart,
127
- "scatter",
128
- dataX,
129
- dataYLeft,
130
- dataYRight,
131
- pxPerDataX,
132
- pxPerDataYLeft,
133
- pxPerDataYRight,
134
- );
157
+ // Test topmost-first: the REVERSE of the paint order, which is the
158
+ // `columns` declaration order (`_glyphRuns`, ascending `aggIdx` —
159
+ // see `drawGlyphRuns`). A single-run chart passes no `aggRange`,
160
+ // reducing to the legacy whole-type scan.
161
+ const runs = chart._glyphRuns;
162
+ const single = runs.length <= 1;
163
+ for (let r = runs.length - 1; r >= 0; r--) {
164
+ const run = runs[r];
165
+ const aggRange = single
166
+ ? undefined
167
+ : { start: run.aggStart, end: run.aggEnd };
168
+ switch (run.chartType) {
169
+ case "scatter":
170
+ case "line":
171
+ nextSample = hitTestPoints(
172
+ chart,
173
+ run.chartType,
174
+ dataX,
175
+ dataYLeft,
176
+ dataYRight,
177
+ pxPerDataX,
178
+ pxPerDataYLeft,
179
+ pxPerDataYRight,
180
+ splitFilter,
181
+ aggRange,
182
+ );
183
+ break;
184
+ case "bar":
185
+ nextBarIdx = hitTestBars(
186
+ chart,
187
+ dataX,
188
+ dataYLeft,
189
+ dataYRight,
190
+ splitFilter,
191
+ aggRange,
192
+ );
193
+ break;
194
+ case "area": {
195
+ const areaHit = hitTestAreas(
196
+ chart,
197
+ dataX,
198
+ dataYLeft,
199
+ dataYRight,
200
+ splitFilter,
201
+ aggRange,
202
+ );
203
+ if (areaHit) {
204
+ if (areaHit.idx >= 0) {
205
+ nextBarIdx = areaHit.idx;
206
+ } else {
207
+ nextSample = areaHit.bar;
208
+ }
209
+ }
135
210
 
136
- // 2. Line points (still above bars; treat as point hits).
137
- if (!nextSample) {
138
- nextSample = hitTestPoints(
139
- chart,
140
- "line",
141
- dataX,
142
- dataYLeft,
143
- dataYRight,
144
- pxPerDataX,
145
- pxPerDataYLeft,
146
- pxPerDataYRight,
147
- );
211
+ break;
212
+ }
213
+ }
214
+
215
+ if (nextBarIdx >= 0 || nextSample) {
216
+ break;
217
+ }
148
218
  }
149
219
 
150
- // 3. Bars (rect intersect).
151
- if (!nextSample) {
152
- const bars = chart._bars;
153
- const ct = bars.chartType;
154
- const sid = bars.seriesId;
155
- const xC = bars.xCenter;
156
- const hw = bars.halfWidth;
157
- const by0 = bars.y0;
158
- const by1 = bars.y1;
159
- const ax = bars.axis;
160
- const hidden = chart._hiddenSeries;
161
- for (let i = 0; i < bars.count; i++) {
162
- if (ct[i] !== BAR_TYPE_BAR) {
163
- continue;
164
- }
220
+ applyHover(chart, nextBarIdx, nextSample);
221
+ }
165
222
 
166
- if (hidden.has(sid[i])) {
167
- continue;
168
- }
223
+ /**
224
+ * Rect-intersect hit-test over the bar-typed `_bars` records. Returns
225
+ * the record index, or `-1`.
226
+ */
227
+ function hitTestBars(
228
+ chart: SeriesChart,
229
+ dataX: number,
230
+ dataYLeft: number,
231
+ dataYRight: number,
232
+ splitFilter?: number,
233
+ aggRange?: { start: number; end: number },
234
+ ): number {
235
+ const bars = chart._bars;
236
+ const ct = bars.chartType;
237
+ const sid = bars.seriesId;
238
+ const xC = bars.xCenter;
239
+ const hw = bars.halfWidth;
240
+ const by0 = bars.y0;
241
+ const by1 = bars.y1;
242
+ const ax = bars.axis;
243
+ const hidden = chart._hiddenSeries;
244
+ const P = Math.max(1, chart._splitPrefixes.length);
245
+ for (let i = 0; i < bars.count; i++) {
246
+ if (ct[i] !== BAR_TYPE_BAR) {
247
+ continue;
248
+ }
169
249
 
170
- const xc = xC[i];
171
- const halfW = hw[i];
172
- if (dataX < xc - halfW || dataX > xc + halfW) {
250
+ if (hidden.has(sid[i])) {
251
+ continue;
252
+ }
253
+
254
+ if (splitFilter !== undefined && sid[i] % P !== splitFilter) {
255
+ continue;
256
+ }
257
+
258
+ if (aggRange !== undefined) {
259
+ const aggIdx = Math.floor(sid[i] / P);
260
+ if (aggIdx < aggRange.start || aggIdx > aggRange.end) {
173
261
  continue;
174
262
  }
263
+ }
175
264
 
176
- const dy = ax[i] === 0 ? dataYLeft : dataYRight;
177
- const y0 = by0[i];
178
- const y1 = by1[i];
179
- const lo = y0 < y1 ? y0 : y1;
180
- const hi = y0 < y1 ? y1 : y0;
181
- if (dy >= lo && dy <= hi) {
182
- nextBarIdx = i;
183
- break;
184
- }
265
+ const xc = xC[i];
266
+ const halfW = hw[i];
267
+ if (dataX < xc - halfW || dataX > xc + halfW) {
268
+ continue;
185
269
  }
186
- }
187
270
 
188
- // 4. Areas (strip hit — stacked records via `_bars`, unstacked via samples).
189
- if (nextBarIdx < 0 && !nextSample) {
190
- const areaHit = hitTestAreas(chart, dataX, dataYLeft, dataYRight);
191
- if (areaHit) {
192
- if (areaHit.idx >= 0) {
193
- nextBarIdx = areaHit.idx;
194
- } else {
195
- nextSample = areaHit.bar;
196
- }
271
+ const dy = ax[i] === 0 ? dataYLeft : dataYRight;
272
+ const y0 = by0[i];
273
+ const y1 = by1[i];
274
+ const lo = y0 < y1 ? y0 : y1;
275
+ const hi = y0 < y1 ? y1 : y0;
276
+ if (dy >= lo && dy <= hi) {
277
+ return i;
197
278
  }
198
279
  }
199
280
 
200
- applyHover(chart, nextBarIdx, nextSample);
281
+ return -1;
201
282
  }
202
283
 
203
284
  function hitTestPoints(
@@ -209,6 +290,8 @@ function hitTestPoints(
209
290
  pxPerDataX: number,
210
291
  pxPerDataYLeft: number,
211
292
  pxPerDataYRight: number,
293
+ splitFilter?: number,
294
+ aggRange?: { start: number; end: number },
212
295
  ): SeriesChartRecord | null {
213
296
  const N = chart._numCategories;
214
297
  const S = chart._series.length;
@@ -233,6 +316,17 @@ function hitTestPoints(
233
316
  continue;
234
317
  }
235
318
 
319
+ if (splitFilter !== undefined && s.splitIdx !== splitFilter) {
320
+ continue;
321
+ }
322
+
323
+ if (
324
+ aggRange !== undefined &&
325
+ (s.aggIdx < aggRange.start || s.aggIdx > aggRange.end)
326
+ ) {
327
+ continue;
328
+ }
329
+
236
330
  const dataY = s.axis === 1 ? dataYRight : dataYLeft;
237
331
  const pyPerData = s.axis === 1 ? pxPerDataYRight : pxPerDataYLeft;
238
332
 
@@ -289,6 +383,8 @@ function hitTestAreas(
289
383
  dataX: number,
290
384
  dataYLeft: number,
291
385
  dataYRight: number,
386
+ splitFilter?: number,
387
+ aggRange?: { start: number; end: number },
292
388
  ): { idx: number; bar: SeriesChartRecord | null } | null {
293
389
  // Closest category to the mouse; an area covers every [cat - 0.5, cat + 0.5]
294
390
  // slot, so use `round(dataX)` as the candidate index.
@@ -313,6 +409,7 @@ function hitTestAreas(
313
409
  const ax = bars.axis;
314
410
  const by0 = bars.y0;
315
411
  const by1 = bars.y1;
412
+ const P = chart._splitPrefixes.length;
316
413
  for (let i = 0; i < bars.count; i++) {
317
414
  if (ct[i] !== BAR_TYPE_AREA) {
318
415
  continue;
@@ -326,6 +423,17 @@ function hitTestAreas(
326
423
  continue;
327
424
  }
328
425
 
426
+ if (splitFilter !== undefined && sid[i] % P !== splitFilter) {
427
+ continue;
428
+ }
429
+
430
+ if (aggRange !== undefined) {
431
+ const aggIdx = Math.floor(sid[i] / Math.max(1, P));
432
+ if (aggIdx < aggRange.start || aggIdx > aggRange.end) {
433
+ continue;
434
+ }
435
+ }
436
+
329
437
  const dy = ax[i] === 0 ? dataYLeft : dataYRight;
330
438
  const y0 = by0[i];
331
439
  const y1 = by1[i];
@@ -346,6 +454,17 @@ function hitTestAreas(
346
454
  continue;
347
455
  }
348
456
 
457
+ if (splitFilter !== undefined && s.splitIdx !== splitFilter) {
458
+ continue;
459
+ }
460
+
461
+ if (
462
+ aggRange !== undefined &&
463
+ (s.aggIdx < aggRange.start || s.aggIdx > aggRange.end)
464
+ ) {
465
+ continue;
466
+ }
467
+
349
468
  const idx = cat * S + s.seriesId;
350
469
  if (!((valid[idx >> 3] >> (idx & 7)) & 1)) {
351
470
  continue;
@@ -383,7 +502,7 @@ function clearHover(chart: SeriesChart): void {
383
502
  chart._hoveredBarIdx = -1;
384
503
  chart._hoveredSample = null;
385
504
  if (chart._glManager) {
386
- renderBarFrame(chart, chart._glManager);
505
+ chart.requestRender(chart._glManager);
387
506
  }
388
507
  }
389
508
  }
@@ -405,13 +524,17 @@ function applyHover(
405
524
  chart._hoveredBarIdx = nextBarIdx;
406
525
  chart._hoveredSample = nextSample;
407
526
  if (chart._glManager) {
408
- renderBarFrame(chart, chart._glManager);
527
+ chart.requestRender(chart._glManager);
409
528
  }
410
529
  }
411
530
 
412
531
  /**
413
532
  * Handle a click on the legend area. Returns true when the click hit a
414
533
  * legend entry (the caller should then treat the event as consumed).
534
+ *
535
+ * An entry may own several series (facet-grid mode groups the legend
536
+ * by aggregate): the toggle is all-or-nothing — any visible member
537
+ * hides the whole group, a fully-hidden group shows every member.
415
538
  */
416
539
  export function handleBarLegendClick(
417
540
  chart: SeriesChart,
@@ -430,10 +553,15 @@ export function handleBarLegendClick(
430
553
  my >= r.y &&
431
554
  my <= r.y + r.height
432
555
  ) {
433
- if (chart._hiddenSeries.has(entry.seriesId)) {
434
- chart._hiddenSeries.delete(entry.seriesId);
435
- } else {
436
- chart._hiddenSeries.add(entry.seriesId);
556
+ const anyVisible = entry.seriesIds.some(
557
+ (sid) => !chart._hiddenSeries.has(sid),
558
+ );
559
+ for (const sid of entry.seriesIds) {
560
+ if (anyVisible) {
561
+ chart._hiddenSeries.add(sid);
562
+ } else {
563
+ chart._hiddenSeries.delete(sid);
564
+ }
437
565
  }
438
566
 
439
567
  // Hidden-series change affects which bars contribute to
@@ -454,7 +582,7 @@ export function handleBarLegendClick(
454
582
  if (chart._glManager) {
455
583
  uploadBarInstances(chart, chart._glManager);
456
584
  rebuildGlyphBuffers(chart, chart._glManager);
457
- renderBarFrame(chart, chart._glManager);
585
+ chart.requestRender(chart._glManager);
458
586
  }
459
587
 
460
588
  return true;
@@ -571,7 +699,11 @@ function pinTooltip(chart: SeriesChart, b: SeriesChartRecord): void {
571
699
  return;
572
700
  }
573
701
 
574
- const layout = chart._lastLayout;
702
+ // Faceted frames anchor in the record's own cell.
703
+ const layout = layoutForRecord(chart, b);
704
+ if (!layout) {
705
+ return;
706
+ }
575
707
 
576
708
  // Anchor at the bar midpoint for bar glyphs (tooltip reads against
577
709
  // the body); at the point itself (`y1`) for line / scatter / area.
@@ -582,7 +714,7 @@ function pinTooltip(chart: SeriesChart, b: SeriesChartRecord): void {
582
714
  ? chart._isHorizontal
583
715
  ? layout.dataToPixel(anchorV, b.xCenter)
584
716
  : layout.dataToPixel(b.xCenter, anchorV)
585
- : rightAxisDataToPixel(chart, b.xCenter, anchorV);
717
+ : rightAxisDataToPixel(chart, b.xCenter, anchorV, layout);
586
718
 
587
719
  const lines = buildBarTooltipLines(chart, b);
588
720
  if (lines.length === 0) {
@@ -594,7 +726,7 @@ function pinTooltip(chart: SeriesChart, b: SeriesChartRecord): void {
594
726
  chart._hoveredBarIdx = -1;
595
727
  chart._hoveredSample = null;
596
728
  if (chart._glManager) {
597
- renderBarFrame(chart, chart._glManager);
729
+ chart.requestRender(chart._glManager);
598
730
  }
599
731
  }
600
732