@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,191 @@
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 { ShaderRegistry } from "./shader-registry";
14
+ import { SHADER_MANIFEST } from "./shader-manifest";
15
+
16
+ export type WebGLCanvas = HTMLCanvasElement | OffscreenCanvas;
17
+
18
+ /**
19
+ * The unit the browser actually counts against its per-agent WebGL
20
+ * context cap (~16 in Chromium): one canvas + one GL context + the
21
+ * shader programs compiled against it.
22
+ *
23
+ * Factored out of {@link WebGLContextManager} so that *many* managers
24
+ * (one per chart) can share *one* GL context. The browser limit is on
25
+ * contexts, not on the buffers/programs living inside one — a single
26
+ * context can host an arbitrary number of independent
27
+ * `WebGLBuffer`s/programs (bounded only by memory). So N charts can
28
+ * render through K ≪ N contexts as long as each keeps its own
29
+ * `BufferPool` (buffers are keyed by name; two charts would otherwise
30
+ * collide) and renders are serialized per context (the shared drawing
31
+ * buffer can hold one chart's frame at a time). See
32
+ * [context-pool.ts](./context-pool.ts) and the scheduler's per-backend
33
+ * grouping in [../render/scheduler.ts](../render/scheduler.ts).
34
+ *
35
+ * Shaders are shared per context: programs are immutable GLSL once
36
+ * compiled (uniforms are set per draw), so co-tenant charts reuse them
37
+ * and only pay the compile cost once per context.
38
+ */
39
+ let NEXT_CONTEXT_ID = 1;
40
+
41
+ export class GLContext {
42
+ readonly id: number;
43
+ readonly canvas: WebGLCanvas;
44
+ readonly gl: WebGL2RenderingContext | WebGLRenderingContext;
45
+ readonly isWebGL2: boolean;
46
+ shaders: ShaderRegistry;
47
+ private _maxVertexAttribs: number;
48
+ private _angle: ANGLE_instanced_arrays | null = null;
49
+ private _precompile: boolean;
50
+ private _destroyed = false;
51
+
52
+ /**
53
+ * Per-tenant hooks fired after a `webglcontextrestored`. Each
54
+ * {@link WebGLContextManager} on this context registers one that
55
+ * rebuilds its `BufferPool` (the shaders are rebuilt here, once, for
56
+ * all tenants). On an intentional {@link destroy} these are dropped
57
+ * without firing.
58
+ */
59
+ private _onRestore = new Set<() => void>();
60
+
61
+ constructor(canvas: WebGLCanvas, options: { precompile?: boolean } = {}) {
62
+ this.id = NEXT_CONTEXT_ID++;
63
+ this.canvas = canvas;
64
+ this._precompile = options.precompile ?? false;
65
+ const gl2 = canvas.getContext("webgl2", {
66
+ antialias: true,
67
+ alpha: true,
68
+ premultipliedAlpha: false,
69
+ });
70
+
71
+ if (gl2) {
72
+ this.gl = gl2 as WebGL2RenderingContext;
73
+ this.isWebGL2 = true;
74
+ } else {
75
+ const gl1 = canvas.getContext("webgl", {
76
+ antialias: true,
77
+ alpha: true,
78
+ premultipliedAlpha: false,
79
+ });
80
+
81
+ if (!gl1) {
82
+ throw new Error("WebGL is not supported");
83
+ }
84
+
85
+ this.gl = gl1 as WebGLRenderingContext;
86
+ this.isWebGL2 = false;
87
+ }
88
+
89
+ this.shaders = new ShaderRegistry(this.gl);
90
+ if (this._precompile) {
91
+ this.shaders.precompile(SHADER_MANIFEST);
92
+ }
93
+
94
+ this._maxVertexAttribs = this.gl.getParameter(
95
+ this.gl.MAX_VERTEX_ATTRIBS,
96
+ ) as number;
97
+ if (!this.isWebGL2) {
98
+ this._angle = this.gl.getExtension("ANGLE_instanced_arrays");
99
+ }
100
+
101
+ // Both `HTMLCanvasElement` and `OffscreenCanvas` dispatch
102
+ // `webglcontextlost` / `webglcontextrestored` on the canvas.
103
+ (canvas as EventTarget).addEventListener(
104
+ "webglcontextlost",
105
+ (e: Event) => {
106
+ // Don't reserve the slot for a restore once we've
107
+ // intentionally torn the context down — `destroy()`
108
+ // calls `loseContext()`, which fires this event.
109
+ if (this._destroyed) {
110
+ return;
111
+ }
112
+
113
+ e.preventDefault();
114
+ },
115
+ );
116
+
117
+ (canvas as EventTarget).addEventListener("webglcontextrestored", () => {
118
+ // Rebuild the shared program cache once for the whole
119
+ // context, then let each tenant rebuild its own buffers.
120
+ this.shaders.releaseAll();
121
+ this.shaders = new ShaderRegistry(this.gl);
122
+ if (this._precompile) {
123
+ this.shaders.precompile(SHADER_MANIFEST);
124
+ }
125
+
126
+ for (const cb of this._onRestore) {
127
+ cb();
128
+ }
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Return every vertex-attribute slot to its default state: disabled,
134
+ * with a zero instance divisor. Vertex-array state (enable flags,
135
+ * pointers, divisors) is global to a context's default VAO — there
136
+ * are no per-chart VAOs in this codebase — so when many charts share
137
+ * one pooled context, a slot a prior chart enabled stays enabled for
138
+ * the next chart, now pointing at a buffer the prior chart's
139
+ * teardown deleted. Its next `drawArraysInstanced` then fails GL
140
+ * validation: "no buffer is bound to enabled attribute", silently
141
+ * dropping the draw.
142
+ *
143
+ * Glyphs already (re)enable + bind + set the divisor for every slot
144
+ * they use on each frame, so clearing to the default here is a safe
145
+ * baseline — it only removes the *leftover* enables a sibling chart
146
+ * left behind. Called from {@link WebGLContextManager.beginFrame} in
147
+ * shared (pooled) mode only; the 1:1 default never shares a context
148
+ * and so keeps its original, reset-free path.
149
+ */
150
+ resetVertexArrayState(): void {
151
+ const gl = this.gl;
152
+ for (let i = 0; i < this._maxVertexAttribs; i++) {
153
+ gl.disableVertexAttribArray(i);
154
+ if (this.isWebGL2) {
155
+ (gl as WebGL2RenderingContext).vertexAttribDivisor(i, 0);
156
+ } else {
157
+ this._angle?.vertexAttribDivisorANGLE(i, 0);
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Register a tenant's post-restore rebuild hook. Returns a disposer.
164
+ */
165
+ onRestore(cb: () => void): () => void {
166
+ this._onRestore.add(cb);
167
+ return () => this._onRestore.delete(cb);
168
+ }
169
+
170
+ /**
171
+ * True once lost — either our own {@link destroy} or a browser
172
+ * eviction of the oldest context past the per-agent cap.
173
+ */
174
+ isContextLost(): boolean {
175
+ return this._destroyed || this.gl.isContextLost();
176
+ }
177
+
178
+ get destroyed(): boolean {
179
+ return this._destroyed;
180
+ }
181
+
182
+ destroy(): void {
183
+ this._destroyed = true;
184
+ this._onRestore.clear();
185
+ this.shaders.releaseAll();
186
+ const ext = this.gl.getExtension("WEBGL_lose_context");
187
+ if (ext) {
188
+ ext.loseContext();
189
+ }
190
+ }
191
+ }
@@ -108,6 +108,12 @@ export function createQuadCornerBuffer(gl: GL): WebGLBuffer {
108
108
  * paint zero data). No-op return `true` when `attr` is negative
109
109
  * (optimized-out attribute — drawing the rest is still valid).
110
110
  *
111
+ * `firstInstance` byte-offsets the attribute pointer so the draw's
112
+ * instance 0 reads element `firstInstance` of the buffer — the WebGL
113
+ * substitute for a `baseInstance` draw parameter. Faceted renderers
114
+ * use it to draw a contiguous per-facet sub-range of a shared
115
+ * instance buffer.
116
+ *
111
117
  * Render-path uses `peek`, never `getOrCreate`: the latter recreates
112
118
  * with zero-initialized contents when `_totalCapacity` has grown past
113
119
  * the current buffer, which would wipe the previous draw's vertex
@@ -120,6 +126,7 @@ export function bindInstancedFloatAttr(
120
126
  attr: number,
121
127
  name: string,
122
128
  components: number,
129
+ firstInstance = 0,
123
130
  ): boolean {
124
131
  if (attr < 0) {
125
132
  return true;
@@ -133,7 +140,14 @@ export function bindInstancedFloatAttr(
133
140
  const gl: GL = glManager.gl;
134
141
  gl.bindBuffer(gl.ARRAY_BUFFER, buf.buffer);
135
142
  gl.enableVertexAttribArray(attr);
136
- gl.vertexAttribPointer(attr, components, gl.FLOAT, false, 0, 0);
143
+ gl.vertexAttribPointer(
144
+ attr,
145
+ components,
146
+ gl.FLOAT,
147
+ false,
148
+ 0,
149
+ firstInstance * components * Float32Array.BYTES_PER_ELEMENT,
150
+ );
137
151
  instancing.setDivisor(attr, 1);
138
152
  return true;
139
153
  }
@@ -40,9 +40,12 @@ export function dispatch(r: WorkerRenderer, msg: ControlMsg): void {
40
40
  case "redraw":
41
41
  r.redraw();
42
42
  break;
43
+ case "deselect":
44
+ r.chartImpl.deselect?.();
45
+ break;
43
46
  case "resize":
44
47
  r.resize(msg.cssWidth, msg.cssHeight, msg.dpr);
45
- r.redraw();
48
+ r.redrawAck(msg.msgId);
46
49
  break;
47
50
  case "clear":
48
51
  r.clear();
@@ -16,6 +16,8 @@ import type { Client, Table, View } from "@perspective-dev/client";
16
16
 
17
17
  import type * as wasm_module_type from "@perspective-dev/viewer/dist/wasm/perspective-viewer.js";
18
18
  import { WebGLContextManager } from "../webgl/context-manager";
19
+ import { ContextPool } from "../webgl/context-pool";
20
+ import { RENDER_CONTEXT_POOL_SIZE } from "../config";
19
21
  import { ChartImplementation } from "../charts/chart";
20
22
  import { ZoomController } from "../interaction/zoom-controller";
21
23
  import {
@@ -37,7 +39,7 @@ import { viewToColumnDataMap } from "../data/view-reader";
37
39
  import { loadFontDeduped } from "./font-loader";
38
40
  import { dispatch } from "./dispatch";
39
41
  import { installSessionHost } from "./session-host";
40
- import { deferIfDraining } from "../render/scheduler";
42
+ import { deferIfDraining, unregister } from "../render/scheduler";
41
43
 
42
44
  /**
43
45
  * Sentinel thrown inside the `with_typed_arrays` callback when a newer
@@ -76,6 +78,25 @@ async function resolveChartImpl(
76
78
  return await factory();
77
79
  }
78
80
 
81
+ /**
82
+ * Renderer-scope shared context pool for pooled blit mode. Lazily
83
+ * created on first use so non-blit / non-pooled scopes never allocate
84
+ * one. One per renderer scope (the worker, or the main thread in
85
+ * in-process mode) — `WorkerRenderer`s in the same scope share its K
86
+ * contexts.
87
+ */
88
+ let CONTEXT_POOL: ContextPool | null = null;
89
+
90
+ function getContextPool(precompile: boolean): ContextPool {
91
+ if (!CONTEXT_POOL) {
92
+ CONTEXT_POOL = new ContextPool(RENDER_CONTEXT_POOL_SIZE, {
93
+ precompile,
94
+ });
95
+ }
96
+
97
+ return CONTEXT_POOL;
98
+ }
99
+
79
100
  export class WorkerRenderer {
80
101
  chartImpl: ChartImplementation;
81
102
  glManager: WebGLContextManager;
@@ -131,20 +152,35 @@ export class WorkerRenderer {
131
152
 
132
153
  this.chartImpl = new ImplClass();
133
154
 
134
- // Direct mode hands us the host's transferred `.webgl-canvas`.
135
- // Blit mode omits it — the renderer owns its own offscreen
136
- // surface and posts each completed frame back as an
137
- // `ImageBitmap` via the `endFrame` callback wired below.
138
- const glCanvas =
139
- msg.glCanvas ??
140
- new OffscreenCanvas(
141
- Math.max(1, Math.round(msg.cssWidth * msg.dpr)),
142
- Math.max(1, Math.round(msg.cssHeight * msg.dpr)),
155
+ // Three surfaces, by mode:
156
+ // - direct: the host's transferred `.webgl-canvas` (1:1 with a
157
+ // context, permanently).
158
+ // - pooled blit: borrow one of K shared contexts so live-
159
+ // context count stays bounded regardless of chart count.
160
+ // - unpooled blit: a private offscreen surface per chart.
161
+ // Blit modes omit `msg.glCanvas`; the renderer ships each frame
162
+ // as an `ImageBitmap` via the `endFrame` callback wired below.
163
+ const pooled =
164
+ msg.renderMode === "blit" &&
165
+ !msg.glCanvas &&
166
+ RENDER_CONTEXT_POOL_SIZE > 0;
167
+
168
+ if (pooled) {
169
+ this.glManager = new WebGLContextManager(
170
+ getContextPool(msg.precompileShaders ?? false).acquire(),
143
171
  );
172
+ } else {
173
+ const glCanvas =
174
+ msg.glCanvas ??
175
+ new OffscreenCanvas(
176
+ Math.max(1, Math.round(msg.cssWidth * msg.dpr)),
177
+ Math.max(1, Math.round(msg.cssHeight * msg.dpr)),
178
+ );
144
179
 
145
- this.glManager = new WebGLContextManager(glCanvas, {
146
- precompile: msg.precompileShaders ?? false,
147
- });
180
+ this.glManager = new WebGLContextManager(glCanvas, {
181
+ precompile: msg.precompileShaders ?? false,
182
+ });
183
+ }
148
184
 
149
185
  if (msg.renderMode === "blit") {
150
186
  this.glManager.setFrameCallback((bitmap) => {
@@ -259,16 +295,23 @@ export class WorkerRenderer {
259
295
  try {
260
296
  await viewToColumnDataMap(
261
297
  this.view,
262
- async (cols) => {
298
+ async (cols, deliveredRows) => {
263
299
  if (this._renderGen !== myGen) {
264
300
  throw new StaleGenerationError();
265
301
  }
266
302
 
303
+ // `totalRows` came from a SEPARATE `num_rows()`
304
+ // round-trip; a fast-updating table with deletions
305
+ // can shrink the view before the Arrow fetch, which
306
+ // clamps its window silently. Iterating to the stale
307
+ // count reads typed arrays out-of-bounds (`undefined`
308
+ // values no validity bitmap covers) — clamp to what
309
+ // was actually delivered.
267
310
  await this.chartImpl.uploadAndRender(
268
311
  this.glManager,
269
312
  cols,
270
313
  0,
271
- totalRows,
314
+ Math.min(totalRows, deliveredRows),
272
315
  );
273
316
  },
274
317
  { end_row: totalRows, float32: msg.options.float32 },
@@ -291,6 +334,24 @@ export class WorkerRenderer {
291
334
  this.chartImpl.requestRender(this.glManager);
292
335
  }
293
336
 
337
+ /**
338
+ * `redraw`, acked to the host AFTER the frame's present completes —
339
+ * the reply backing the viewer's presize contract (`ResizeAckMsg`):
340
+ * `plugin.resize()` must not resolve until the resized frame is
341
+ * actually on screen. A resize landing mid-present defers its
342
+ * canvas mutation (`deferIfDraining`), but the redraw's own frame
343
+ * applies pending dimensions in `beginFrame`, so the present this
344
+ * awaits IS at the new size. Always acks — a failed present or a
345
+ * torn-down chart resolves the host's await rather than stranding
346
+ * it (completion, not success, is the contract).
347
+ */
348
+ redrawAck(msgId: number): void {
349
+ this.chartImpl
350
+ .requestRender(this.glManager)
351
+ .catch(() => {})
352
+ .finally(() => this.post({ kind: "resizeAck", msgId }));
353
+ }
354
+
294
355
  resize(cssWidth: number, cssHeight: number, dpr: number): void {
295
356
  // `glManager.resize` would set `canvas.width = N`, which the
296
357
  // spec mandates clears the drawing buffer immediately. In
@@ -528,13 +589,19 @@ export class WorkerRenderer {
528
589
  * Composite the three layers into a single PNG `Blob`.
529
590
  */
530
591
  async snapshotPng(): Promise<Blob> {
531
- // Snapshot bypasses the scheduler's drain, so it must
532
- // mirror Phase 1's "apply pending resize before paint"
533
- // step itself — otherwise a snapshot taken after a resize
534
- // message but before the next drain would render at the
535
- // previous dimensions.
536
- this.glManager.applyPendingResize();
537
- this.chartImpl._fullRender(this.glManager);
592
+ // Snapshot bypasses the scheduler's drain, so it must mirror
593
+ // the per-frame prep itself — apply any pending resize, and in
594
+ // pooled blit mode size the shared canvas to this chart and
595
+ // reset shared GL state — otherwise a snapshot taken after a
596
+ // resize message but before the next drain (or after a co-tenant
597
+ // chart left the shared canvas at its own size) would render at
598
+ // the wrong dimensions.
599
+ this.glManager.beginFrame();
600
+ // `renderFrameSync` (not `_fullRender`) so the gridline + chrome
601
+ // 2D canvases are actually painted here — the scheduler normally
602
+ // defers those to a post-fence flush, but the snapshot path
603
+ // bypasses the scheduler and composites the 2D layers below.
604
+ this.chartImpl.renderFrameSync(this.glManager);
538
605
  const gl = this.glManager.gl;
539
606
  const glCanvas = gl.canvas as OffscreenCanvas;
540
607
  const w = glCanvas.width;
@@ -574,6 +641,11 @@ export class WorkerRenderer {
574
641
  }
575
642
 
576
643
  destroy(): void {
644
+ // Drop any frame this manager still has queued in the
645
+ // module-level scheduler *before* losing its GL context, so a
646
+ // next-RAF `drain()` can't paint/present against a dead
647
+ // context (the "scheduler: present failed" path).
648
+ unregister(this.glManager);
577
649
  this.chartImpl.destroy();
578
650
  this.glManager.destroy();
579
651
  }
@@ -21,6 +21,20 @@ import { dispatch } from "./dispatch";
21
21
 
22
22
  const RENDERERS = new Map<number, WorkerRenderer>();
23
23
 
24
+ /**
25
+ * Sessions whose `destroy` arrived before their async `bootstrap`
26
+ * resolved. Bootstrap does multiple awaits (wasm `initSync`, font
27
+ * loads, `open_table`, lazy chart-impl import); a host that connects
28
+ * then immediately disconnects a viewer can post `init` and `destroy`
29
+ * back-to-back, with `destroy` landing while the renderer is still
30
+ * being built. The plain `RENDERERS.get(sessionId)` lookup in the
31
+ * destroy branch would miss it and the bootstrap would then register a
32
+ * renderer (and its WebGL context) that nothing ever tears down —
33
+ * leaking one context per raced toggle until the browser evicts the
34
+ * oldest. We tombstone the id here and honor it when bootstrap lands.
35
+ */
36
+ const PENDING_DESTROY = new Set<number>();
37
+
24
38
  function postSession(
25
39
  sessionId: number,
26
40
  msg: WorkerMsg,
@@ -89,9 +103,18 @@ export function installSessionHost(
89
103
 
90
104
  bootstrap(msg as InitMsg, makeSessionPort(sessionId))
91
105
  .then((r) => {
106
+ // A `destroy` raced ahead of bootstrap completing —
107
+ // honor it now instead of leaking the freshly-built
108
+ // renderer + its WebGL context.
109
+ if (PENDING_DESTROY.delete(sessionId)) {
110
+ dispatch(r, { kind: "destroy" });
111
+ return;
112
+ }
113
+
92
114
  RENDERERS.set(sessionId, r);
93
115
  })
94
116
  .catch((err) => {
117
+ PENDING_DESTROY.delete(sessionId);
95
118
  postSession(sessionId, {
96
119
  kind: "error",
97
120
  message: String(err),
@@ -105,6 +128,12 @@ export function installSessionHost(
105
128
  if (r) {
106
129
  dispatch(r, msg);
107
130
  RENDERERS.delete(sessionId);
131
+ } else {
132
+ // Renderer not registered yet — bootstrap is still in
133
+ // flight. Tombstone the id so the bootstrap `.then`
134
+ // tears it down on arrival instead of registering a
135
+ // leaked context.
136
+ PENDING_DESTROY.add(sessionId);
108
137
  }
109
138
 
110
139
  return;