@perspective-dev/viewer-charts 4.5.1 → 4.5.2

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.
@@ -11,7 +11,6 @@ export declare class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement
11
11
  private _rendererPromise;
12
12
  private _rawEventForwarder;
13
13
  private _generation;
14
- private _renderBlitMode;
15
14
  private _resetClickAbort;
16
15
  /**
17
16
  * Plugin-scoped global config. Seeded lazily from
@@ -64,7 +63,21 @@ export declare class HTMLPerspectiveViewerWebGLPluginElement extends HTMLElement
64
63
  */
65
64
  private _setupInteraction;
66
65
  private _buildRenderer;
67
- setBlitMode(mode: "direct" | "blit"): void;
66
+ /**
67
+ * Select the GL presentation strategy for *all* chart-type plugins
68
+ * in this renderer. Static + process-global: the value is shared by
69
+ * every per-chart-type subclass, so it must be set once before the
70
+ * charts that should use it build their renderers (a renderer reads
71
+ * {@link BLIT_MODE} at construction in `_buildRenderer`; charts
72
+ * already built keep their mode until torn down and rebuilt).
73
+ *
74
+ * - `"direct"` — each chart owns a GL context 1:1 with its visible
75
+ * canvas (lowest latency; bounded by the browser's ~16-context
76
+ * cap).
77
+ * - `"blit"` — charts render off-screen and share a pool of GL
78
+ * contexts ([webgl/context-pool.ts]), so a page can exceed the cap.
79
+ */
80
+ static setBlitMode(mode: "direct" | "blit"): void;
68
81
  get_static_config(): PluginStaticConfig;
