@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
@@ -0,0 +1,54 @@
1
+ import { ShaderRegistry } from "./shader-registry";
2
+ export type WebGLCanvas = HTMLCanvasElement | OffscreenCanvas;
3
+ export declare class GLContext {
4
+ readonly id: number;
5
+ readonly canvas: WebGLCanvas;
6
+ readonly gl: WebGL2RenderingContext | WebGLRenderingContext;
7
+ readonly isWebGL2: boolean;
8
+ shaders: ShaderRegistry;
9
+ private _maxVertexAttribs;
10
+ private _angle;
11
+ private _precompile;
12
+ private _destroyed;
13
+ /**
14
+ * Per-tenant hooks fired after a `webglcontextrestored`. Each
15
+ * {@link WebGLContextManager} on this context registers one that
16
+ * rebuilds its `BufferPool` (the shaders are rebuilt here, once, for
17
+ * all tenants). On an intentional {@link destroy} these are dropped
18
+ * without firing.
19
+ */
20
+ private _onRestore;
21
+ constructor(canvas: WebGLCanvas, options?: {
22
+ precompile?: boolean;
23
+ });
24
+ /**
25
+ * Return every vertex-attribute slot to its default state: disabled,
26
+ * with a zero instance divisor. Vertex-array state (enable flags,
27
+ * pointers, divisors) is global to a context's default VAO — there
28
+ * are no per-chart VAOs in this codebase — so when many charts share
29
+ * one pooled context, a slot a prior chart enabled stays enabled for
30
+ * the next chart, now pointing at a buffer the prior chart's
31
+ * teardown deleted. Its next `drawArraysInstanced` then fails GL
32
+ * validation: "no buffer is bound to enabled attribute", silently
33
+ * dropping the draw.
34
+ *
35
+ * Glyphs already (re)enable + bind + set the divisor for every slot
36
+ * they use on each frame, so clearing to the default here is a safe
37
+ * baseline — it only removes the *leftover* enables a sibling chart
38
+ * left behind. Called from {@link WebGLContextManager.beginFrame} in
39
+ * shared (pooled) mode only; the 1:1 default never shares a context
40
+ * and so keeps its original, reset-free path.
41
+ */
42
+ resetVertexArrayState(): void;
43
+ /**
44
+ * Register a tenant's post-restore rebuild hook. Returns a disposer.
45
+ */
46
+ onRestore(cb: () => void): () => void;
47
+ /**
48
+ * True once lost — either our own {@link destroy} or a browser
49
+ * eviction of the oldest context past the per-agent cap.
50
+ */
51
+ isContextLost(): boolean;
52
+ get destroyed(): boolean;
53
+ destroy(): void;
54
+ }
@@ -34,11 +34,17 @@ export declare function createQuadCornerBuffer(gl: GL): WebGLBuffer;
34
34
  * paint zero data). No-op return `true` when `attr` is negative
35
35
  * (optimized-out attribute — drawing the rest is still valid).
36
36
  *
37
+ * `firstInstance` byte-offsets the attribute pointer so the draw's
38
+ * instance 0 reads element `firstInstance` of the buffer — the WebGL
39
+ * substitute for a `baseInstance` draw parameter. Faceted renderers
40
+ * use it to draw a contiguous per-facet sub-range of a shared
41
+ * instance buffer.
42
+ *
37
43
  * Render-path uses `peek`, never `getOrCreate`: the latter recreates
38
44
  * with zero-initialized contents when `_totalCapacity` has grown past
39
45
  * the current buffer, which would wipe the previous draw's vertex
40
46
  * data and leave `drawArraysInstanced` to render zeros. See
