@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
@@ -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";
@@ -123,6 +124,35 @@ export class SeriesChart extends CategoricalYChart {
123
124
  _splitPrefixes: string[] = [];
124
125
  _series: SeriesInfo[] = [];
125
126
 
127
+ /**
128
+ * Whether the CURRENT build's geometry was produced for facet-grid
129
+ * mode (`facet_mode: "grid"` with real splits). The render / hover
130
+ * paths branch on this — never on the live `_facetConfig` — so a
131
+ * `setPluginConfig` that flips `facet_mode` between a build and a
132
+ * redraw can't pair grid-mode rendering with overlay-built stack
133
+ * geometry (or vice versa). The host always follows a
134
+ * `plugin.restore` with an `update` → `loadAndRender`, so the flag
135
+ * catches up one build later.
136
+ */
137
+ _facetActive = false;
138
+
139
+ /**
140
+ * Facet grid of the most recent faceted frame — one cell per
141
+ * split group. `null` in overlay / no-split frames. Read by the
142
+ * chrome overlay, hover hit-test, and tooltip pin to resolve the
143
+ * per-facet `PlotLayout`.
144
+ */
145
+ _facetGrid: FacetGrid | null = null;
146
+
147
+ /**
148
+ * Per-split contiguous instance ranges in the uploaded bar
149
+ * buffers, indexed by `splitIdx`. Populated by
150
+ * `uploadBarInstances` when `_facetActive` (instances are emitted
151
+ * split-major); `null` in overlay mode. Each facet's draw binds
152
+ * the instance attributes at `start` and draws `count` instances.
153
+ */
154
+ _facetBarRanges: { start: number; count: number }[] | null = null;
155
+
126
156
  /**
127
157
  * Columnar bar/area record storage. Indexed by bar slot in
128
158
  * `[0, _bars.count)`. Replaces the legacy `SeriesChartRecord[]` to
@@ -205,8 +235,12 @@ export class SeriesChart extends CategoricalYChart {
205
235
 
206
236
  /**
207
237
  * `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.
238
+ * every prior build's VALUE-axis extents (primary + alt) for as
239
+ * long as the option is active. The category axis deliberately has
240
+ * NO accumulator: `domain_mode` scopes to the value axis only on
241
+ * band charts (Y for the Y-family, X for X Bar) — a numeric or
242
+ * datetime `group_by` axis always fits the current data, so a
243
+ * streaming chart's time axis releases departed categories.
210
244
  * Cleared in `resetExpandedDomain` — wired from the worker's
211
245
  * `resetAllZooms` and from view-config mutations on the base
212
246
  * class. `null` whenever the option is `"fit"` or the accumulator
@@ -214,7 +248,6 @@ export class SeriesChart extends CategoricalYChart {
214
248
  */
215
249
  _expandedLeftDomain: { min: number; max: number } | null = null;
216
250
  _expandedRightDomain: { min: number; max: number } | null = null;
217
- _expandedCategoryDomain: { min: number; max: number } | null = null;
218
251
 
219
252
  /**
220
253
  * Numeric category-axis state. Populated only when `group_by` has
@@ -296,7 +329,13 @@ export class SeriesChart extends CategoricalYChart {
296
329
  */
297
330
  _visibleBarIndices: Int32Array = new Int32Array(0);
298
331
 
299
- _legendRects: { seriesId: number; rect: PlotRect }[] = [];
332
+ /**
333
+ * Hit-test rects for legend entries. Overlay mode: one entry per
334
+ * series (`seriesIds.length === 1`). Facet-grid mode: one entry
335
+ * per AGGREGATE, carrying every split's seriesId — a legend
336
+ * toggle hides/shows the aggregate across all facets at once.
337
+ */
338
+ _legendRects: { seriesIds: number[]; rect: PlotRect }[] = [];
300
339
 
301
340
  /**
302
341
  * Cached legend layout — recomputed only on series-set / palette /
@@ -362,7 +401,14 @@ export class SeriesChart extends CategoricalYChart {
362
401
  this._hoveredBarIdx = -1;
363
402
  this._hoveredSample = null;
364
403
  if (this._glManager) {
365
- renderBarFrame(this, this._glManager);
404
+ // Route through the scheduler (not a direct
405
+ // `renderBarFrame`) so the frame is serialized
406
+ // against sibling charts sharing a pooled GL
407
+ // context in blit mode — a direct render would
408
+ // paint into the shared canvas outside the
409
+ // present cycle and leak into another plugin's
410
+ // frame.
411
+ this.requestRender(this._glManager);
366
412
  }
367
413
  }
368
414
  },
@@ -479,6 +525,9 @@ export class SeriesChart extends CategoricalYChart {
479
525
  this._cornerBuffer = createQuadCornerBuffer(gl);
480
526
  }
481
527
 
528
+ const facetSplits =
529
+ this._splitBy.length > 0 && this._facetConfig.facet_mode === "grid";
530
+
482
531
  const result = buildSeriesPipeline({
483
532
  columns,
484
533
  numRows: endRow,
@@ -497,15 +546,21 @@ export class SeriesChart extends CategoricalYChart {
497
546
  bandInnerFrac: this._pluginConfig.band_inner_frac,
498
547
  barInnerPad: this._pluginConfig.bar_inner_pad,
499
548
  includeZero: this._pluginConfig.include_zero,
549
+ facetSplits,
500
550
  scratchBars: this._bars,
501
551
  scratchPosStack: this._posStackScratch,
502
552
  scratchNegStack: this._negStackScratch,
503
553
  });
504
554
 
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.
555
+ // `domain_mode: "expand"` post-build union — VALUE axes only
556
+ // (`leftDomain`/`rightDomain` are the logical value domains,
557
+ // orientation-agnostic, so this is Y for the Y-family and X for
558
+ // X Bar). The category axis is NEVER expanded: it identifies
559
+ // the data, and pinning a numeric/datetime `group_by` axis to
560
+ // departed categories froze streaming charts on their full
561
+ // history. Each call mutates the pipeline result in place so
562
+ // the downstream assignments below (`_leftDomain`,
563
+ // `_rightDomain`) automatically pick up the grown extent.
509
564
  // `"fit"` (or a fresh reset) leaves the result untouched and
510
565
  // clears the accumulators so the next toggle starts fresh.
511
566
  if (this._pluginConfig.domain_mode === "expand") {
@@ -513,27 +568,25 @@ export class SeriesChart extends CategoricalYChart {
513
568
  this._expandedLeftDomain,
514
569
  result.leftDomain,
515
570
  );
571
+
516
572
  if (result.rightDomain) {
517
573
  this._expandedRightDomain = expandDomainInPlace(
518
574
  this._expandedRightDomain,
519
575
  result.rightDomain,
520
576
  );
521
577
  }
522
-
523
- if (result.numericCategoryDomain) {
524
- this._expandedCategoryDomain = expandDomainInPlace(
525
- this._expandedCategoryDomain,
526
- result.numericCategoryDomain,
527
- );
528
- }
529
578
  } else {
530
579
  this._expandedLeftDomain = null;
531
580
  this._expandedRightDomain = null;
532
- this._expandedCategoryDomain = null;
533
581
  }
534
582
 
535
583
  this._aggregates = result.aggregates;
536
584
  this._splitPrefixes = result.splitPrefixes;
585
+
586
+ // Stamp AFTER the build so render always branches on the mode
587
+ // the geometry was actually built for. A single "" prefix means
588
+ // no real splits — facet mode degenerates to the single plot.
589
+ this._facetActive = facetSplits && result.splitPrefixes.length > 1;
537
590
  this._rowPaths = result.rowPaths;
538
591
  this._numCategories = result.numCategories;
539
592
  this._rowOffset = result.rowOffset;
@@ -638,7 +691,6 @@ export class SeriesChart extends CategoricalYChart {
638
691
  override resetExpandedDomain(): void {
639
692
  this._expandedLeftDomain = null;
640
693
  this._expandedRightDomain = null;
641
- this._expandedCategoryDomain = null;
642
694
  }
643
695
 
644
696
  protected destroyInternal(): void {
@@ -654,6 +706,9 @@ export class SeriesChart extends CategoricalYChart {
654
706
  this._program = null;
655
707
  this._locations = null;
656
708
  this._cornerBuffer = null;
709
+ this._facetActive = false;
710
+ this._facetGrid = null;
711
+ this._facetBarRanges = null;
657
712
  this._bars = emptyBarColumns();
658
713
  this._series = [];
659
714
  this._barSeries = [];
@@ -718,9 +773,16 @@ function uniqueAggLabels(series: SeriesInfo[], axis: 0 | 1): string {
718
773
  /**
719
774
  * Resolve the per-series palette and stamp it onto `_series[i].color`.
720
775
  * 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.
776
+ * inputs + resolved color count — only `restyle()` (which clears
777
+ * `_paletteCache` via `invalidateTheme`) or a data load (which clears
778
+ * it explicitly) forces re-resolution.
779
+ *
780
+ * Facet-grid mode colors by AGGREGATE, not by (agg × split) series:
781
+ * the facet is the axis of splitting, so aggregate `k` renders the
782
+ * same color in every facet and the legend lists aggregates once.
783
+ * Overlay mode keeps the per-series palette (splits are distinguished
784
+ * by color there). `_facetActive` only changes at build time, which
785
+ * clears the cache, so the stamp mode can't go stale.
724
786
  *
725
787
  * Returns true when the cache changed (caller invalidates color upload).
726
788
  */
@@ -728,7 +790,9 @@ export function ensurePalette(chart: SeriesChart): boolean {
728
790
  const theme = chart._resolveTheme();
729
791
  const seriesPalette = theme.seriesPalette;
730
792
  const gradientStops = theme.gradientStops;
731
- const seriesLength = chart._series.length;
793
+ const paletteCount = chart._facetActive
794
+ ? Math.max(1, chart._aggregates.length)
795
+ : chart._series.length;
732
796
 
733
797
  const key = chart._paletteCacheKey;
734
798
  if (
@@ -736,7 +800,7 @@ export function ensurePalette(chart: SeriesChart): boolean {
736
800
  key &&
737
801
  key.seriesPalette === seriesPalette &&
738
802
  key.gradientStops === gradientStops &&
739
- key.seriesLength === seriesLength
803
+ key.seriesLength === paletteCount
740
804
  ) {
741
805
  return false;
742
806
  }
@@ -744,13 +808,19 @@ export function ensurePalette(chart: SeriesChart): boolean {
744
808
  const palette = resolvePaletteCached(
745
809
  seriesPalette,
746
810
  gradientStops,
747
- seriesLength,
811
+ paletteCount,
748
812
  );
749
813
  chart._paletteCache = palette;
750
- chart._paletteCacheKey = { seriesPalette, gradientStops, seriesLength };
814
+ chart._paletteCacheKey = {
815
+ seriesPalette,
816
+ gradientStops,
817
+ seriesLength: paletteCount,
818
+ };
751
819
 
752
820
  for (let i = 0; i < chart._series.length; i++) {
753
- chart._series[i].color = palette[i];
821
+ chart._series[i].color = chart._facetActive
822
+ ? palette[chart._series[i].aggIdx]
823
+ : palette[i];
754
824
  }
755
825
 
756
826
  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
  },
package/src/ts/config.ts CHANGED
@@ -33,7 +33,27 @@ export const RUNTIME_MODE: "worker" | "inprocess" = "worker";
33
33
  * `ImageBitmap` over the control channel; the host blits the bitmap
34
34
  * into the visible canvas via `drawImage`.
35
35
  */
36
- export const RENDER_BLIT_MODE: "direct" | "blit" = "direct";
36
+ export const RENDER_BLIT_MODE: "direct" | "blit" = "blit";
37
+
38
+ /**
39
+ * Number of shared WebGL contexts in pooled blit mode.
40
+ *
41
+ * The browser caps live contexts per agent (~16 in Chromium) and
42
+ * force-loses the oldest past that cap, so a page with more charts than
43
+ * the cap cannot give each its own context. When `> 0` *and*
44
+ * `RENDER_BLIT_MODE === "blit"`, every chart borrows one of this many
45
+ * shared contexts (round-robin, sticky for the chart's lifetime) instead
46
+ * of allocating its own — decoupling live-context count from chart
47
+ * count. N charts render through K = this many contexts; the scheduler
48
+ * serializes renders that land on the same context.
49
+ *
50
+ * `0` disables pooling (every chart gets its own context — the original
51
+ * behavior). Pooling never applies to `"direct"` mode, which renders
52
+ * into the host's transferred visible canvas and is permanently 1:1 with
53
+ * a context; keep pages that need more than ~16 simultaneous charts on
54
+ * `"blit"`.
55
+ */
56
+ export const RENDER_CONTEXT_POOL_SIZE: number = 4;
37
57
 
38
58
  /**
39
59
  * Strict-mode validation for `BufferPool.upload`.
@@ -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";
@@ -48,9 +48,12 @@ export interface ChartTypeConfig {
48
48
  * Per-chart-type overrides for `DEFAULT_PLUGIN_CONFIG`. Used when a
49
49
  * field's sensible default differs by chart family — currently
50
50
  * `include_zero` (true for Y Bar / Y Area / X Bar, false for line
51
- * / scatter / cartesian / financial). Applied at schema generation
52
- * and at `restore({})` so the effective default matches the
53
- * surfaced UI default.
51
+ * / scatter / cartesian / financial) and `facet_mode` ("overlay"
52
+ * for the band-pipeline families — series / financial — whose
53
+ * historical split_by rendering is the single stacked/colored
54
+ * plot; "grid" elsewhere). Applied at schema generation and at
55
+ * `restore({})` so the effective default matches the surfaced UI
56
+ * default.
54
57
  */
55
58
  plugin_field_defaults?: Partial<PluginConfig>;
56
59
  }
@@ -85,10 +88,19 @@ const SERIES_FIELDS: readonly PluginConfigField[] = [
85
88
  "bar_inner_pad",
86
89
  ];
87
90
 
91
+ // The band pipeline's historical split_by rendering is a single plot
92
+ // (splits stacked / colored in place), so the series family defaults
93
+ // `facet_mode` to "overlay" — grid faceting is the opt-in. Cartesian
94
+ // charts keep the global "grid" default from `DEFAULT_PLUGIN_CONFIG`.
95
+ const SERIES_DEFAULTS: Partial<PluginConfig> = { facet_mode: "overlay" };
96
+
88
97
  // Bar / area series glyphs grow from the zero baseline, so the value
89
98
  // axis must enclose 0 to render correctly. Line / scatter glyphs have
90
99
  // no such constraint — their default `include_zero` stays `false`.
91
- const ZERO_ANCHORED_DEFAULTS: Partial<PluginConfig> = { include_zero: true };
100
+ const ZERO_ANCHORED_DEFAULTS: Partial<PluginConfig> = {
101
+ ...SERIES_DEFAULTS,
102
+ include_zero: true,
103
+ };
92
104
 
93
105
  // Pure Cartesian (X/Y Scatter, X/Y Line) — no categorical axis, so no
94
106
  // band geometry; gets the facet-routing variant of zoom_mode.
@@ -210,9 +222,11 @@ const CHARTS: ChartTypeConfig[] = [
210
222
  }),
211
223
  make("Y Line", "y-line", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, {
212
224
  default_chart_type: "line",
225
+ plugin_field_defaults: SERIES_DEFAULTS,
213
226
  }),
214
227
  make("Y Scatter", "y-scatter", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, {
215
228
  default_chart_type: "scatter",
229
+ plugin_field_defaults: SERIES_DEFAULTS,
216
230
  }),
217
231
  make("Y Area", "y-area", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, {
218
232
  default_chart_type: "area",
@@ -250,9 +264,11 @@ const CHARTS: ChartTypeConfig[] = [
250
264
  make("Heatmap", "heatmap", HIER, SELECT, 1, ["Color"], HEATMAP_FIELDS),
251
265
  make("Candlestick", "candlestick", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, {
252
266
  default_chart_type: "candlestick",
267
+ plugin_field_defaults: SERIES_DEFAULTS,
253
268
  }),
254
269
  make("OHLC", "ohlc", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, {
255
270
  default_chart_type: "ohlc",
271
+ plugin_field_defaults: SERIES_DEFAULTS,
256
272
  }),
257
273
  make(
258
274
  "Map Scatter",