69
82
  column_config_schema(column_type: string, _group: string | undefined, _column_name: string, current_value: Record<string, unknown> | null, _viewer_config?: {
70
83
  group_by?: string[];
@@ -34,6 +34,29 @@ export declare function requestRender(glManager: WebGLContextManager, fullRender
34
34
  * picks it up for the next RAF.
35
35
  */
36
36
  export declare function deferIfDraining(glManager: WebGLContextManager, op: () => void): void;
37
+ /**
38
+ * Drop every scheduler reference to `glManager` — called from
39
+ * `WebGLContextManager`'s owning renderer at teardown, *before*
40
+ * `glManager.destroy()` loses the GL context.
41
+ *
42
+ * Without this, a frame queued for the next RAF (an `Entry` in
43
+ * `pending`) or a `deferIfDraining` op still parked in `deferred`
44
+ * would survive the destroy and, on the next `drain()`, drive
45
+ * `fullRender` / `present` against a context-lost manager. In blit
46
+ * mode that surfaces as `endFrame`'s `transferToImageBitmap` throwing
47
+ * "Cannot transfer to ImageBitmap because WebGL context is lost" —
48
+ * logged as "scheduler: present failed". In direct mode it's a wasted
49
+ * paint against a dead context.
50
+ *
51
+ * Outstanding waiters for a destroyed manager are resolved (not
52
+ * rejected): the caller asked to tear the chart down, so its awaited
53
+ * `draw()` should observe a clean no-op rather than an error it would
54
+ * have to suppress. An in-flight `present()` (manager in `inFlight`)
55
+ * is left to unwind on its own — its `finally` clause will not find a
56
+ * `deferred` entry to flush, and the manager is already gone from
57
+ * `pending`, so it cannot re-enqueue.
58
+ */
59
+ export declare function unregister(glManager: WebGLContextManager): void;
37
60
  /**
38
61
  * Test-only: clear pending state. Production callers must not use
39
62
  * this — outstanding waiters are silently dropped.
@@ -1,62 +1,78 @@
1
1
  import { ShaderRegistry } from "./shader-registry";
2
2
  import { BufferPool } from "./buffer-pool";
3
- export type WebGLCanvas = HTMLCanvasElement | OffscreenCanvas;
3
+ import { GLContext, type WebGLCanvas } from "./gl-context";
4
+ export type { WebGLCanvas } from "./gl-context";
4
5
  export interface WebGLContextManagerOptions {
5
6
  /**
6
7
  * If `true`, compile + link every shader in `SHADER_MANIFEST`
7
- * during construction (and again after a context-loss/restore
8
- * cycle). Trades ~30-100ms of init time for elimination of the
9
- * compile/link cost on the first-frame path of every chart type
10
- * that ends up rendered. Default `false` keeps the original
11
- * lazy-compile behavior — programs are compiled on first
12
- * `getOrCreate(name, ...)` call from a glyph's render path.
8
+ * during context construction (and again after a context-loss/
9
+ * restore cycle). Trades ~30-100ms of init time for elimination of
10
+ * the compile/link cost on the first-frame path. Ignored when the
11
+ * manager is constructed against an already-built shared
12
+ * {@link GLContext} — that context owns the precompile decision.
13
+ * Default `false` keeps lazy-compile behavior.
13
14
  */
14
15
  precompile?: boolean;
15
16
  }
17
+ /**
18
+ * Per-chart render handle. Owns the chart's GPU **buffers** and frame
19
+ * bookkeeping, but only *borrows* its GL **context** — either a private
20
+ * one it constructs (the default 1:1 mode, where the manager is handed a
21
+ * raw canvas) or a shared {@link GLContext} from the
22
+ * [ContextPool](./context-pool.ts) (pooled blit mode, where many
23
+ * managers share K contexts to stay under the browser's per-agent
24
+ * context cap).
25
+ *
26
+ * Buffers are always per-manager: {@link BufferPool} keys buffers by
27
+ * name, so two charts sharing one pool would stomp each other's `"x"` /
28
+ * `"y"` / `"color"`. Shaders, the GL context, and the canvas drawing
29
+ * buffer are shared per {@link GLContext}; the scheduler serializes
30
+ * renders of managers that share a context so the shared drawing buffer
31
+ * only ever holds one chart's frame at a time (see {@link beginFrame}).
32
+ */
16
33
  export declare class WebGLContextManager {
17
- private _canvas;
18
- private _gl;
19
- private _isWebGL2;
20
- private _shaders;
34
+ private _ctx;
35
+ private _ownsCtx;
36
+ private _disposeRestore;
21
37
  private _buffers;
22
38
  private _uploadedCount;
23
39
  private _cssWidth;
24
40
  private _cssHeight;
25
41
  private _dpr;
26
- private _precompile;
42
+ private _destroyed;
27
43
  private _frameCallback;
28
44
  /**
29
45
  * Per-instance `MessageChannel` used by `_yieldToTask` to resume a
30
- * polling `awaitGpuFence` loop on the next task. Allocated lazily —
31
- * the polling path is rarely hit when the GPU is idle, and many
32
- * `WebGLContextManager` instances never need one.
33
- *
46
+ * polling `awaitGpuFence` loop on the next task. Allocated lazily.
34
47
  * Must be per-instance: a module-level singleton races when two
35
- * managers poll concurrently. Both call sites assign
36
- * `port1.onmessage = resolve`, the second assignment overwrites the
37
- * first, and the first poll's promise never settles — leaving its
38
- * `awaitGpuFence` hung. The hang propagates up through the render
39
- * scheduler's `present` → `uploadAndRender` → the worker's
40
- * `uploadChunkAck` → the host's `with_typed_arrays` callback,
41
- * stalling `draw()` indefinitely. (Now that fence waits across
42
- * different `WebGLContextManager`s run in parallel inside one
43
- * scheduler drain, the per-instance discipline matters even
44
- * more — concurrent poll loops are the common case, not the
45
- * exception.)
46
- *
47
- * Cost of per-instance allocation: one extra `MessageChannel` (two
48
- * `MessagePort`s, ~negligible bytes, zero idle CPU) per chart on
49
- * top of the per-chart proxy channel that the transport already
50
- * holds. Bounded by chart count; safe even for pathological pages
51
- * with hundreds of charts. The alternative — allocating a fresh
52
- * channel on every `_yieldToTask` call — would churn ports on every
53
- * fence poll (potentially many per frame on slow GPUs), which is
54
- * far worse for the structured-clone subsystem than holding one
55
- * port pair for the manager's lifetime.
48
+ * managers poll concurrently (the second `onmessage` assignment
49
+ * clobbers the first's resolver, hanging that fence wait). Now that
50
+ * fence waits across managers run in parallel within one scheduler
51
+ * drain, concurrent poll loops are the common case.
56
52
  */
57
53
  private _yieldChannel;
58
- constructor(canvas: WebGLCanvas, options?: WebGLContextManagerOptions);
54
+ private _pendingYieldResolve;
55
+ /**
56
+ * @param source A raw canvas (manager constructs and **owns** a
57
+ * private {@link GLContext}) or an existing shared `GLContext`
58
+ * (manager borrows it; the pool owns its lifecycle).
59
+ */
60
+ constructor(source: WebGLCanvas | GLContext, options?: WebGLContextManagerOptions);
59
61
  get gl(): WebGL2RenderingContext | WebGLRenderingContext;
62
+ /**
63
+ * Identifies the shared GL context behind this manager. The
64
+ * scheduler groups pending renders by this id: managers on the same
65
+ * backend serialize (they share one drawing buffer); managers on
66
+ * different backends render in parallel. In the default 1:1 mode
67
+ * every manager has its own backend, so every render is independent
68
+ * — identical to the pre-pooling behavior.
69
+ */
70
+ get backendId(): number;
71
+ /**
72
+ * True once this manager's context has been lost — its own
73
+ * {@link destroy}, or a browser eviction of the shared context.
74
+ */
75
+ isContextLost(): boolean;
60
76
  get isWebGL2(): boolean;
61
77
  get shaders(): ShaderRegistry;
62
78
  get bufferPool(): BufferPool;
@@ -64,11 +80,10 @@ export declare class WebGLContextManager {
64
80
  set uploadedCount(count: number);
65
81
  /**
66
82
  * Resize the GL canvas's bitmap to match the host's CSS layout. The
67
- * Host is responsible for measuring the DOM element (or otherwise
68
- * deciding the target CSS size) and the device pixel ratio — the
69
- * manager itself does not touch DOM, so the same code path works
70
- * whether the canvas is an `HTMLCanvasElement` (in-process) or an
71
- * `OffscreenCanvas` (in-process via transfer, or in a worker).
83
+ * host measures the DOM element and DPR — the manager never touches
84
+ * DOM, so the same path works for an `HTMLCanvasElement`
85
+ * (in-process), a transferred `OffscreenCanvas` (direct/worker), or
86
+ * a pool-shared `OffscreenCanvas` (pooled blit).
72
87
  */
73
88
  resize(cssWidth: number, cssHeight: number, dpr: number): void;
74
89
  /**
@@ -78,106 +93,74 @@ export declare class WebGLContextManager {
78
93
  */
79
94
  private _pendingResize;
80
95
  /**
81
- * Record a dimension change to be applied at the start of the
82
- * next render's Phase 1, *before* `_fullRender` runs. The actual
83
- * `canvas.width = N` assignment (which clears the drawing buffer
84
- * per the WebGL spec) happens inside `applyPendingResize()`,
85
- * paired in the same synchronous task as the paint that fills
86
- * the new buffer.
96
+ * Record a dimension change to be applied at the start of the next
97
+ * render's Phase 1, before `_fullRender` runs. The actual
98
+ * `canvas.width = N` assignment (which clears the drawing buffer per
99
+ * the WebGL spec) happens inside {@link applyPendingResize} /
100
+ * {@link beginFrame}, paired in the same synchronous task as the
101
+ * paint that fills the new buffer — eliminating the one-frame blank
102
+ * the compositor would otherwise show in direct/in-process modes
103
+ * (where the GL canvas IS the visible canvas).
87
104
  *
88
- * Why split the dimension change off from the existing
89
- * {@link resize} method: in direct / in-process modes the
90
- * GL canvas IS the host's visible canvas, and `canvas.width = N`
91
- * is immediately observable to the browser's compositor as a
92
- * cleared buffer. If the resize lands in the message handler
93
- * (one task) but the matching `_fullRender` lands in the next
94
- * RAF (a later task), the compositor cycles between them and
95
- * presents one full frame of empty canvas — visible flicker.
96
- * Deferring the dimension change to the same RAF as the paint
97
- * eliminates the inter-frame gap; both happen inside Phase 1's
98
- * un-yielded loop.
99
- *
100
- * Multiple `requestResize` calls before the next render coalesce
101
- * to last-write-wins — five rapid width changes from a window
102
- * drag produce one resize+paint, not five.
105
+ * Multiple calls before the next render coalesce to last-write-wins.
103
106
  */
104
107
  requestResize(cssWidth: number, cssHeight: number, dpr: number): void;
105
108
  /**
106
109
  * Apply any pending dimension change recorded by
107
- * {@link requestResize}. Called by the scheduler's Phase 1
108
- * (immediately before each entry's `fullRender`) and by the
109
- * `snapshotPng` bypass path. Returns `true` when a resize was
110
- * applied, `false` when there was nothing pending — useful for
111
- * callers that want to skip a no-op render.
110
+ * {@link requestResize}. Returns `true` when a resize was applied.
112
111
  */
113
112
  applyPendingResize(): boolean;
114
113
  /**
115
- * Last CSS width passed to `resize()`.
114
+ * Prepare the shared GL surface for *this* manager's render. Called
115
+ * by the scheduler immediately before `_fullRender` (and by the
116
+ * `snapshotPng` bypass).
117
+ *
118
+ * - **Own context (1:1):** identical to the old Phase-1 step — apply
119
+ * only a *pending* resize. The context isn't shared, so there is
120
+ * no sibling state to reset and no need to touch the canvas when
121
+ * dimensions are unchanged.
122
+ * - **Shared context (pooled):** a co-tenant chart rendered last and
123
+ * left the shared canvas at *its* dimensions with *its* GL state.
124
+ * Force the canvas to this manager's dimensions and reset the
125
+ * global GL state a sibling might have left dirty (bound
126
+ * framebuffer, viewport, scissor/depth enables) so frames can't
127
+ * bleed across charts. Per-frame draw state (blend, clear) is
128
+ * re-established by the chart's `clearAndSetupFrame`.
116
129
  */
130
+ beginFrame(): void;
117
131
  get cssWidth(): number;
118
- /**
119
- * Last CSS height passed to `resize()`.
120
- */
121
132
  get cssHeight(): number;
122
- /**
123
- * Last device pixel ratio passed to `resize()`.
124
- */
125
133
  get dpr(): number;
126
134
  clear(): void;
127
135
  /**
128
136
  * Register a per-frame hook invoked at the end of each render. In
129
- * blit-mode rendering, the worker installs a callback that
130
- * transfers an `ImageBitmap` from `_canvas` (an `OffscreenCanvas`)
131
- * back to the host so the visible display canvas can `drawImage`
132
- * it. In direct mode the callback is left null and `endFrame` is a
133
- * no-op.
134
- *
135
- * Pass `null` to detach.
137
+ * blit-mode rendering the worker installs a callback that transfers
138
+ * an `ImageBitmap` from the canvas back to the host. In direct mode
139
+ * the callback is null and `endFrame` is a no-op. Pass `null` to
140
+ * detach.
136
141
  */
137
142
  setFrameCallback(cb: ((bitmap: ImageBitmap) => void) | null): void;
138
143
  /**
139
- * Called by chart impls at the bottom of `_fullRender` (and any
140
- * other path that produces a complete frame). When a frame
141
- * callback is registered AND the GL surface is an
142
- * `OffscreenCanvas`, ship its current contents as an
143
- * `ImageBitmap` to the host. Otherwise no-op — direct-mode
144
- * rendering has nothing to ship; the visible canvas already holds
145
- * the drawing buffer.
144
+ * Ship the current canvas contents as an `ImageBitmap` to the host
145
+ * when a frame callback is registered and the surface is an
146
+ * `OffscreenCanvas`. Otherwise no-op (direct mode paints straight to
147
+ * the visible drawing buffer).
146
148
  */
147
149
  endFrame(): void;
148
150
  /**
149
151
  * Resolve when every GL command submitted up to this call has been
150
- * executed by the GPU.
151
- *
152
- * On WebGL2 this issues a `fenceSync(SYNC_GPU_COMMANDS_COMPLETE)`
153
- * and polls `clientWaitSync` with a zero timeout, yielding to the
154
- * task queue between polls. The first poll passes
155
- * `SYNC_FLUSH_COMMANDS_BIT` so the fence becomes reachable without
156
- * a separate `gl.flush()`.
157
- *
158
- * On WebGL1 there is no fenceSync; we fall back to the blocking
159
- * `gl.finish()`. This is acceptable in a worker — never call this
160
- * from the main thread on a heavy frame.
161
- *
162
- * Used as a per-frame "GPU is idle" barrier so callers can serialize
163
- * follow-on work (`endFrame` snapshot, present roundtrip, the next
164
- * chunk upload) against actual GPU completion instead of the
165
- * implicit, implementation-defined timing of `transferToImageBitmap`.
152
+ * executed by the GPU. WebGL2 issues a `fenceSync` and polls
153
+ * `clientWaitSync` (yielding to the task queue between polls); WebGL1
154
+ * falls back to a blocking `gl.finish()` (acceptable in a worker).
166
155
  */
167
156
  awaitGpuFence(): Promise<void>;
168
157
  ensureBufferCapacity(totalRows: number): void;
169
158
  /**
170
- * Yield to the task queue between fence polls. We avoid
171
- * `setTimeout(0)` because Chromium clamps nested `setTimeout` to
172
- * ~4ms in workers, which would inflate the measured cost of
173
- * `awaitGpuFence`. A reused per-instance `MessageChannel` lands the
174
- * resume in the next task with sub-ms latency.
175
- *
176
- * `addEventListener(..., { once: true })` is used over
177
- * `port1.onmessage = ...` so concurrent in-flight resumes (should
178
- * any path ever introduce them) cannot clobber each other's
179
- * resolvers — the previous module-level singleton lost a resolver
180
- * on every overlap and hung one chart's `draw()` indefinitely.
159
+ * Yield to the task queue between fence polls. `setTimeout(0)` is
160
+ * avoided because Chromium clamps nested worker `setTimeout` to
161
+ * ~4ms; a reused per-instance `MessageChannel` resumes in the next
162
+ * task with sub-ms latency. `{ once: true }` over `onmessage = ...`
163
+ * so concurrent resumes can't clobber each other's resolvers.
181
164
  */
182
165
  private _yieldToTask;
183
166
  destroy(): void;
@@ -0,0 +1,40 @@
1
+ import { GLContext } from "./gl-context";
2
+ /**
3
+ * Renderer-scope pool of shared {@link GLContext}s for pooled blit mode.
4
+ *
5
+ * The browser caps live WebGL contexts per agent (~16 in Chromium) and
6
+ * force-loses the oldest past that cap. A page with more than ~16 charts
7
+ * therefore can't give each its own context. This pool caps the number
8
+ * of live contexts at `size`, distributing renderers across them
9
+ * round-robin (sticky for a renderer's lifetime — a renderer keeps the
10
+ * same context so its resident GPU buffers and any cached textures/FBOs
11
+ * stay valid frame to frame). N charts render through K = `size`
12
+ * contexts; the scheduler serializes renders that land on the same
13
+ * context.
14
+ *
15
+ * Pooling applies to blit mode only: direct mode renders into the host's
16
+ * transferred visible canvas, which is permanently 1:1 with a context.
17
+ *
18
+ * One pool per renderer scope (the worker, or the main thread in
19
+ * in-process mode). Contexts are created lazily on first `acquire` and
20
+ * live until renderer-scope teardown — like the contexts they replace,
21
+ * they are not refcounted, because a shared context outlives any single
22
+ * borrowing renderer.
23
+ */
24
+ export declare class ContextPool {
25
+ private _size;
26
+ private _precompile;
27
+ private _contexts;
28
+ private _next;
29
+ constructor(size: number, options?: {
30
+ precompile?: boolean;
31
+ });
32
+ /**
33
+ * Borrow a context for a new renderer. Fills the pool to `size`
34
+ * before reusing, then round-robins. A dead context (browser
35
+ * eviction) is replaced in place so a borrower never receives a lost
36
+ * context.
37
+ */
38
+ acquire(): GLContext;
39
+ private _make;
40
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perspective-dev/viewer-charts",
3
- "version": "4.5.1",
3
+ "version": "4.5.2",
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",
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`.
@@ -125,6 +125,22 @@ const GLOBAL_STYLES = (() => {
125
125
  return [sheet];
126
126
  })();
127
127
 
128
+ /**
129
+ * Process-global GL presentation strategy, shared by *every* chart-type
130
+ * plugin in this renderer. Seeded from the build-time {@link RENDER_BLIT_MODE}
131
+ * and overridable at runtime via
132
+ * {@link HTMLPerspectiveViewerWebGLPluginElement.setBlitMode}.
133
+ *
134
+ * Module scope (not per-instance) on purpose: blit-vs-direct is a
135
+ * whole-renderer decision — in `"blit"` mode the worker shares a pool of
136
+ * GL contexts across all charts ([webgl/context-pool.ts]), so a page
137
+ * can't sensibly mix strategies per chart. Every per-chart-type subclass
138
+ * registered in [index.ts] reads this one value when it builds its
139
+ * renderer, so setting it once (before the first chart renders) applies
140
+ * to all of them.
141
+ */
142
+ let BLIT_MODE: "direct" | "blit" = RENDER_BLIT_MODE;
143
+
128
144
  export class HTMLPerspectiveViewerWebGLPluginElement
129
145
  extends HTMLElement
130
146
  implements IPerspectiveViewerPlugin
@@ -139,7 +155,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement
139
155
  private _rendererPromise: Promise<RendererTransport> | null = null;
140
156
  private _rawEventForwarder: RawEventForwarder | null = null;
141
157
  private _generation = 0;
142
- private _renderBlitMode: "direct" | "blit" = RENDER_BLIT_MODE;
143
158
  private _resetClickAbort: AbortController | null = null;
144
159
 
145
160
  /**
@@ -257,13 +272,41 @@ export class HTMLPerspectiveViewerWebGLPluginElement
257
272
  return this._rendererPromise;
258
273
  }
259
274
 
260
- this._rendererPromise = this._buildRenderer(view).then((r) => {
261
- this._renderer = r;
262
- this._setupInteraction(r);
263
- return r;
264
- });
275
+ // `_buildRenderer` is async — it awaits `getClient`, `getTable`,
276
+ // and a full worker handshake — so a `disconnectedCallback`
277
+ // (toggle / chart-type switch) frequently lands *before* this
278
+ // `.then` runs. At that point `this._renderer` is still null,
279
+ // so `delete()`'s `if (this._renderer)` teardown is skipped and
280
+ // the freshly-built transport (and its WebGL context) would be
281
+ // assigned to a detached element and leak — one context per
282
+ // raced toggle, until the browser evicts the oldest.
283
+ //
284
+ // `delete()` sets `_rendererPromise = null`, so promise identity
285
+ // is the dispose signal: if `this._rendererPromise` no longer
286
+ // points at *this* build when it resolves, the element was
287
+ // deleted (or a reconnect started a newer build) and we destroy
288
+ // the orphan instead of adopting it. Promise identity — not
289
+ // `_generation`, which every `draw`/`update` bumps — is the
290
+ // right token: a rapid draw→update must NOT tear down the
291
+ // single in-flight build it shares via this memoized promise.
292
+ const p: Promise<RendererTransport> = this._buildRenderer(view).then(
293
+ (r) => {
294
+ if (this._rendererPromise !== p) {
295
+ r.destroy();
296
+ throw new Error("renderer disposed during init");
297
+ }
298
+
299
+ this._renderer = r;
300
+ this._setupInteraction(r);
301
+ return r;
302
+ },
303
+ );
265
304
 
266
- return this._rendererPromise;
305
+ // Swallow the dispose rejection so it doesn't surface as an
306
+ // unhandled rejection; `_drawImpl` catches it and bails.
307
+ p.catch(() => {});
308
+ this._rendererPromise = p;
309
+ return p;
267
310
  }
268
311
 
269
312
  /**
@@ -350,15 +393,28 @@ export class HTMLPerspectiveViewerWebGLPluginElement
350
393
  },
351
394
  pluginConfig: this._pluginConfig,
352
395
  defaultChartType: this._chartType.default_chart_type,
353
- renderBlitMode: this._renderBlitMode,
396
+ renderBlitMode: BLIT_MODE,
354
397
  });
355
398
 
356
399
  return transport;
357
400
  }
358
401
 
359
- setBlitMode(mode: "direct" | "blit") {
360
- console.assert(this._initialized, "Already initialized");
361
- this._renderBlitMode = mode;
402
+ /**
403
+ * Select the GL presentation strategy for *all* chart-type plugins
404
+ * in this renderer. Static + process-global: the value is shared by
405
+ * every per-chart-type subclass, so it must be set once before the
406
+ * charts that should use it build their renderers (a renderer reads
407
+ * {@link BLIT_MODE} at construction in `_buildRenderer`; charts
408
+ * already built keep their mode until torn down and rebuilt).
409
+ *
410
+ * - `"direct"` — each chart owns a GL context 1:1 with its visible
411
+ * canvas (lowest latency; bounded by the browser's ~16-context
412
+ * cap).
413
+ * - `"blit"` — charts render off-screen and share a pool of GL
414
+ * contexts ([webgl/context-pool.ts]), so a page can exceed the cap.
415
+ */
416
+ static setBlitMode(mode: "direct" | "blit") {
417
+ BLIT_MODE = mode;
362
418
  }
363
419
 
364
420
  get_static_config(): PluginStaticConfig {
@@ -499,7 +555,15 @@ export class HTMLPerspectiveViewerWebGLPluginElement
499
555
 
500
556
  private async _drawImpl(view: View): Promise<void> {
501
557
  const gen = ++this._generation;
502
- const renderer = await this._ensureRenderer(view);
558
+ let renderer: RendererTransport;
559
+ try {
560
+ renderer = await this._ensureRenderer(view);
561
+ } catch {
562
+ // Renderer was disposed mid-init (element disconnected
563
+ // during the async build) — nothing to draw.
564
+ return;
565
+ }
566
+
503
567
  if (this._generation !== gen) {
504
568
  return;
505
569
  }