41
47
  * {@link BufferPool#peek} for the full rationale.
42
48
  */
43
- export declare function bindInstancedFloatAttr(glManager: WebGLContextManager, instancing: Instancing, attr: number, name: string, components: number): boolean;
49
+ export declare function bindInstancedFloatAttr(glManager: WebGLContextManager, instancing: Instancing, attr: number, name: string, components: number, firstInstance?: number): boolean;
44
50
  export {};
@@ -72,6 +72,18 @@ export declare class WorkerRenderer {
72
72
  */
73
73
  loadAndRender(msg: LoadAndRenderMsg): Promise<void>;
74
74
  redraw(): void;
75
+ /**
76
+ * `redraw`, acked to the host AFTER the frame's present completes —
77
+ * the reply backing the viewer's presize contract (`ResizeAckMsg`):
78
+ * `plugin.resize()` must not resolve until the resized frame is
79
+ * actually on screen. A resize landing mid-present defers its
80
+ * canvas mutation (`deferIfDraining`), but the redraw's own frame
81
+ * applies pending dimensions in `beginFrame`, so the present this
82
+ * awaits IS at the new size. Always acks — a failed present or a
83
+ * torn-down chart resolves the host's await rather than stranding
84
+ * it (completion, not success, is the contract).
85
+ */
86
+ redrawAck(msgId: number): void;
75
87
  resize(cssWidth: number, cssHeight: number, dpr: number): void;
76
88
  clear(): void;
77
89
  saveZoom(): any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perspective-dev/viewer-charts",
3
- "version": "4.5.1",
3
+ "version": "5.0.0",
4
4
  "description": "Perspective.js WebGL Plugin",
5
5
  "unpkg": "./dist/cdn/perspective-viewer-charts.js",
6
6
  "jsdelivr": "./dist/cdn/perspective-viewer-charts.js",
@@ -224,7 +224,10 @@ export function renderCandlestickFrame(
224
224
  chart._lastCatTicks = numericCat
225
225
  ? computeNiceTicks(vis.xMin, vis.xMax, 6)
226
226
  : null;
227
- renderCandlestickChromeOverlay(chart);
227
+ // Deferred past the GPU fence (see `_defer2D`) so the chrome canvas
228
+ // doesn't present ahead of the GL glyphs on resize. Reads the
229
+ // `_last*` frame state set above.
230
+ chart._defer2D(() => renderCandlestickChromeOverlay(chart));
228
231
  }
229
232
 
230
233
  export function renderCandlestickChromeOverlay(chart: CandlestickChart): void {
@@ -92,15 +92,16 @@ export class CandlestickChart extends CategoricalYChart {
92
92
  _yDomain: { min: number; max: number } = { min: 0, max: 1 };
93
93
 
94
94
  /**
95
- * `domain_mode: "expand"` accumulators. Hold the running union of
96
- * the value-axis (and, in numeric-category mode, category-axis)
97
- * extent across data loads. Cleared in `resetExpandedDomain` —
98
- * wired from the worker's `resetAllZooms` and from view-config
99
- * mutations on `AbstractChart`. `null` whenever the option is
100
- * `"fit"` or the accumulator has just been cleared.
95
+ * `domain_mode: "expand"` accumulator — the VALUE (Y) axis only.
96
+ * The category axis has NO accumulator on band charts (see
97
+ * `SeriesChart._expandedLeftDomain`): a numeric/datetime `group_by`
98
+ * axis always fits the current data. Cleared in
99
+ * `resetExpandedDomain` — wired from the worker's `resetAllZooms`
100
+ * and from view-config mutations on `AbstractChart`. `null`
101
+ * whenever the option is `"fit"` or the accumulator has just been
102
+ * cleared.
101
103
  */
102
104
  _expandedYDomain: { min: number; max: number } | null = null;
103
- _expandedCategoryDomain: { min: number; max: number } | null = null;
104
105
 
105
106
  /**
106
107
  * Numeric category-axis state (single non-string group_by).
@@ -267,22 +268,17 @@ export class CandlestickChart extends CategoricalYChart {
267
268
  scratchCandles: this._candles,
268
269
  });
269
270
  // `domain_mode: "expand"` post-build union — mirrors the series
270
- // pipeline. `expandDomainInPlace` mutates `result.*` so the
271
- // assignments below pick up the grown extent automatically.
271
+ // pipeline: VALUE (Y) axis only, never the category axis (see
272
+ // `SeriesChart.uploadAndRender`). `expandDomainInPlace` mutates
273
+ // `result.yDomain` so the assignment below picks up the grown
274
+ // extent automatically.
272
275
  if (this._pluginConfig.domain_mode === "expand") {
273
276
  this._expandedYDomain = expandDomainInPlace(
274
277
  this._expandedYDomain,
275
278
  result.yDomain,
276
279
  );
277
- if (result.numericCategoryDomain) {
278
- this._expandedCategoryDomain = expandDomainInPlace(
279
- this._expandedCategoryDomain,
280
- result.numericCategoryDomain,
281
- );
282
- }
283
280
  } else {
284
281
  this._expandedYDomain = null;
285
- this._expandedCategoryDomain = null;
286
282
  }
287
283
 
288
284
  this._rowPaths = result.rowPaths;
@@ -320,7 +316,6 @@ export class CandlestickChart extends CategoricalYChart {
320
316
 
321
317
  override resetExpandedDomain(): void {
322
318
  this._expandedYDomain = null;
323
- this._expandedCategoryDomain = null;
324
319
  }
325
320
 
326
321
  protected destroyInternal(): void {
@@ -204,7 +204,11 @@ export function renderCartesianFrame(
204
204
  });
205
205
  }
206
206
 
207
- renderCartesianChromeOverlay(chart);
207
+ // Defer the chrome (axes/legend/tooltip) draw past the GPU fence so
208
+ // the chrome canvas doesn't present ahead of the GL glyphs on resize.
209
+ // `renderCartesianChromeOverlay` reads the `_last*` frame state set
210
+ // above, which stays stable until the next `_fullRender`.
211
+ chart._defer2D(() => renderCartesianChromeOverlay(chart));
208
212
  }
209
213
 
210
214
  interface RenderFrameCtx {
@@ -406,26 +410,25 @@ function renderSinglePlotFrame(
406
410
 
407
411
  const isMap = chart._renderMode === "map";
408
412
 
409
- if (chart._gridlineCanvas && !isMap) {
410
- // One-shot destructive prep (resizes + clears + scales to DPR).
411
- // `renderGridlines` itself is non-destructive.
412
- const dpr = glManager.dpr;
413
- initCanvas(chart._gridlineCanvas, layout, dpr);
414
- renderGridlines(
415
- chart._gridlineCanvas,
416
- layout,
417
- xTicks,
418
- yTicks,
419
- theme,
420
- dpr,
421
- );
422
- } else if (chart._gridlineCanvas && isMap) {
413
+ // Defer the gridline draw past the GPU fence (see `_defer2D`) so the
414
+ // gridline canvas doesn't present ahead of the GL glyphs on resize.
415
+ // The closure captures this frame's `layout` / ticks / `theme`.
416
+ const dpr = glManager.dpr;
417
+ const gridlineCanvas = chart._gridlineCanvas;
418
+ if (gridlineCanvas && !isMap) {
419
+ chart._defer2D(() => {
420
+ // One-shot destructive prep (resizes + clears + scales to
421
+ // DPR). `renderGridlines` itself is non-destructive.
422
+ initCanvas(gridlineCanvas, layout, dpr);
423
+ renderGridlines(gridlineCanvas, layout, xTicks, yTicks, theme, dpr);
424
+ });
425
+ } else if (gridlineCanvas && isMap) {
423
426
  // Map mode draws no cartesian gridlines, but the gridline
424
427
  // canvas may carry stale ink from a prior cartesian chart
425
428
  // type. Reset it to a clean transparent surface so the
426
429
  // basemap (rendered into the GL canvas below) reads as the
427
430
  // only background layer.
428
- initCanvas(chart._gridlineCanvas, layout, glManager.dpr);
431
+ chart._defer2D(() => initCanvas(gridlineCanvas, layout, dpr));
429
432
  }
430
433
 
431
434
  renderInPlotFrame(gl, layout, glManager.dpr, () => {
@@ -571,10 +574,17 @@ function renderFacetedFrame(
571
574
  // One-shot destructive prep for the gridline + WebGL canvases.
572
575
  // Both phases below are per-facet; calling their destructive
573
576
  // helpers (initCanvas / renderInPlotFrame) in the loop would wipe
574
- // every previously-drawn facet, leaving only the last cell
575
- // visible.
576
- if (chart._gridlineCanvas && sampleLayout) {
577
- initCanvas(chart._gridlineCanvas, sampleLayout, glManager.dpr);
577
+ // every previously-drawn facet, leaving only the last cell visible.
578
+ //
579
+ // The gridline draws are deferred past the GPU fence (see
580
+ // `_defer2D`) so the gridline canvas doesn't present ahead of the GL
581
+ // glyphs on resize. `initCanvas` is deferred first so it runs before
582
+ // the per-facet `renderGridlines` closures below (FIFO flush order);
583
+ // the GL prep (`clearAndSetupFrame`) and draws stay in this pass.
584
+ const dpr = glManager.dpr;
585
+ const gridlineCanvas = chart._gridlineCanvas;
586
+ if (gridlineCanvas && sampleLayout) {
587
+ chart._defer2D(() => initCanvas(gridlineCanvas, sampleLayout, dpr));
578
588
  }
579
589
 
580
590
  clearAndSetupFrame(gl);
@@ -609,49 +619,56 @@ function renderFacetedFrame(
609
619
  // basemap layer is rendered into the GL canvas inside the
610
620
  // facet's scissor below.
611
621
  const isMap = chart._renderMode === "map";
612
- if (chart._gridlineCanvas && !isMap) {
613
- const localXTicks = independent
614
- ? computeTicks(
615
- buildXDomain(
616
- chart,
617
- facetDomain.xMin,
618
- facetDomain.xMax,
619
- xIsDate,
620
- ),
621
- buildYDomain(
622
- chart,
623
- facetDomain.yMin,
624
- facetDomain.yMax,
625
- yIsDate,
626
- ),
627
- cell.layout,
628
- ).xTicks
629
- : xTicks;
630
- const localYTicks = independent
631
- ? computeTicks(
632
- buildXDomain(
633
- chart,
634
- facetDomain.xMin,
635
- facetDomain.xMax,
636
- xIsDate,
637
- ),
638
- buildYDomain(
639
- chart,
640
- facetDomain.yMin,
641
- facetDomain.yMax,
642
- yIsDate,
643
- ),
644
- cell.layout,
645
- ).yTicks
646
- : yTicks;
647
- renderGridlines(
648
- chart._gridlineCanvas,
649
- cell.layout,
650
- localXTicks,
651
- localYTicks,
652
- theme,
653
- glManager.dpr,
654
- );
622
+ if (gridlineCanvas && !isMap) {
623
+ // Deferred to the post-fence 2D flush. The closure captures
624
+ // this facet's `cell` (whose `cell.layout` already carries
625
+ // the padded domain from `buildProjectionMatrix` above) plus
626
+ // the frame-local domain/ticks, so it stays correct without
627
+ // recomputing at flush time.
628
+ chart._defer2D(() => {
629
+ const localXTicks = independent
630
+ ? computeTicks(
631
+ buildXDomain(
632
+ chart,
633
+ facetDomain.xMin,
634
+ facetDomain.xMax,
635
+ xIsDate,
636
+ ),
637
+ buildYDomain(
638
+ chart,
639
+ facetDomain.yMin,
640
+ facetDomain.yMax,
641
+ yIsDate,
642
+ ),
643
+ cell.layout,
644
+ ).xTicks
645
+ : xTicks;
646
+ const localYTicks = independent
647
+ ? computeTicks(
648
+ buildXDomain(
649
+ chart,
650
+ facetDomain.xMin,
651
+ facetDomain.xMax,
652
+ xIsDate,
653
+ ),
654
+ buildYDomain(
655
+ chart,
656
+ facetDomain.yMin,
657
+ facetDomain.yMax,
658
+ yIsDate,
659
+ ),
660
+ cell.layout,
661
+ ).yTicks
662
+ : yTicks;
663
+ renderGridlines(
664
+ gridlineCanvas,
665
+ cell.layout,
666
+ localXTicks,
667
+ localYTicks,
668
+ theme,
669
+ dpr,
670
+ );
671
+ });
655
672
  }
656
673
 
657
674
  withScissor(gl, cell.layout, glManager.dpr, () => {
@@ -142,6 +142,23 @@ export abstract class AbstractChart implements ChartImplementation {
142
142
  _gridlineCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
143
143
  _chromeCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
144
144
 
145
+ /**
146
+ * 2D-canvas draw closures collected during `_fullRender` (the GL
147
+ * pass) and flushed by {@link _flush2D} *after* the scheduler's
148
+ * `awaitGpuFence`. Deferring the gridline/chrome draws past the GPU
149
+ * fence stops the 2D placeholder canvases from pushing a resized
150
+ * frame to the compositor a frame ahead of the GL present — the
151
+ * visible GL/2D misalignment on resize.
152
+ *
153
+ * Renderers wrap their 2D drawing in {@link _defer2D}; the closures
154
+ * capture the exact per-frame locals (layout, ticks, theme) so no
155
+ * state needs to be recomputed or replayed from cache at flush time.
156
+ * Reset at the start of every GL pass (see {@link requestRender} /
157
+ * {@link renderFrameSync}) so a `_fullRender` that throws mid-way
158
+ * can't leak stale closures into the next frame.
159
+ */
160
+ _deferred2D: Array<() => void> = [];
161
+
145
162
  /**
146
163
  * Host-supplied CSS-variable map. The host snapshots its DOM via
147
164
  * `snapshotThemeVars(el)` and ships it over the control channel;
@@ -630,6 +647,20 @@ export abstract class AbstractChart implements ChartImplementation {
630
647
  }
631
648
  }
632
649
 
650
+ /**
651
+ * Silently dismiss any pinned tooltip (the chart's selection visual)
652
+ * WITHOUT emitting `perspective-global-filter` — unlike the `setView`
653
+ * implicit dismiss above, the HOST initiated this (its global filter
654
+ * bar removed the clause this selection contributed), so an unselect
655
+ * emit would double-mutate the host's filter set.
656
+ * `TooltipController.dismiss()` posts the host-side pin teardown but
657
+ * never fires `onUnpin` (that emit belongs to `dispatchClick`'s
658
+ * click-to-unpin gesture), so this is emit-free by construction.
659
+ */
660
+ deselect(): void {
661
+ this._tooltip.dismiss();
662
+ }
663
+
633
664
  /**
634
665
  * Build the chart-specific {@link TooltipCallbacks} object — the
635
666
  * `onHover` / `onLeave` / `onClickPre` / `onPin` / `onDblClick`
@@ -810,17 +841,66 @@ export abstract class AbstractChart implements ChartImplementation {
810
841
  * scheduler so concurrent calls collapse to one `_fullRender` per
811
842
  * RAF and the host blitter receives one bitmap per frame. The
812
843
  * returned promise resolves after this chart's `awaitGpuFence` +
813
- * `endFrame` chain — independent of other charts in the same
814
- * RAF, which run their fence waits in parallel.
844
+ * `render2D` + `endFrame` chain — independent of other charts in the
845
+ * same RAF, which run their fence waits in parallel.
846
+ *
847
+ * `_fullRender` submits the GL commands and stashes the frame's 2D
848
+ * draws (gridlines + chrome) via {@link _defer2D}; the scheduler
849
+ * flushes them with {@link _flush2D} *after* the GPU fence, so the
850
+ * 2D canvases don't present ahead of the GL frame. The GL pass
851
+ * resets `_deferred2D` first so a mid-pass throw can't leak stale
852
+ * closures into the next frame.
815
853
  *
816
854
  * Every render-triggering caller — upload chunks, zoom / pan,
817
855
  * resize, theme invalidation, host-driven redraws — calls this.
818
- * The only sanctioned bypass is `snapshotPng`, which calls
819
- * `_fullRender` directly to keep the GL backbuffer intact for
856
+ * The only sanctioned bypass is `snapshotPng`, which uses
857
+ * {@link renderFrameSync} to keep the GL backbuffer intact for
820
858
  * `gl.readPixels`.
821
859
  */
822
860
  requestRender(glManager: WebGLContextManager): Promise<void> {
823
- return scheduleRender(glManager, () => this._fullRender(glManager));
861
+ return scheduleRender(
862
+ glManager,
863
+ () => {
864
+ this._deferred2D = [];
865
+ this._fullRender(glManager);
866
+ },
867
+ () => this._flush2D(),
868
+ );
869
+ }
870
+
871
+ /**
872
+ * Queue a 2D-canvas draw (gridline or chrome) to run after the
873
+ * GPU fence. Called from the chart renderers in place of drawing to
874
+ * the gridline/chrome canvases inline during the GL pass. See
875
+ * {@link _deferred2D}.
876
+ */
877
+ _defer2D(fn: () => void): void {
878
+ this._deferred2D.push(fn);
879
+ }
880
+
881
+ /**
882
+ * Run and clear the queued 2D-canvas draws. Invoked by the scheduler
883
+ * after `awaitGpuFence` (Phase 2), and by {@link renderFrameSync}.
884
+ */
885
+ _flush2D(): void {
886
+ const draws = this._deferred2D;
887
+ this._deferred2D = [];
888
+ for (const draw of draws) {
889
+ draw();
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Synchronous full frame (GL + 2D) for the `snapshotPng` bypass,
895
+ * which sits outside the scheduler and needs the gridline/chrome
896
+ * canvases painted before it composites them. Resets the deferred
897
+ * queue, runs the GL pass, then flushes the 2D draws immediately
898
+ * (no fence split — a snapshot isn't presented to the compositor).
899
+ */
900
+ renderFrameSync(glManager: WebGLContextManager): void {
901
+ this._deferred2D = [];
902
+ this._fullRender(glManager);
903
+ this._flush2D();
824
904
  }
825
905
 
826
906
  // Lifecycle
@@ -44,13 +44,23 @@ export interface ChartImplementation {
44
44
  requestRender(glManager: WebGLContextManager): Promise<void>;
45
45
 
46
46
  /**
47
- * The chart-specific frame builder. The scheduler wraps this with
48
- * fence + `endFrame`; callers must not invoke it directly except
49
- * for `snapshotPng`, which needs an intact GL backbuffer for
50
- * `gl.readPixels` and so must skip the `endFrame` pair.
47
+ * The chart-specific GL frame builder. Submits the GL draw commands
48
+ * and stashes the frame's 2D-canvas draws (gridlines + chrome) for
49
+ * the scheduler to flush after `awaitGpuFence`. The scheduler wraps
50
+ * this with fence + 2D flush + `endFrame`; callers must not invoke
51
+ * it directly — `snapshotPng` uses {@link renderFrameSync} instead.
51
52
  */
52
53
  _fullRender(glManager: WebGLContextManager): void;
53
54
 
55
+ /**
56
+ * Synchronous full frame (GL + 2D) for the `snapshotPng` bypass,
57
+ * which sits outside the scheduler and needs the gridline/chrome
58
+ * canvases painted before compositing. Runs the GL pass then flushes
59
+ * the deferred 2D draws immediately, skipping the fence split and
60
+ * `endFrame` so the GL backbuffer stays intact for `gl.readPixels`.
61
+ */
62
+ renderFrameSync(glManager: WebGLContextManager): void;
63
+
54
64
  /**
55
65
  * Hand the current View to the chart so it can make on-demand
56
66
  * per-row queries (for lazy tooltip column lookups). Called on
@@ -174,6 +184,15 @@ export interface ChartImplementation {
174
184
  */
175
185
  resetExpandedDomain?(): void;
176
186
 
187
+ /**
188
+ * Silently clear any active selection state (pinned tooltip) WITHOUT
189
+ * emitting selection events. Driven from the host's `deselect`
190
+ * message — its global filter bar removed a clause this chart's
191
+ * selection contributed, so an unselect emit would double-mutate the
192
+ * host's filter set.
193
+ */
194
+ deselect?(): void;
195
+
177
196
  destroy(): void;
178
197
  }
179
198
 
@@ -246,8 +265,19 @@ export interface PluginConfig {
246
265
  * Faceting strategy when `split_by` is non-empty.
247
266
  *
248
267
  * - `"grid"` — one small-multiple sub-plot per split group.
249
- * - `"overlay"` — single plot with split groups differentiated by
250
- * color. Synced into `_facetConfig.facet_mode`.
268
+ * - `"overlay"` — a single plot: cartesian charts differentiate
269
+ * split groups by color; the categorical band pipeline (series
270
+ * charts) stacks splits within each aggregate's band slot.
271
+ * Synced into `_facetConfig.facet_mode`.
272
+ *
273
+ * The default differs by family via
274
+ * `ChartTypeConfig.plugin_field_defaults`: cartesian / density /
275
+ * map chart types default to `"grid"`, the series / financial
276
+ * band-pipeline types to `"overlay"` (their historical split
277
+ * rendering). Series charts REBUILD on a mode change — grid mode
278
+ * keys the stack ladder per split (`facetSplits` in
279
+ * `buildSeriesPipeline`) so each facet grows from its own
280
+ * baseline.
251
281
  */
252
282
  facet_mode: "grid" | "overlay";
253
283
 
@@ -283,15 +313,23 @@ export interface PluginConfig {
283
313
  /**
284
314
  * Domain accumulation policy across successive `View` updates.
285
315
  *
286
- * - `"fit"` — every update recomputes the rendered domain (and on
287
- * cartesian charts, the X/Y range and color/size scales) from
316
+ * - `"fit"` — every update recomputes the affected domains from
288
317
  * the current data extent. Can grow or shrink frame-to-frame.
289
- * - `"expand"` — the rendered domain monotonically *grows*: each
318
+ * - `"expand"` — the affected domains monotonically *grow*: each
290
319
  * update unions the new data extent with the previously rendered
291
320
  * extent, so once a value is in scope it stays in scope. Reset
292
321
  * by the "Reset Zoom" button, view-config changes (group_by /
293
322
  * split_by / column-slot / column-type), or toggling back to
294
323
  * `"fit"`.
324
+ *
325
+ * AXIS SCOPE differs by family: cartesian charts (X/Y Scatter,
326
+ * X/Y Line, Density, Maps) apply it to BOTH axes plus the
327
+ * color/size scales (categorical string axes opt out — slot
328
+ * indices are frame-local); the categorical band pipeline (series
329
+ * / financial) applies it to the VALUE axis only — Y for the
330
+ * Y-family, X for X Bar — while the category axis always fits, so
331
+ * a streaming numeric/datetime `group_by` axis releases departed
332
+ * categories instead of pinning to its history.
295
333
  */
296
334
  domain_mode: "fit" | "expand";
297
335
 
@@ -157,7 +157,11 @@ function formatLevelValue(
157
157
  valid: boolean,
158
158
  levelType: string,
159
159
  ): string {
160
- if (!valid) {
160
+ // `value` can be `undefined` even when `valid` claims otherwise — an
161
+ // out-of-bounds read when a row window outran the delivered Arrow
162
+ // (belt to `loadAndRender`'s delivered-rows clamp) — treat it as
163
+ // null rather than crash the label formatters.
164
+ if (!valid || value == null || Number.isNaN(value)) {
161
165
  return "";
162
166
  }
163
167