@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
@@ -13,6 +13,7 @@
13
13
  import type { ColumnDataMap } from "../../data/view-reader";
14
14
  import type { WebGLContextManager } from "../../webgl/context-manager";
15
15
  import type { ZoomConfig } from "../../interaction/zoom-controller";
16
+ import type { FacetGrid } from "../../layout/facet-grid";
16
17
  import { CategoricalYChart } from "../common/categorical-y-chart";
17
18
  import { type PlotRect } from "../../layout/plot-layout";
18
19
  import { type AxisDomain } from "../../axis/numeric-axis";
@@ -23,6 +24,7 @@ import {
23
24
  type SeriesChartRecord,
24
25
  type NumericCategoryDomain,
25
26
  type SeriesInfo,
27
+ type SeriesPipelineResult,
26
28
  type BarColumns,
27
29
  emptyBarColumns,
28
30
  } from "./series-build";
@@ -65,6 +67,16 @@ export interface SeriesAutoFitCache {
65
67
  hasRight: boolean;
66
68
  }
67
69
 
70
+ /**
71
+ * One paint pass: the contiguous span of aggregates (`columns` indices,
72
+ * inclusive) sharing a glyph type. See {@link SeriesChart._glyphRuns}.
73
+ */
74
+ export interface GlyphRun {
75
+ chartType: SeriesInfo["chartType"];
76
+ aggStart: number;
77
+ aggEnd: number;
78
+ }
79
+
68
80
  export interface CachedLocations {
69
81
  u_proj_left: WebGLUniformLocation | null;
70
82
  u_proj_right: WebGLUniformLocation | null;
@@ -123,6 +135,61 @@ export class SeriesChart extends CategoricalYChart {
123
135
  _splitPrefixes: string[] = [];
124
136
  _series: SeriesInfo[] = [];
125
137
 
138
+ /**
139
+ * Whether the CURRENT build's geometry was produced for facet-grid
140
+ * mode (`facet_mode: "grid"` with real splits). The render / hover
141
+ * paths branch on this — never on the live `_facetConfig` — so a
142
+ * `setPluginConfig` that flips `facet_mode` between a build and a
143
+ * redraw can't pair grid-mode rendering with overlay-built stack
144
+ * geometry (or vice versa). The host always follows a
145
+ * `plugin.restore` with an `update` → `loadAndRender`, so the flag
146
+ * catches up one build later.
147
+ */
148
+ _facetActive = false;
149
+
150
+ /**
151
+ * Facet grid of the most recent faceted frame — one cell per
152
+ * split group. `null` in overlay / no-split frames. Read by the
153
+ * chrome overlay, hover hit-test, and tooltip pin to resolve the
154
+ * per-facet `PlotLayout`.
155
+ */
156
+ _facetGrid: FacetGrid | null = null;
157
+
158
+ /**
159
+ * Per-split contiguous instance ranges in the uploaded bar
160
+ * buffers, indexed by `splitIdx`. Populated by
161
+ * `uploadBarInstances` when `_facetActive` (instances are emitted
162
+ * split-major); `null` in overlay mode. Each facet's draw binds
163
+ * the instance attributes at `start` and draws `count` instances.
164
+ */
165
+ _facetBarRanges: { start: number; count: number }[] | null = null;
166
+
167
+ /**
168
+ * Paint order of the current build: consecutive aggregates sharing
169
+ * a glyph type, merged into runs, in `columns` declaration order
170
+ * (`aggIdx` ascending — later columns paint on top). The frame
171
+ * draws one pass per run; a homogeneous chart is a single run and
172
+ * degenerates to the legacy one-pass-per-type path. `aggEnd` is
173
+ * inclusive.
174
+ */
175
+ _glyphRuns: GlyphRun[] = [];
176
+
177
+ /**
178
+ * Per-aggregate contiguous instance ranges in the uploaded bar
179
+ * buffers, indexed by `aggIdx` — the overlay-mode counterpart of
180
+ * `_facetBarRanges`, populated by `uploadBarInstances`' aggregate
181
+ * counting sort. `null` until first upload.
182
+ */
183
+ _barAggRanges: { start: number; count: number }[] | null = null;
184
+
185
+ /**
186
+ * Faceted per-(split, aggregate) instance ranges,
187
+ * `[splitIdx][aggIdx]` — instances are emitted split-major then
188
+ * aggregate-ordered, so a facet's glyph-run slice is contiguous.
189
+ * `null` in overlay mode.
190
+ */
191
+ _facetBarAggRanges: { start: number; count: number }[][] | null = null;
192
+
126
193
  /**
127
194
  * Columnar bar/area record storage. Indexed by bar slot in
128
195
  * `[0, _bars.count)`. Replaces the legacy `SeriesChartRecord[]` to
@@ -205,8 +272,12 @@ export class SeriesChart extends CategoricalYChart {
205
272
 
206
273
  /**
207
274
  * `domain_mode: "expand"` accumulators. Hold the running union of
208
- * every prior build's value-axis (and, in numeric-category mode,
209
- * category-axis) extent for as long as the option is active.
275
+ * every prior build's VALUE-axis extents (primary + alt) for as
276
+ * long as the option is active. The category axis deliberately has
277
+ * NO accumulator: `domain_mode` scopes to the value axis only on
278
+ * band charts (Y for the Y-family, X for X Bar) — a numeric or
279
+ * datetime `group_by` axis always fits the current data, so a
280
+ * streaming chart's time axis releases departed categories.
210
281
  * Cleared in `resetExpandedDomain` — wired from the worker's
211
282
  * `resetAllZooms` and from view-config mutations on the base
212
283
  * class. `null` whenever the option is `"fit"` or the accumulator
@@ -214,7 +285,20 @@ export class SeriesChart extends CategoricalYChart {
214
285
  */
215
286
  _expandedLeftDomain: { min: number; max: number } | null = null;
216
287
  _expandedRightDomain: { min: number; max: number } | null = null;
217
- _expandedCategoryDomain: { min: number; max: number } | null = null;
288
+
289
+ /**
290
+ * The axis partition (per-aggregate axis side) the expand
291
+ * accumulators were accumulated under. An accumulator is only
292
+ * meaningful for the partition that produced it: when an aggregate
293
+ * moves sides (`columns_config.alt_axis` pin via a config-only
294
+ * `update()`, or a data-driven `auto_alt_y_axis` flip), the side it
295
+ * LEFT would otherwise retain its extent forever — the departed
296
+ * column's range never leaves the primary axis. A signature
297
+ * mismatch resets BOTH accumulators before the union. Keyed per
298
+ * AGGREGATE (not per series) so split-group growth on a streaming
299
+ * update never spuriously resets.
300
+ */
301
+ _expandedAxisSig: string | null = null;
218
302
 
219
303
  /**
220
304
  * Numeric category-axis state. Populated only when `group_by` has
@@ -296,7 +380,13 @@ export class SeriesChart extends CategoricalYChart {
296
380
  */
297
381
  _visibleBarIndices: Int32Array = new Int32Array(0);
298
382
 
299
- _legendRects: { seriesId: number; rect: PlotRect }[] = [];
383
+ /**
384
+ * Hit-test rects for legend entries. Overlay mode: one entry per
385
+ * series (`seriesIds.length === 1`). Facet-grid mode: one entry
386
+ * per AGGREGATE, carrying every split's seriesId — a legend
387
+ * toggle hides/shows the aggregate across all facets at once.
388
+ */
389
+ _legendRects: { seriesIds: number[]; rect: PlotRect }[] = [];
300
390
 
301
391
  /**
302
392
  * Cached legend layout — recomputed only on series-set / palette /
@@ -362,7 +452,14 @@ export class SeriesChart extends CategoricalYChart {
362
452
  this._hoveredBarIdx = -1;
363
453
  this._hoveredSample = null;
364
454
  if (this._glManager) {
365
- renderBarFrame(this, this._glManager);
455
+ // Route through the scheduler (not a direct
456
+ // `renderBarFrame`) so the frame is serialized
457
+ // against sibling charts sharing a pooled GL
458
+ // context in blit mode — a direct render would
459
+ // paint into the shared canvas outside the
460
+ // present cycle and leak into another plugin's
461
+ // frame.
462
+ this.requestRender(this._glManager);
366
463
  }
367
464
  }
368
465
  },
@@ -479,6 +576,9 @@ export class SeriesChart extends CategoricalYChart {
479
576
  this._cornerBuffer = createQuadCornerBuffer(gl);
480
577
  }
481
578
 
579
+ const facetSplits =
580
+ this._splitBy.length > 0 && this._facetConfig.facet_mode === "grid";
581
+
482
582
  const result = buildSeriesPipeline({
483
583
  columns,
484
584
  numRows: endRow,
@@ -497,43 +597,55 @@ export class SeriesChart extends CategoricalYChart {
497
597
  bandInnerFrac: this._pluginConfig.band_inner_frac,
498
598
  barInnerPad: this._pluginConfig.bar_inner_pad,
499
599
  includeZero: this._pluginConfig.include_zero,
600
+ facetSplits,
500
601
  scratchBars: this._bars,
501
602
  scratchPosStack: this._posStackScratch,
502
603
  scratchNegStack: this._negStackScratch,
503
604
  });
504
605
 
505
- // `domain_mode: "expand"` post-build union. Each call mutates
506
- // the pipeline result in place so the downstream assignments
507
- // below (`_leftDomain`, `_rightDomain`, `_numericCategoryDomain`,
508
- // `_categoryOrigin`) automatically pick up the grown extent.
606
+ // `domain_mode: "expand"` post-build union — VALUE axes only
607
+ // (`leftDomain`/`rightDomain` are the logical value domains,
608
+ // orientation-agnostic, so this is Y for the Y-family and X for
609
+ // X Bar). The category axis is NEVER expanded: it identifies
610
+ // the data, and pinning a numeric/datetime `group_by` axis to
611
+ // departed categories froze streaming charts on their full
612
+ // history. Each call mutates the pipeline result in place so
613
+ // the downstream assignments below (`_leftDomain`,
614
+ // `_rightDomain`) automatically pick up the grown extent.
509
615
  // `"fit"` (or a fresh reset) leaves the result untouched and
510
616
  // clears the accumulators so the next toggle starts fresh.
511
617
  if (this._pluginConfig.domain_mode === "expand") {
618
+ const axisSig = expandAxisSignature(result);
619
+ if (this._expandedAxisSig !== axisSig) {
620
+ this._expandedLeftDomain = null;
621
+ this._expandedRightDomain = null;
622
+ this._expandedAxisSig = axisSig;
623
+ }
624
+
512
625
  this._expandedLeftDomain = expandDomainInPlace(
513
626
  this._expandedLeftDomain,
514
627
  result.leftDomain,
515
628
  );
629
+
516
630
  if (result.rightDomain) {
517
631
  this._expandedRightDomain = expandDomainInPlace(
518
632
  this._expandedRightDomain,
519
633
  result.rightDomain,
520
634
  );
521
635
  }
522
-
523
- if (result.numericCategoryDomain) {
524
- this._expandedCategoryDomain = expandDomainInPlace(
525
- this._expandedCategoryDomain,
526
- result.numericCategoryDomain,
527
- );
528
- }
529
636
  } else {
530
637
  this._expandedLeftDomain = null;
531
638
  this._expandedRightDomain = null;
532
- this._expandedCategoryDomain = null;
639
+ this._expandedAxisSig = null;
533
640
  }
534
641
 
535
642
  this._aggregates = result.aggregates;
536
643
  this._splitPrefixes = result.splitPrefixes;
644
+
645
+ // Stamp AFTER the build so render always branches on the mode
646
+ // the geometry was actually built for. A single "" prefix means
647
+ // no real splits — facet mode degenerates to the single plot.
648
+ this._facetActive = facetSplits && result.splitPrefixes.length > 1;
537
649
  this._rowPaths = result.rowPaths;
538
650
  this._numCategories = result.numCategories;
539
651
  this._rowOffset = result.rowOffset;
@@ -586,6 +698,25 @@ export class SeriesChart extends CategoricalYChart {
586
698
  this._primaryValueLabel = uniqueAggLabels(result.series, 0);
587
699
  this._altValueLabel = uniqueAggLabels(result.series, 1);
588
700
 
701
+ // Paint-order runs: every series of an aggregate shares one
702
+ // glyph type (`resolveChartType` is per-aggName), so sampling
703
+ // the aggregate's first series suffices. MUST be computed
704
+ // before `uploadBarInstances` below — the aggregate counting
705
+ // sort publishes ranges the run passes draw from.
706
+ this._glyphRuns.length = 0;
707
+ {
708
+ const P = Math.max(1, result.splitPrefixes.length);
709
+ for (let k = 0; k < result.aggregates.length; k++) {
710
+ const chartType = result.series[k * P].chartType;
711
+ const last = this._glyphRuns[this._glyphRuns.length - 1];
712
+ if (last && last.chartType === chartType) {
713
+ last.aggEnd = k;
714
+ } else {
715
+ this._glyphRuns.push({ chartType, aggStart: k, aggEnd: k });
716
+ }
717
+ }
718
+ }
719
+
589
720
  // Pre-build the area-strip lookup index (seriesId * 1e9 + catIdx
590
721
  // → bar slot). Legacy code rebuilt this every frame inside
591
722
  // `drawAreas`. The index is derived purely from `_bars` and is
@@ -604,6 +735,17 @@ export class SeriesChart extends CategoricalYChart {
604
735
  this._paletteCacheKey = null;
605
736
  this._catExtentsHidden = null;
606
737
  this._lastUploadedColors = null;
738
+
739
+ // Hover records index into `_bars` / `_series` and MUST NOT
740
+ // survive a build that replaces them: bar-column capacity is
741
+ // reused across builds, so a stale `_hoveredBarIdx` reads a
742
+ // leftover `seriesId` past the new series set (and a retained
743
+ // `_hoveredSample` carries one verbatim), crashing the tooltip
744
+ // pass of the next present when the pointer rests over a glyph
745
+ // through a host `restore`. The next mousemove re-hit-tests
746
+ // against the new data.
747
+ this._hoveredBarIdx = -1;
748
+ this._hoveredSample = null;
607
749
  this._sampleValid = result.sampleValid;
608
750
  this._leftDomain = result.leftDomain;
609
751
  this._rightDomain = result.rightDomain;
@@ -638,7 +780,7 @@ export class SeriesChart extends CategoricalYChart {
638
780
  override resetExpandedDomain(): void {
639
781
  this._expandedLeftDomain = null;
640
782
  this._expandedRightDomain = null;
641
- this._expandedCategoryDomain = null;
783
+ this._expandedAxisSig = null;
642
784
  }
643
785
 
644
786
  protected destroyInternal(): void {
@@ -654,6 +796,12 @@ export class SeriesChart extends CategoricalYChart {
654
796
  this._program = null;
655
797
  this._locations = null;
656
798
  this._cornerBuffer = null;
799
+ this._facetActive = false;
800
+ this._facetGrid = null;
801
+ this._facetBarRanges = null;
802
+ this._facetBarAggRanges = null;
803
+ this._barAggRanges = null;
804
+ this._glyphRuns.length = 0;
657
805
  this._bars = emptyBarColumns();
658
806
  this._series = [];
659
807
  this._barSeries = [];
@@ -669,6 +817,8 @@ export class SeriesChart extends CategoricalYChart {
669
817
  this._rowPaths = [];
670
818
  this._numCategories = 0;
671
819
  this._hiddenSeries.clear();
820
+ this._hoveredBarIdx = -1;
821
+ this._hoveredSample = null;
672
822
  }
673
823
  }
674
824
 
@@ -715,12 +865,37 @@ function uniqueAggLabels(series: SeriesInfo[], axis: 0 | 1): string {
715
865
  return ordered.join(", ");
716
866
  }
717
867
 
868
+ /**
869
+ * Per-aggregate axis-partition signature for the `domain_mode:
870
+ * "expand"` accumulators. Invariant: every series of an aggregate
871
+ * shares one axis side (both axis-override loops in the build pipeline
872
+ * assign by `aggIdx`), so sampling the aggregate's first series
873
+ * suffices. NUL-joined — aggregate names may contain any printable
874
+ * separator.
875
+ */
876
+ function expandAxisSignature(result: SeriesPipelineResult): string {
877
+ const P = Math.max(1, result.splitPrefixes.length);
878
+ const parts: string[] = [];
879
+ for (let k = 0; k < result.aggregates.length; k++) {
880
+ parts.push(`${result.aggregates[k]}:${result.series[k * P].axis}`);
881
+ }
882
+
883
+ return parts.join("\u0000");
884
+ }
885
+
718
886
  /**
719
887
  * Resolve the per-series palette and stamp it onto `_series[i].color`.
720
888
  * Cached on `_paletteCache` keyed by reference identity of the theme
721
- * inputs + series count — only `restyle()` (which clears `_paletteCache`
722
- * via `invalidateTheme`) or a data load (which clears it explicitly)
723
- * forces re-resolution.
889
+ * inputs + resolved color count — only `restyle()` (which clears
890
+ * `_paletteCache` via `invalidateTheme`) or a data load (which clears
891
+ * it explicitly) forces re-resolution.
892
+ *
893
+ * Facet-grid mode colors by AGGREGATE, not by (agg × split) series:
894
+ * the facet is the axis of splitting, so aggregate `k` renders the
895
+ * same color in every facet and the legend lists aggregates once.
896
+ * Overlay mode keeps the per-series palette (splits are distinguished
897
+ * by color there). `_facetActive` only changes at build time, which
898
+ * clears the cache, so the stamp mode can't go stale.
724
899
  *
725
900
  * Returns true when the cache changed (caller invalidates color upload).
726
901
  */
@@ -728,7 +903,9 @@ export function ensurePalette(chart: SeriesChart): boolean {
728
903
  const theme = chart._resolveTheme();
729
904
  const seriesPalette = theme.seriesPalette;
730
905
  const gradientStops = theme.gradientStops;
731
- const seriesLength = chart._series.length;
906
+ const paletteCount = chart._facetActive
907
+ ? Math.max(1, chart._aggregates.length)
908
+ : chart._series.length;
732
909
 
733
910
  const key = chart._paletteCacheKey;
734
911
  if (
@@ -736,7 +913,7 @@ export function ensurePalette(chart: SeriesChart): boolean {
736
913
  key &&
737
914
  key.seriesPalette === seriesPalette &&
738
915
  key.gradientStops === gradientStops &&
739
- key.seriesLength === seriesLength
916
+ key.seriesLength === paletteCount
740
917
  ) {
741
918
  return false;
742
919
  }
@@ -744,13 +921,19 @@ export function ensurePalette(chart: SeriesChart): boolean {
744
921
  const palette = resolvePaletteCached(
745
922
  seriesPalette,
746
923
  gradientStops,
747
- seriesLength,
924
+ paletteCount,
748
925
  );
749
926
  chart._paletteCache = palette;
750
- chart._paletteCacheKey = { seriesPalette, gradientStops, seriesLength };
927
+ chart._paletteCacheKey = {
928
+ seriesPalette,
929
+ gradientStops,
930
+ seriesLength: paletteCount,
931
+ };
751
932
 
752
933
  for (let i = 0; i < chart._series.length; i++) {
753
- chart._series[i].color = palette[i];
934
+ chart._series[i].color = chart._facetActive
935
+ ? palette[chart._series[i].aggIdx]
936
+ : palette[i];
754
937
  }
755
938
 
756
939
  return true;
@@ -13,7 +13,6 @@
13
13
  import type { SunburstChart } from "./sunburst";
14
14
  import { NULL_NODE } from "../common/node-store";
15
15
  import {
16
- renderSunburstFrame,
17
16
  renderSunburstChromeOverlay,
18
17
  facetCenterForNode,
19
18
  } from "./sunburst-render";
@@ -264,7 +263,7 @@ export function handleSunburstClick(
264
263
  }
265
264
 
266
265
  if (chart._glManager) {
267
- renderSunburstFrame(chart, chart._glManager);
266
+ chart.requestRender(chart._glManager);
268
267
  }
269
268
  }
270
269
 
@@ -289,7 +288,7 @@ export function handleSunburstClick(
289
288
  function drillTo(chart: SunburstChart, nodeId: number): void {
290
289
  treeDrillTo(chart, nodeId, () => {
291
290
  if (chart._glManager) {
292
- renderSunburstFrame(chart, chart._glManager);
291
+ chart.requestRender(chart._glManager);
293
292
  }
294
293
  });
295
294
  }
@@ -189,7 +189,9 @@ export function renderSunburstFrame(
189
189
  drawArcs(chart, gl, glManager, 0, chart._instanceCount);
190
190
  }
191
191
 
192
- renderSunburstChromeOverlay(chart);
192
+ // Deferred past the GPU fence (see `_defer2D`) so the chrome canvas
193
+ // doesn't present ahead of the GL arcs on resize.
194
+ chart._defer2D(() => renderSunburstChromeOverlay(chart));
193
195
  }
194
196
 
195
197
  /**
@@ -13,10 +13,7 @@
13
13
  import type { TreemapChart } from "./treemap";
14
14
  import { NULL_NODE } from "../common/node-store";
15
15
  import { PADDING_LABEL } from "./treemap-layout";
16
- import {
17
- renderTreemapFrame,
18
- renderTreemapChromeOverlay,
19
- } from "./treemap-render";
16
+ import { renderTreemapChromeOverlay } from "./treemap-render";
20
17
  import {
21
18
  buildTreeTooltipLines,
22
19
  dismissTreePinnedTooltip,
@@ -254,7 +251,7 @@ export function handleTreemapDblClick(
254
251
  function drillTo(chart: TreemapChart, nodeId: number): void {
255
252
  treeDrillTo(chart, nodeId, () => {
256
253
  if (chart._glManager) {
257
- renderTreemapFrame(chart, chart._glManager);
254
+ chart.requestRender(chart._glManager);
258
255
  }
259
256
  });
260
257
  }
@@ -173,7 +173,9 @@ export function renderTreemapFrame(
173
173
 
174
174
  gl.drawArrays(gl.TRIANGLES, 0, chart._vertexCount);
175
175
 
176
- renderTreemapChromeOverlay(chart);
176
+ // Deferred past the GPU fence (see `_defer2D`) so the chrome canvas
177
+ // doesn't present ahead of the GL tiles on resize.
178
+ chart._defer2D(() => renderTreemapChromeOverlay(chart));
177
179
  }
178
180
 
179
181
  /**
@@ -109,7 +109,12 @@ export class TreemapChart extends TreeChartBase {
109
109
  ) {
110
110
  this._hoveredNodeId = NULL_NODE;
111
111
  if (this._glManager) {
112
- renderTreemapFrame(this, this._glManager);
112
+ // Schedule through the render scheduler rather
113
+ // than painting directly — a direct frame render
114
+ // bypasses the per-context serialization and, in
115
+ // pooled blit mode, corrupts a sibling chart's
116
+ // in-flight shared-canvas frame.
117
+ this.requestRender(this._glManager);
113
118
  }
114
119
  }
115
120
  },
@@ -50,10 +50,19 @@ export interface TypedArrayWindowOptions {
50
50
  * `Promise`, the underlying `with_typed_arrays` call awaits it before
51
51
  * releasing the backing Arrow buffer. Callers must not retain any
52
52
  * `ColumnData` reference past `render`'s resolution.
53
+ *
54
+ * `render` also receives the DELIVERED row count — the shortest
55
+ * column's length, which may be less than the requested `end_row`:
56
+ * the engine clamps the window to the view's size at fetch time, and
57
+ * on a fast-updating table with deletions that size can shrink after
58
+ * the caller's `num_rows()` round-trip. Build pipelines must iterate
59
+ * to THIS count, never the requested one — a typed array indexed past
60
+ * its length returns `undefined`, which no amount of validity-bitmap
61
+ * checking catches (the bitmap read is out-of-bounds too).
53
62
  */
54
63
  export async function viewToColumnDataMap(
55
64
  view: View,
56
- render: (data: ColumnDataMap) => void | Promise<void>,
65
+ render: (data: ColumnDataMap, numRows: number) => void | Promise<void>,
57
66
  options?: TypedArrayWindowOptions,
58
67
  ): Promise<void> {
59
68
  const result: ColumnDataMap = new Map();
@@ -101,7 +110,18 @@ export async function viewToColumnDataMap(
101
110
  }
102
111
  }
103
112
 
104
- await render(result);
113
+ let numRows = 0;
114
+ if (result.size > 0) {
115
+ numRows = Infinity;
116
+ for (const col of result.values()) {
117
+ const n = col.indices?.length ?? col.values?.length ?? 0;
118
+ if (n < numRows) {
119
+ numRows = n;
120
+ }
121
+ }
122
+ }
123
+
124
+ await render(result, numRows);
105
125
  },
106
126
  );
107
127
  }
@@ -39,6 +39,14 @@ export interface PerspectiveClickDetail {
39
39
  * `[<col>, "==", <value>]` clauses.
40
40
  */
41
41
  config: Partial<ViewConfig>;
42
+
43
+ /**
44
+ * The source panel's `slot` name in a multi-panel `<perspective-viewer>`,
45
+ * so the host can attribute the click to the originating panel. `undefined`
46
+ * for a lone, unslotted viewer. Mirrors `PerspectiveSelectDetail.panel` and
47
+ * the datagrid `PerspectiveClickDetail`.
48
+ */
49
+ panel?: string;
42
50
  }
43
51
 
44
52
  export { PerspectiveSelectDetail } from "@perspective-dev/viewer/src/ts/extensions.js";
package/src/ts/index.ts CHANGED
@@ -23,17 +23,17 @@ export function register(...plugin_names: string[]) {
23
23
  : CHARTS.map((chart) => chart.name),
24
24
  );
25
25
 
26
+ const already_registered: string[] = [];
27
+
26
28
  CHARTS.forEach((chart) => {
27
29
  if (plugins.has(chart.name)) {
28
30
  const tagName = `perspective-viewer-charts-${chart.tag}`;
29
31
 
30
- // Each registered tag is a thin subclass that pins
31
- // `_chartType` so `draw()` / `save()` / etc. know which
32
- // `ChartTypeConfig` they're driving. The chart impl
33
- // class itself lives in the worker bundle — only
34
- // `ChartTypeConfig.tag` crosses the host/renderer
35
- // boundary, and the renderer constructs the impl from
36
- // its own `CHART_IMPLS` registry.
32
+ if (customElements.get(tagName)) {
33
+ already_registered.push(tagName);
34
+ return;
35
+ }
36
+
37
37
  customElements.define(
38
38
  tagName,
39
39
  class extends HTMLPerspectiveViewerWebGLPluginElement {
@@ -48,6 +48,12 @@ export function register(...plugin_names: string[]) {
48
48
  });
49
49
  }
50
50
  });
51
+
52
+ if (already_registered.length > 0) {
53
+ console.warn(
54
+ `viewer-charts plugins already registered (${already_registered.length}); skipping duplicate registration (Perspective was loaded more than once on this page).`,
55
+ );
56
+ }
51
57
  }
52
58
 
53
59
  register();
@@ -55,6 +55,13 @@ export class RawEventForwarder {
55
55
  };
56
56
 
57
57
  this._onPointerDown = (e: PointerEvent) => {
58
+ // Only forward primary-button pointerdowns. Right/middle clicks must
59
+ // reach the panel's `contextmenu` handler — capturing the pointer
60
+ // here would otherwise swallow the right-click.
61
+ if (e.button !== 0) {
62
+ return;
63
+ }
64
+
58
65
  const rect = element.getBoundingClientRect();
59
66
  const mx = e.clientX - rect.left;
60
67
  const my = e.clientY - rect.top;
@@ -388,6 +388,14 @@ export class ZoomController {
388
388
  };
389
389
 
390
390
  this._onPointerDown = (e: PointerEvent) => {
391
+ // Only the primary (left) button starts a pan. Right/middle clicks
392
+ // must fall through so the panel's `contextmenu` handler can open
393
+ // the menu — capturing the pointer here would otherwise swallow the
394
+ // right-click.
395
+ if (e.button !== 0) {
396
+ return;
397
+ }
398
+
391
399
  const rect = element.getBoundingClientRect();
392
400
  const mouseX = e.clientX - rect.left;
393
401
  const mouseY = e.clientY - rect.top;
@@ -72,8 +72,17 @@ export function computeNiceTicks(
72
72
  /**
73
73
  * Format a numeric tick value for display.
74
74
  * Uses K/M/B suffixes for large numbers, fixed decimals for small.
75
+ *
76
+ * Total over any input: label formatters run inside render passes, so a
77
+ * non-finite value (or `undefined` smuggled in by an upstream
78
+ * out-of-bounds read) must degrade to a placeholder, never throw the
79
+ * frame away.
75
80
  */
76
81
  export function formatTickValue(val: number): string {
82
+ if (!Number.isFinite(val)) {
83
+ return "-";
84
+ }
85
+
77
86
  const abs = Math.abs(val);
78
87
  if (abs === 0) {
79
88
  return "0";