@perspective-dev/viewer-charts 4.5.0 → 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.
Files changed (84) hide show
  1. package/LICENSE.md +193 -0
  2. package/dist/cdn/perspective-viewer-charts.js +2 -2
  3. package/dist/cdn/perspective-viewer-charts.js.map +3 -3
  4. package/dist/esm/axis/bar-axis.d.ts +9 -1
  5. package/dist/esm/axis/categorical-axis.d.ts +0 -2
  6. package/dist/esm/charts/cartesian/cartesian.d.ts +26 -0
  7. package/dist/esm/charts/common/category-axis-resolver.d.ts +43 -1
  8. package/dist/esm/charts/common/expand-domain.d.ts +20 -0
  9. package/dist/esm/charts/common/tree-chart.d.ts +7 -0
  10. package/dist/esm/charts/common/tree-chrome.d.ts +23 -1
  11. package/dist/esm/charts/common/tree-interact.d.ts +46 -0
  12. package/dist/esm/charts/series/glyphs/draw-lines.d.ts +11 -4
  13. package/dist/esm/charts/series/series-build.d.ts +38 -2
  14. package/dist/esm/charts/series/series-render.d.ts +1 -4
  15. package/dist/esm/charts/series/series-type.d.ts +19 -17
  16. package/dist/esm/charts/series/series.d.ts +16 -0
  17. package/dist/esm/charts/sunburst/sunburst-interact.d.ts +1 -1
  18. package/dist/esm/charts/treemap/treemap-interact.d.ts +1 -6
  19. package/dist/esm/config.d.ts +19 -0
  20. package/dist/esm/interaction/host-sink-message.d.ts +10 -28
  21. package/dist/esm/interaction/raw-event-forwarder.d.ts +6 -7
  22. package/dist/esm/interaction/zoom-controller.d.ts +31 -20
  23. package/dist/esm/interaction/zoom-router.d.ts +3 -26
  24. package/dist/esm/perspective-viewer-charts.js +2 -2
  25. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  26. package/dist/esm/plugin/plugin.d.ts +15 -3
  27. package/dist/esm/render/scheduler.d.ts +23 -0
  28. package/dist/esm/theme/palette.d.ts +0 -5
  29. package/dist/esm/transport/protocol.d.ts +2 -7
  30. package/dist/esm/webgl/context-manager.d.ts +100 -117
  31. package/dist/esm/webgl/context-pool.d.ts +40 -0
  32. package/dist/esm/webgl/gl-context.d.ts +54 -0
  33. package/dist/esm/worker/renderer.worker.d.ts +2 -4
  34. package/package.json +45 -45
  35. package/src/ts/axis/bar-axis.ts +74 -45
  36. package/src/ts/axis/categorical-axis.ts +0 -2
  37. package/src/ts/charts/candlestick/candlestick-render.ts +10 -7
  38. package/src/ts/charts/candlestick/candlestick.ts +10 -29
  39. package/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +36 -2
  40. package/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +36 -2
  41. package/src/ts/charts/cartesian/cartesian-build.ts +143 -9
  42. package/src/ts/charts/cartesian/cartesian-render.ts +205 -30
  43. package/src/ts/charts/cartesian/cartesian.ts +43 -4
  44. package/src/ts/charts/cartesian/glyphs/density.ts +36 -41
  45. package/src/ts/charts/cartesian/glyphs/lines.ts +13 -15
  46. package/src/ts/charts/cartesian/glyphs/points.ts +12 -17
  47. package/src/ts/charts/chart-base.ts +20 -6
  48. package/src/ts/charts/chart.ts +1 -1
  49. package/src/ts/charts/common/category-axis-resolver.ts +135 -1
  50. package/src/ts/charts/common/expand-domain.ts +40 -0
  51. package/src/ts/charts/common/tree-chart.ts +16 -0
  52. package/src/ts/charts/common/tree-chrome.ts +86 -1
  53. package/src/ts/charts/common/tree-interact.ts +209 -0
  54. package/src/ts/charts/heatmap/heatmap-render.ts +9 -11
  55. package/src/ts/charts/series/glyphs/draw-areas.ts +30 -1
  56. package/src/ts/charts/series/glyphs/draw-lines.ts +151 -76
  57. package/src/ts/charts/series/series-build.ts +394 -21
  58. package/src/ts/charts/series/series-render.ts +159 -38
  59. package/src/ts/charts/series/series-type.ts +37 -17
  60. package/src/ts/charts/series/series.ts +63 -68
  61. package/src/ts/charts/sunburst/sunburst-interact.ts +18 -162
  62. package/src/ts/charts/sunburst/sunburst-render.ts +24 -89
  63. package/src/ts/charts/sunburst/sunburst.ts +1 -15
  64. package/src/ts/charts/treemap/treemap-interact.ts +22 -189
  65. package/src/ts/charts/treemap/treemap-render.ts +19 -46
  66. package/src/ts/charts/treemap/treemap.ts +1 -16
  67. package/src/ts/config.ts +21 -1
  68. package/src/ts/interaction/host-sink-message.ts +33 -22
  69. package/src/ts/interaction/raw-event-forwarder.ts +10 -12
  70. package/src/ts/interaction/zoom-controller.ts +120 -83
  71. package/src/ts/interaction/zoom-router.ts +3 -126
  72. package/src/ts/map/tile-layer.ts +13 -13
  73. package/src/ts/plugin/plugin.ts +176 -196
  74. package/src/ts/render/scheduler.ts +125 -42
  75. package/src/ts/shaders/line-uniform.frag.glsl +2 -1
  76. package/src/ts/shaders/line-uniform.vert.glsl +19 -0
  77. package/src/ts/theme/palette.ts +1 -4
  78. package/src/ts/transport/protocol.ts +3 -8
  79. package/src/ts/webgl/context-manager.ts +195 -189
  80. package/src/ts/webgl/context-pool.ts +78 -0
  81. package/src/ts/webgl/gl-context.ts +191 -0
  82. package/src/ts/worker/dispatch.ts +0 -1
  83. package/src/ts/worker/renderer.worker.ts +72 -65
  84. package/src/ts/worker/session-host.ts +29 -0
@@ -11,137 +11,125 @@
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
13
  import { ShaderRegistry } from "./shader-registry";
14
- import { SHADER_MANIFEST } from "./shader-manifest";
15
14
  import { BufferPool } from "./buffer-pool";
15
+ import { GLContext, type WebGLCanvas } from "./gl-context";
16
16
 
17
- export type WebGLCanvas = HTMLCanvasElement | OffscreenCanvas;
17
+ export type { WebGLCanvas } from "./gl-context";
18
18
 
19
19
  export interface WebGLContextManagerOptions {
20
20
  /**
21
21
  * If `true`, compile + link every shader in `SHADER_MANIFEST`
22
- * during construction (and again after a context-loss/restore
23
- * cycle). Trades ~30-100ms of init time for elimination of the
24
- * compile/link cost on the first-frame path of every chart type
25
- * that ends up rendered. Default `false` keeps the original
26
- * lazy-compile behavior — programs are compiled on first
27
- * `getOrCreate(name, ...)` call from a glyph's render path.
22
+ * during context construction (and again after a context-loss/
23
+ * restore cycle). Trades ~30-100ms of init time for elimination of
24
+ * the compile/link cost on the first-frame path. Ignored when the
25
+ * manager is constructed against an already-built shared
26
+ * {@link GLContext} — that context owns the precompile decision.
27
+ * Default `false` keeps lazy-compile behavior.
28
28
  */
29
29
  precompile?: boolean;
30
30
  }
31
31
 
32
+ /**
33
+ * Per-chart render handle. Owns the chart's GPU **buffers** and frame
34
+ * bookkeeping, but only *borrows* its GL **context** — either a private
35
+ * one it constructs (the default 1:1 mode, where the manager is handed a
36
+ * raw canvas) or a shared {@link GLContext} from the
37
+ * [ContextPool](./context-pool.ts) (pooled blit mode, where many
38
+ * managers share K contexts to stay under the browser's per-agent
39
+ * context cap).
40
+ *
41
+ * Buffers are always per-manager: {@link BufferPool} keys buffers by
42
+ * name, so two charts sharing one pool would stomp each other's `"x"` /
43
+ * `"y"` / `"color"`. Shaders, the GL context, and the canvas drawing
44
+ * buffer are shared per {@link GLContext}; the scheduler serializes
45
+ * renders of managers that share a context so the shared drawing buffer
46
+ * only ever holds one chart's frame at a time (see {@link beginFrame}).
47
+ */
32
48
  export class WebGLContextManager {
33
- private _canvas: WebGLCanvas;
34
- private _gl: WebGL2RenderingContext | WebGLRenderingContext;
35
- private _isWebGL2: boolean;
36
- private _shaders: ShaderRegistry;
49
+ private _ctx: GLContext;
50
+ private _ownsCtx: boolean;
51
+ private _disposeRestore: () => void;
37
52
  private _buffers: BufferPool;
38
53
  private _uploadedCount = 0;
39
54
  private _cssWidth = 0;
40
55
  private _cssHeight = 0;
41
56
  private _dpr = 1;
42
- private _precompile: boolean;
57
+ private _destroyed = false;
43
58
  private _frameCallback: ((bitmap: ImageBitmap) => void) | null = null;
44
59
 
45
60
  /**
46
61
  * Per-instance `MessageChannel` used by `_yieldToTask` to resume a
47
- * polling `awaitGpuFence` loop on the next task. Allocated lazily —
48
- * the polling path is rarely hit when the GPU is idle, and many
49
- * `WebGLContextManager` instances never need one.
50
- *
62
+ * polling `awaitGpuFence` loop on the next task. Allocated lazily.
51
63
  * Must be per-instance: a module-level singleton races when two
52
- * managers poll concurrently. Both call sites assign
53
- * `port1.onmessage = resolve`, the second assignment overwrites the
54
- * first, and the first poll's promise never settles — leaving its
55
- * `awaitGpuFence` hung. The hang propagates up through the render
56
- * scheduler's `present` → `uploadAndRender` → the worker's
57
- * `uploadChunkAck` → the host's `with_typed_arrays` callback,
58
- * stalling `draw()` indefinitely. (Now that fence waits across
59
- * different `WebGLContextManager`s run in parallel inside one
60
- * scheduler drain, the per-instance discipline matters even
61
- * more — concurrent poll loops are the common case, not the
62
- * exception.)
63
- *
64
- * Cost of per-instance allocation: one extra `MessageChannel` (two
65
- * `MessagePort`s, ~negligible bytes, zero idle CPU) per chart on
66
- * top of the per-chart proxy channel that the transport already
67
- * holds. Bounded by chart count; safe even for pathological pages
68
- * with hundreds of charts. The alternative — allocating a fresh
69
- * channel on every `_yieldToTask` call — would churn ports on every
70
- * fence poll (potentially many per frame on slow GPUs), which is
71
- * far worse for the structured-clone subsystem than holding one
72
- * port pair for the manager's lifetime.
64
+ * managers poll concurrently (the second `onmessage` assignment
65
+ * clobbers the first's resolver, hanging that fence wait). Now that
66
+ * fence waits across managers run in parallel within one scheduler
67
+ * drain, concurrent poll loops are the common case.
73
68
  */
74
69
  private _yieldChannel: MessageChannel | null = null;
75
70
 
76
- constructor(canvas: WebGLCanvas, options: WebGLContextManagerOptions = {}) {
77
- this._canvas = canvas;
78
- this._precompile = options.precompile ?? false;
79
- const gl2 = canvas.getContext("webgl2", {
80
- antialias: true,
81
- alpha: true,
82
- premultipliedAlpha: false,
83
- });
71
+ private _pendingYieldResolve: (() => void) | null = null;
84
72
 
85
- if (gl2) {
86
- this._gl = gl2 as WebGL2RenderingContext;
87
- this._isWebGL2 = true;
73
+ /**
74
+ * @param source A raw canvas (manager constructs and **owns** a
75
+ * private {@link GLContext}) or an existing shared `GLContext`
76
+ * (manager borrows it; the pool owns its lifecycle).
77
+ */
78
+ constructor(
79
+ source: WebGLCanvas | GLContext,
80
+ options: WebGLContextManagerOptions = {},
81
+ ) {
82
+ if (source instanceof GLContext) {
83
+ this._ctx = source;
84
+ this._ownsCtx = false;
88
85
  } else {
89
- const gl1 = canvas.getContext("webgl", {
90
- antialias: true,
91
- alpha: true,
92
- premultipliedAlpha: false,
86
+ this._ctx = new GLContext(source, {
87
+ precompile: options.precompile ?? false,
93
88
  });
94
-
95
- if (!gl1) {
96
- throw new Error("WebGL is not supported");
97
- }
98
-
99
- this._gl = gl1 as WebGLRenderingContext;
100
- this._isWebGL2 = false;
89
+ this._ownsCtx = true;
101
90
  }
102
91
 
103
- this._shaders = new ShaderRegistry(this._gl);
104
- this._buffers = new BufferPool(this._gl);
105
-
106
- if (this._precompile) {
107
- this._shaders.precompile(SHADER_MANIFEST);
108
- }
92
+ this._buffers = new BufferPool(this._ctx.gl);
109
93
 
110
- // Both `HTMLCanvasElement` and `OffscreenCanvas` dispatch
111
- // `webglcontextlost` / `webglcontextrestored` events on the
112
- // canvas itself. `addEventListener` exists on both.
113
- (canvas as EventTarget).addEventListener(
114
- "webglcontextlost",
115
- (e: Event) => {
116
- e.preventDefault();
117
- },
118
- );
119
-
120
- (canvas as EventTarget).addEventListener("webglcontextrestored", () => {
121
- this._shaders.releaseAll();
94
+ // Rebuild this tenant's buffers after a context restore. The
95
+ // shared `GLContext` rebuilds the shader cache itself, once.
96
+ this._disposeRestore = this._ctx.onRestore(() => {
122
97
  this._buffers.releaseAll();
123
- this._shaders = new ShaderRegistry(this._gl);
124
- this._buffers = new BufferPool(this._gl);
98
+ this._buffers = new BufferPool(this._ctx.gl);
125
99
  this._uploadedCount = 0;
126
-
127
- // Re-prime the cache after restore so post-recovery
128
- // first-frame doesn't re-pay the lazy compile cost.
129
- if (this._precompile) {
130
- this._shaders.precompile(SHADER_MANIFEST);
131
- }
132
100
  });
133
101
  }
134
102
 
135
103
  get gl(): WebGL2RenderingContext | WebGLRenderingContext {
136
- return this._gl;
104
+ return this._ctx.gl;
105
+ }
106
+
107
+ /**
108
+ * Identifies the shared GL context behind this manager. The
109
+ * scheduler groups pending renders by this id: managers on the same
110
+ * backend serialize (they share one drawing buffer); managers on
111
+ * different backends render in parallel. In the default 1:1 mode
112
+ * every manager has its own backend, so every render is independent
113
+ * — identical to the pre-pooling behavior.
114
+ */
115
+ get backendId(): number {
116
+ return this._ctx.id;
117
+ }
118
+
119
+ /**
120
+ * True once this manager's context has been lost — its own
121
+ * {@link destroy}, or a browser eviction of the shared context.
122
+ */
123
+ isContextLost(): boolean {
124
+ return this._destroyed || this._ctx.isContextLost();
137
125
  }
138
126
 
139
127
  get isWebGL2(): boolean {
140
- return this._isWebGL2;
128
+ return this._ctx.isWebGL2;
141
129
  }
142
130
 
143
131
  get shaders(): ShaderRegistry {
144
- return this._shaders;
132
+ return this._ctx.shaders;
145
133
  }
146
134
 
147
135
  get bufferPool(): BufferPool {
@@ -158,11 +146,10 @@ export class WebGLContextManager {
158
146
 
159
147
  /**
160
148
  * Resize the GL canvas's bitmap to match the host's CSS layout. The
161
- * Host is responsible for measuring the DOM element (or otherwise
162
- * deciding the target CSS size) and the device pixel ratio — the
163
- * manager itself does not touch DOM, so the same code path works
164
- * whether the canvas is an `HTMLCanvasElement` (in-process) or an
165
- * `OffscreenCanvas` (in-process via transfer, or in a worker).
149
+ * host measures the DOM element and DPR — the manager never touches
150
+ * DOM, so the same path works for an `HTMLCanvasElement`
151
+ * (in-process), a transferred `OffscreenCanvas` (direct/worker), or
152
+ * a pool-shared `OffscreenCanvas` (pooled blit).
166
153
  */
167
154
  resize(cssWidth: number, cssHeight: number, dpr: number): void {
168
155
  this._cssWidth = cssWidth;
@@ -171,11 +158,11 @@ export class WebGLContextManager {
171
158
 
172
159
  const width = Math.round(cssWidth * dpr);
173
160
  const height = Math.round(cssHeight * dpr);
174
-
175
- if (this._canvas.width !== width || this._canvas.height !== height) {
176
- this._canvas.width = width;
177
- this._canvas.height = height;
178
- this._gl.viewport(0, 0, width, height);
161
+ const canvas = this._ctx.canvas;
162
+ if (canvas.width !== width || canvas.height !== height) {
163
+ canvas.width = width;
164
+ canvas.height = height;
165
+ this._ctx.gl.viewport(0, 0, width, height);
179
166
  }
180
167
  }
181
168
 
@@ -191,28 +178,16 @@ export class WebGLContextManager {
191
178
  } | null = null;
192
179
 
193
180
  /**
194
- * Record a dimension change to be applied at the start of the
195
- * next render's Phase 1, *before* `_fullRender` runs. The actual
196
- * `canvas.width = N` assignment (which clears the drawing buffer
197
- * per the WebGL spec) happens inside `applyPendingResize()`,
198
- * paired in the same synchronous task as the paint that fills
199
- * the new buffer.
200
- *
201
- * Why split the dimension change off from the existing
202
- * {@link resize} method: in direct / in-process modes the
203
- * GL canvas IS the host's visible canvas, and `canvas.width = N`
204
- * is immediately observable to the browser's compositor as a
205
- * cleared buffer. If the resize lands in the message handler
206
- * (one task) but the matching `_fullRender` lands in the next
207
- * RAF (a later task), the compositor cycles between them and
208
- * presents one full frame of empty canvas — visible flicker.
209
- * Deferring the dimension change to the same RAF as the paint
210
- * eliminates the inter-frame gap; both happen inside Phase 1's
211
- * un-yielded loop.
181
+ * Record a dimension change to be applied at the start of the next
182
+ * render's Phase 1, before `_fullRender` runs. The actual
183
+ * `canvas.width = N` assignment (which clears the drawing buffer per
184
+ * the WebGL spec) happens inside {@link applyPendingResize} /
185
+ * {@link beginFrame}, paired in the same synchronous task as the
186
+ * paint that fills the new buffer — eliminating the one-frame blank
187
+ * the compositor would otherwise show in direct/in-process modes
188
+ * (where the GL canvas IS the visible canvas).
212
189
  *
213
- * Multiple `requestResize` calls before the next render coalesce
214
- * to last-write-wins — five rapid width changes from a window
215
- * drag produce one resize+paint, not five.
190
+ * Multiple calls before the next render coalesce to last-write-wins.
216
191
  */
217
192
  requestResize(cssWidth: number, cssHeight: number, dpr: number): void {
218
193
  this._pendingResize = { cssWidth, cssHeight, dpr };
@@ -220,11 +195,7 @@ export class WebGLContextManager {
220
195
 
221
196
  /**
222
197
  * Apply any pending dimension change recorded by
223
- * {@link requestResize}. Called by the scheduler's Phase 1
224
- * (immediately before each entry's `fullRender`) and by the
225
- * `snapshotPng` bypass path. Returns `true` when a resize was
226
- * applied, `false` when there was nothing pending — useful for
227
- * callers that want to skip a no-op render.
198
+ * {@link requestResize}. Returns `true` when a resize was applied.
228
199
  */
229
200
  applyPendingResize(): boolean {
230
201
  if (!this._pendingResize) {
@@ -238,61 +209,99 @@ export class WebGLContextManager {
238
209
  }
239
210
 
240
211
  /**
241
- * Last CSS width passed to `resize()`.
212
+ * Prepare the shared GL surface for *this* manager's render. Called
213
+ * by the scheduler immediately before `_fullRender` (and by the
214
+ * `snapshotPng` bypass).
215
+ *
216
+ * - **Own context (1:1):** identical to the old Phase-1 step — apply
217
+ * only a *pending* resize. The context isn't shared, so there is
218
+ * no sibling state to reset and no need to touch the canvas when
219
+ * dimensions are unchanged.
220
+ * - **Shared context (pooled):** a co-tenant chart rendered last and
221
+ * left the shared canvas at *its* dimensions with *its* GL state.
222
+ * Force the canvas to this manager's dimensions and reset the
223
+ * global GL state a sibling might have left dirty (bound
224
+ * framebuffer, viewport, scissor/depth enables) so frames can't
225
+ * bleed across charts. Per-frame draw state (blend, clear) is
226
+ * re-established by the chart's `clearAndSetupFrame`.
242
227
  */
228
+ beginFrame(): void {
229
+ if (this._ownsCtx) {
230
+ this.applyPendingResize();
231
+ return;
232
+ }
233
+
234
+ // Honor a queued `requestResize` (it carries the latest dims but
235
+ // hasn't touched the canvas yet), then force the shared canvas
236
+ // to this manager's dims — a co-tenant chart may have left it at
237
+ // its own size. `resize` is a no-op when dims already match.
238
+ const pending = this._pendingResize;
239
+ this._pendingResize = null;
240
+ if (pending) {
241
+ this.resize(pending.cssWidth, pending.cssHeight, pending.dpr);
242
+ } else {
243
+ this.resize(this._cssWidth, this._cssHeight, this._dpr);
244
+ }
245
+
246
+ const gl = this._ctx.gl;
247
+ const canvas = this._ctx.canvas;
248
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
249
+ gl.viewport(0, 0, canvas.width, canvas.height);
250
+ gl.disable(gl.SCISSOR_TEST);
251
+ gl.disable(gl.DEPTH_TEST);
252
+ this._ctx.resetVertexArrayState();
253
+ }
254
+
243
255
  get cssWidth(): number {
244
256
  return this._cssWidth;
245
257
  }
246
258
 
247
- /**
248
- * Last CSS height passed to `resize()`.
249
- */
250
259
  get cssHeight(): number {
251
260
  return this._cssHeight;
252
261
  }
253
262
 
254
- /**
255
- * Last device pixel ratio passed to `resize()`.
256
- */
257
263
  get dpr(): number {
258
264
  return this._dpr;
259
265
  }
260
266
 
261
267
  clear(): void {
262
- this._gl.clearColor(0, 0, 0, 0);
263
- this._gl.clear(this._gl.COLOR_BUFFER_BIT | this._gl.DEPTH_BUFFER_BIT);
268
+ this._ctx.gl.clearColor(0, 0, 0, 0);
269
+ this._ctx.gl.clear(
270
+ this._ctx.gl.COLOR_BUFFER_BIT | this._ctx.gl.DEPTH_BUFFER_BIT,
271
+ );
264
272
  this._uploadedCount = 0;
265
273
  }
266
274
 
267
275
  /**
268
276
  * Register a per-frame hook invoked at the end of each render. In
269
- * blit-mode rendering, the worker installs a callback that
270
- * transfers an `ImageBitmap` from `_canvas` (an `OffscreenCanvas`)
271
- * back to the host so the visible display canvas can `drawImage`
272
- * it. In direct mode the callback is left null and `endFrame` is a
273
- * no-op.
274
- *
275
- * Pass `null` to detach.
277
+ * blit-mode rendering the worker installs a callback that transfers
278
+ * an `ImageBitmap` from the canvas back to the host. In direct mode
279
+ * the callback is null and `endFrame` is a no-op. Pass `null` to
280
+ * detach.
276
281
  */
277
282
  setFrameCallback(cb: ((bitmap: ImageBitmap) => void) | null): void {
278
283
  this._frameCallback = cb;
279
284
  }
280
285
 
281
286
  /**
282
- * Called by chart impls at the bottom of `_fullRender` (and any
283
- * other path that produces a complete frame). When a frame
284
- * callback is registered AND the GL surface is an
285
- * `OffscreenCanvas`, ship its current contents as an
286
- * `ImageBitmap` to the host. Otherwise no-op — direct-mode
287
- * rendering has nothing to ship; the visible canvas already holds
288
- * the drawing buffer.
287
+ * Ship the current canvas contents as an `ImageBitmap` to the host
288
+ * when a frame callback is registered and the surface is an
289
+ * `OffscreenCanvas`. Otherwise no-op (direct mode paints straight to
290
+ * the visible drawing buffer).
289
291
  */
290
292
  endFrame(): void {
291
293
  if (!this._frameCallback) {
292
294
  return;
293
295
  }
294
296
 
295
- const canvas = this._canvas as
297
+ // A context lost mid-frame (our own `destroy`, or a browser
298
+ // eviction between paint and present) makes
299
+ // `transferToImageBitmap` throw. Bail quietly.
300
+ if (this.isContextLost()) {
301
+ return;
302
+ }
303
+
304
+ const canvas = this._ctx.canvas as
296
305
  | OffscreenCanvas
297
306
  | (HTMLCanvasElement & { transferToImageBitmap?: never });
298
307
  if (
@@ -308,30 +317,17 @@ export class WebGLContextManager {
308
317
 
309
318
  /**
310
319
  * Resolve when every GL command submitted up to this call has been
311
- * executed by the GPU.
312
- *
313
- * On WebGL2 this issues a `fenceSync(SYNC_GPU_COMMANDS_COMPLETE)`
314
- * and polls `clientWaitSync` with a zero timeout, yielding to the
315
- * task queue between polls. The first poll passes
316
- * `SYNC_FLUSH_COMMANDS_BIT` so the fence becomes reachable without
317
- * a separate `gl.flush()`.
318
- *
319
- * On WebGL1 there is no fenceSync; we fall back to the blocking
320
- * `gl.finish()`. This is acceptable in a worker — never call this
321
- * from the main thread on a heavy frame.
322
- *
323
- * Used as a per-frame "GPU is idle" barrier so callers can serialize
324
- * follow-on work (`endFrame` snapshot, present roundtrip, the next
325
- * chunk upload) against actual GPU completion instead of the
326
- * implicit, implementation-defined timing of `transferToImageBitmap`.
320
+ * executed by the GPU. WebGL2 issues a `fenceSync` and polls
321
+ * `clientWaitSync` (yielding to the task queue between polls); WebGL1
322
+ * falls back to a blocking `gl.finish()` (acceptable in a worker).
327
323
  */
328
324
  async awaitGpuFence(): Promise<void> {
329
- if (!this._isWebGL2) {
330
- this._gl.finish();
325
+ if (!this._ctx.isWebGL2) {
326
+ this._ctx.gl.finish();
331
327
  return;
332
328
  }
333
329
 
334
- const gl = this._gl as WebGL2RenderingContext;
330
+ const gl = this._ctx.gl as WebGL2RenderingContext;
335
331
  const fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
336
332
  if (!fence) {
337
333
  gl.finish();
@@ -356,6 +352,10 @@ export class WebGLContextManager {
356
352
 
357
353
  flags = 0;
358
354
  await this._yieldToTask();
355
+
356
+ if (this._destroyed) {
357
+ return;
358
+ }
359
359
  }
360
360
  } finally {
361
361
  gl.deleteSync(fence);
@@ -367,48 +367,54 @@ export class WebGLContextManager {
367
367
  }
368
368
 
369
369
  /**
370
- * Yield to the task queue between fence polls. We avoid
371
- * `setTimeout(0)` because Chromium clamps nested `setTimeout` to
372
- * ~4ms in workers, which would inflate the measured cost of
373
- * `awaitGpuFence`. A reused per-instance `MessageChannel` lands the
374
- * resume in the next task with sub-ms latency.
375
- *
376
- * `addEventListener(..., { once: true })` is used over
377
- * `port1.onmessage = ...` so concurrent in-flight resumes (should
378
- * any path ever introduce them) cannot clobber each other's
379
- * resolvers — the previous module-level singleton lost a resolver
380
- * on every overlap and hung one chart's `draw()` indefinitely.
370
+ * Yield to the task queue between fence polls. `setTimeout(0)` is
371
+ * avoided because Chromium clamps nested worker `setTimeout` to
372
+ * ~4ms; a reused per-instance `MessageChannel` resumes in the next
373
+ * task with sub-ms latency. `{ once: true }` over `onmessage = ...`
374
+ * so concurrent resumes can't clobber each other's resolvers.
381
375
  */
382
376
  private _yieldToTask(): Promise<void> {
383
377
  if (!this._yieldChannel) {
384
378
  this._yieldChannel = new MessageChannel();
385
- // `addEventListener` does not auto-start a `MessagePort` —
386
- // only `onmessage = ...` does. Start once at allocation so
387
- // posted resumes are actually delivered.
388
379
  this._yieldChannel.port1.start();
389
380
  }
390
381
 
391
382
  return new Promise<void>((resolve) => {
392
383
  const ch = this._yieldChannel!;
393
- ch.port1.addEventListener("message", () => resolve(), {
394
- once: true,
395
- });
384
+ this._pendingYieldResolve = resolve;
385
+ ch.port1.addEventListener(
386
+ "message",
387
+ () => {
388
+ this._pendingYieldResolve = null;
389
+ resolve();
390
+ },
391
+ { once: true },
392
+ );
396
393
  ch.port2.postMessage(null);
397
394
  });
398
395
  }
399
396
 
400
397
  destroy(): void {
398
+ this._destroyed = true;
399
+ this._frameCallback = null;
400
+ this._disposeRestore();
401
401
  this._buffers.releaseAll();
402
- this._shaders.releaseAll();
402
+ if (this._pendingYieldResolve) {
403
+ const resolve = this._pendingYieldResolve;
404
+ this._pendingYieldResolve = null;
405
+ resolve();
406
+ }
407
+
403
408
  if (this._yieldChannel) {
404
409
  this._yieldChannel.port1.close();
405
410
  this._yieldChannel.port2.close();
406
411
  this._yieldChannel = null;
407
412
  }
408
413
 
409
- const ext = this._gl.getExtension("WEBGL_lose_context");
410
- if (ext) {
411
- ext.loseContext();
414
+ // Only lose the GL context when we own it. A shared context
415
+ // outlives any one tenant — the pool destroys it.
416
+ if (this._ownsCtx) {
417
+ this._ctx.destroy();
412
418
  }
413
419
  }
414
420
  }
@@ -0,0 +1,78 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ import { GLContext } from "./gl-context";
14
+
15
+ /**
16
+ * Renderer-scope pool of shared {@link GLContext}s for pooled blit mode.
17
+ *
18
+ * The browser caps live WebGL contexts per agent (~16 in Chromium) and
19
+ * force-loses the oldest past that cap. A page with more than ~16 charts
20
+ * therefore can't give each its own context. This pool caps the number
21
+ * of live contexts at `size`, distributing renderers across them
22
+ * round-robin (sticky for a renderer's lifetime — a renderer keeps the
23
+ * same context so its resident GPU buffers and any cached textures/FBOs
24
+ * stay valid frame to frame). N charts render through K = `size`
25
+ * contexts; the scheduler serializes renders that land on the same
26
+ * context.
27
+ *
28
+ * Pooling applies to blit mode only: direct mode renders into the host's
29
+ * transferred visible canvas, which is permanently 1:1 with a context.
30
+ *
31
+ * One pool per renderer scope (the worker, or the main thread in
32
+ * in-process mode). Contexts are created lazily on first `acquire` and
33
+ * live until renderer-scope teardown — like the contexts they replace,
34
+ * they are not refcounted, because a shared context outlives any single
35
+ * borrowing renderer.
36
+ */
37
+ export class ContextPool {
38
+ private _size: number;
39
+ private _precompile: boolean;
40
+ private _contexts: GLContext[] = [];
41
+ private _next = 0;
42
+
43
+ constructor(size: number, options: { precompile?: boolean } = {}) {
44
+ this._size = Math.max(1, size);
45
+ this._precompile = options.precompile ?? false;
46
+ }
47
+
48
+ /**
49
+ * Borrow a context for a new renderer. Fills the pool to `size`
50
+ * before reusing, then round-robins. A dead context (browser
51
+ * eviction) is replaced in place so a borrower never receives a lost
52
+ * context.
53
+ */
54
+ acquire(): GLContext {
55
+ let ctx: GLContext;
56
+ if (this._contexts.length < this._size) {
57
+ ctx = this._make();
58
+ this._contexts.push(ctx);
59
+ } else {
60
+ const idx = this._next % this._contexts.length;
61
+ this._next++;
62
+ if (this._contexts[idx].isContextLost()) {
63
+ this._contexts[idx] = this._make();
64
+ }
65
+
66
+ ctx = this._contexts[idx];
67
+ }
68
+
69
+ return ctx;
70
+ }
71
+
72
+ private _make(): GLContext {
73
+ // Initial size is arbitrary — every render resizes the shared
74
+ // canvas to the borrowing renderer's dimensions in `beginFrame`.
75
+ const canvas = new OffscreenCanvas(1, 1);
76
+ return new GLContext(canvas, { precompile: this._precompile });
77
+ }
78
+ }