@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
@@ -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
@@ -138,8 +154,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement
138
154
  private _renderer: RendererTransport | null = null;
139
155
  private _rendererPromise: Promise<RendererTransport> | null = null;
140
156
  private _rawEventForwarder: RawEventForwarder | null = null;
141
- private _generation = 0;
142
- private _renderBlitMode: "direct" | "blit" = RENDER_BLIT_MODE;
143
157
  private _resetClickAbort: AbortController | null = null;
144
158
 
145
159
  /**
@@ -257,13 +271,40 @@ export class HTMLPerspectiveViewerWebGLPluginElement
257
271
  return this._rendererPromise;
258
272
  }
259
273
 
260
- this._rendererPromise = this._buildRenderer(view).then((r) => {
261
- this._renderer = r;
262
- this._setupInteraction(r);
263
- return r;
264
- });
274
+ // `_buildRenderer` is async — it awaits `getClient`, `getTable`,
275
+ // and a full worker handshake — so a `disconnectedCallback`
276
+ // (toggle / chart-type switch) frequently lands *before* this
277
+ // `.then` runs. At that point `this._renderer` is still null,
278
+ // so `delete()`'s `if (this._renderer)` teardown is skipped and
279
+ // the freshly-built transport (and its WebGL context) would be
280
+ // assigned to a detached element and leak — one context per
281
+ // raced toggle, until the browser evicts the oldest.
282
+ //
283
+ // `delete()` sets `_rendererPromise = null`, so promise identity
284
+ // is the dispose signal: if `this._rendererPromise` no longer
285
+ // points at *this* build when it resolves, the element was
286
+ // deleted (or a reconnect started a newer build) and we destroy
287
+ // the orphan instead of adopting it. Promise identity is the
288
+ // right token: a rapid draw→update must NOT tear down the
289
+ // single in-flight build it shares via this memoized promise.
290
+ const p: Promise<RendererTransport> = this._buildRenderer(view).then(
291
+ (r) => {
292
+ if (this._rendererPromise !== p) {
293
+ r.destroy();
294
+ throw new Error("renderer disposed during init");
295
+ }
296
+
297
+ this._renderer = r;
298
+ this._setupInteraction(r);
299
+ return r;
300
+ },
301
+ );
265
302
 
266
- return this._rendererPromise;
303
+ // Swallow the dispose rejection so it doesn't surface as an
304
+ // unhandled rejection; `_drawImpl` catches it and bails.
305
+ p.catch(() => {});
306
+ this._rendererPromise = p;
307
+ return p;
267
308
  }
268
309
 
269
310
  /**
@@ -310,11 +351,16 @@ export class HTMLPerspectiveViewerWebGLPluginElement
310
351
 
311
352
  private async _buildRenderer(view: View): Promise<RendererTransport> {
312
353
  const viewer = this.parentElement as HTMLPerspectiveViewerElement;
313
- const client = await viewer.getClient();
354
+ // This chart's own panel (its `slot`). Used to scope the client/table
355
+ // to THIS panel rather than the host's active/seed panel (the bare
356
+ // getClient()/getTable() resolve element-wide in a multi-panel viewer),
357
+ // and to tag the dispatched interaction events with their source panel.
358
+ const panel = this.getAttribute("slot") ?? undefined;
359
+ const client = await viewer.getClient({ panel });
314
360
  const viewer_class = customElements.get("perspective-viewer");
315
361
  const clientWasm = viewer_class.get_wasm_module();
316
362
  const clientWorkerURL = viewer_class.get_worker_url();
317
- const table = await viewer?.getTable?.();
363
+ const table = await viewer?.getTable?.({ panel });
318
364
  const tableName: string | undefined = table
319
365
  ? await table.get_name()
320
366
  : undefined;
@@ -327,6 +373,7 @@ export class HTMLPerspectiveViewerWebGLPluginElement
327
373
  client,
328
374
  view,
329
375
  tableName,
376
+ panel,
330
377
  clientWorkerURL,
331
378
  clientWasm,
332
379
  chartTag: this._chartType.tag,
@@ -350,15 +397,28 @@ export class HTMLPerspectiveViewerWebGLPluginElement
350
397
  },
351
398
  pluginConfig: this._pluginConfig,
352
399
  defaultChartType: this._chartType.default_chart_type,
353
- renderBlitMode: this._renderBlitMode,
400
+ renderBlitMode: BLIT_MODE,
354
401
  });
355
402
 
356
403
  return transport;
357
404
  }
358
405
 
359
- setBlitMode(mode: "direct" | "blit") {
360
- console.assert(this._initialized, "Already initialized");
361
- this._renderBlitMode = mode;
406
+ /**
407
+ * Select the GL presentation strategy for *all* chart-type plugins
408
+ * in this renderer. Static + process-global: the value is shared by
409
+ * every per-chart-type subclass, so it must be set once before the
410
+ * charts that should use it build their renderers (a renderer reads
411
+ * {@link BLIT_MODE} at construction in `_buildRenderer`; charts
412
+ * already built keep their mode until torn down and rebuilt).
413
+ *
414
+ * - `"direct"` — each chart owns a GL context 1:1 with its visible
415
+ * canvas (lowest latency; bounded by the browser's ~16-context
416
+ * cap).
417
+ * - `"blit"` — charts render off-screen and share a pool of GL
418
+ * contexts ([webgl/context-pool.ts]), so a page can exceed the cap.
419
+ */
420
+ static setBlitMode(mode: "direct" | "blit") {
421
+ BLIT_MODE = mode;
362
422
  }
363
423
 
364
424
  get_static_config(): PluginStaticConfig {
@@ -497,22 +557,36 @@ export class HTMLPerspectiveViewerWebGLPluginElement
497
557
  return this._drawImpl(view);
498
558
  }
499
559
 
560
+ /**
561
+ * Shared body of `draw` / `update`. No re-entrancy guard: the host
562
+ * serializes every rendering call (`draw`, `update`, `render`,
563
+ * `resize`) and `delete` on its per-renderer draw lock, so this
564
+ * method never overlaps itself or a teardown. The one exception —
565
+ * an external DOM disconnect `delete()`ing the element mid-draw —
566
+ * is absorbed by `RendererTransport`, whose post-`destroy()`
567
+ * requests settle immediately instead of pending forever.
568
+ */
500
569
  private async _drawImpl(view: View): Promise<void> {
501
- const gen = ++this._generation;
502
- const renderer = await this._ensureRenderer(view);
503
- if (this._generation !== gen) {
570
+ let renderer: RendererTransport;
571
+ try {
572
+ renderer = await this._ensureRenderer(view);
573
+ } catch {
574
+ // Renderer was disposed mid-init (element disconnected
575
+ // during the async build) — nothing to draw.
504
576
  return;
505
577
  }
506
578
 
507
579
  renderer.setView(view);
508
580
  renderer.setBufferMaxCapacity(this._chartType.max_cells);
581
+ const panel = this.getAttribute("slot") ?? undefined;
509
582
  const viewer = this
510
583
  .parentElement as HTMLPerspectiveViewerElement | null;
511
- const viewerConfig = (await viewer?.getViewConfig?.()) ?? {};
512
- if (this._generation !== gen) {
584
+
585
+ if (viewer === null) {
513
586
  return;
514
587
  }
515
588
 
589
+ const viewerConfig = await viewer.getViewConfig({ panel });
516
590
  await renderer.loadAndRender({
517
591
  viewerConfig: {
518
592
  group_by: viewerConfig?.group_by ?? [],
@@ -524,17 +598,78 @@ export class HTMLPerspectiveViewerWebGLPluginElement
524
598
  }
525
599
 
526
600
  async clear(): Promise<void> {
527
- this._generation++;
528
601
  this._renderer?.clear();
529
602
  }
530
603
 
531
604
  async resize(): Promise<void> {
532
- this._renderer?.resize();
605
+ // Hidden (an unslotted tab-stack panel, a `display: none` host):
606
+ // the 0×0 rect a hidden element reports would resize the worker
607
+ // canvas to zero — CLEARING the retained frame, so the next
608
+ // activation repaints from blank (the two-stage tab-switch
609
+ // artifact). Skip instead and keep the last frame for an instant,
610
+ // single-blit reveal — mirroring the datagrid's guard. The host's
611
+ // activation nudge is also a `resize()`, so a hidden panel's nudge
612
+ // is free by the same check.
613
+ if (!this.isConnected || this.offsetParent == null) {
614
+ return;
615
+ }
616
+
617
+ // AWAITED to the resized frame's PRESENT (the worker's
618
+ // `resizeAck`) — the host's presize protocol style-overrides
619
+ // this element to its target box and holds the layout commit on
620
+ // this promise, so resolving at message-post would commit the
621
+ // settings-pane layout against the old-dimensions bitmap (the
622
+ // aspect-ratio warp the datagrid's synchronous resize never
623
+ // shows).
624
+ await this._renderer?.resize();
625
+ }
626
+
627
+ /**
628
+ * OPTIONAL host presize protocol: render at the TARGET element box
629
+ * `(width, height)` — the box the host's pending layout commit will
630
+ * produce — holding the resulting frame offscreen, so nothing on
631
+ * screen changes during the round-trip. Resolves, once the resized
632
+ * frame is staged, to a present closure; the host calls it in the
633
+ * same task as the layout commit, landing geometry and pixels in
634
+ * one paint. Plugins without this method get the host's held
635
+ * style-override presize path instead.
636
+ */
637
+ async presize(width: number, height: number): Promise<(() => void) | void> {
638
+ if (
639
+ !this.isConnected ||
640
+ this.offsetParent == null ||
641
+ !this._renderer ||
642
+ !this._glCanvas
643
+ ) {
644
+ return;
645
+ }
646
+
647
+ // Target GL-canvas box = its current box shifted by the element's
648
+ // box delta — the chrome between the element edge and the canvas
649
+ // is constant across a resize.
650
+ const hostRect = this.getBoundingClientRect();
651
+ const glRect = this._glCanvas.getBoundingClientRect();
652
+ return await this._renderer.presize(
653
+ Math.max(0, glRect.width + (width - hostRect.width)),
654
+ Math.max(0, glRect.height + (height - hostRect.height)),
655
+ );
533
656
  }
534
657
 
535
658
  restyle() {
536
659
  this._renderer?.invalidateTheme();
537
- return 5;
660
+ }
661
+
662
+ /**
663
+ * Clear any active selection state (pinned tooltip) WITHOUT emitting
664
+ * selection events — the host calls this when a global filter
665
+ * contributed by this panel's selection is removed from the global
666
+ * filter bar, so the pin can't outlive the filter it produced. The
667
+ * transport's `_lastInsertConfig` (remove-set memory) is deliberately
668
+ * RETAINED so a subsequent selection still replaces any leftover
669
+ * clauses rather than accumulating.
670
+ */
671
+ async deselect(): Promise<void> {
672
+ this._renderer?.deselect();
538
673
  }
539
674
 
540
675
  async render(view: View): Promise<Blob> {
@@ -563,7 +698,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement
563
698
  }
564
699
 
565
700
  delete() {
566
- this._generation++;
567
701
  if (this._rawEventForwarder) {
568
702
  this._rawEventForwarder.detach();
569
703
  this._rawEventForwarder = null;
@@ -16,8 +16,8 @@ import type { WebGLContextManager } from "../webgl/context-manager";
16
16
  * Module-level render scheduler. The single entry point for driving a
17
17
  * chart frame. Every render-triggering caller — upload chunks, zoom /
18
18
  * pan, resize, theme invalidation, host-driven redraws — calls
19
- * `requestRender(glManager, fullRender)` and awaits the returned
20
- * promise.
19
+ * `requestRender(glManager, fullRender, render2D)` and awaits the
20
+ * returned promise.
21
21
  *
22
22
  * ## Guarantees
23
23
  *
@@ -51,9 +51,12 @@ import type { WebGLContextManager } from "../webgl/context-manager";
51
51
  * begins, letting per-context GPU work overlap.
52
52
  *
53
53
  * - **Phase 2 (parallel):** `Promise.all(snapshot.map(present))`
54
- * where `present` does `await awaitGpuFence(); endFrame();
55
- * resolve waiters`. Each entry's waiters resolve as soon as its
56
- * own present completes — independent of other entries.
54
+ * where `present` does `await awaitGpuFence(); render2D();
55
+ * endFrame(); resolve waiters`. The `render2D()` step draws the 2D
56
+ * layers (gridlines + chrome) only after the GPU fence, so the 2D
57
+ * placeholder canvases don't present a frame ahead of the GL frame
58
+ * on resize. Each entry's waiters resolve as soon as its own
59
+ * present completes — independent of other entries.
57
60
  *
58
61
  * ## Failure modes
59
62
  *
@@ -78,6 +81,15 @@ import type { WebGLContextManager } from "../webgl/context-manager";
78
81
  interface Entry {
79
82
  glManager: WebGLContextManager;
80
83
  fullRender: () => void;
84
+ /**
85
+ * The chart's 2D-canvas draws (gridlines + chrome), split out of
86
+ * `fullRender` so they run *after* `awaitGpuFence` — see
87
+ * {@link present}. Deferring the 2D work past the GPU fence keeps
88
+ * the 2D placeholder canvases from pushing a resized frame to the
89
+ * compositor a frame ahead of the GL present (the GL/2D "distortion"
90
+ * on resize). Populated as captured closures during `fullRender`.
91
+ */
92
+ render2D: () => void;
81
93
  waiters: PromiseWithResolvers<void>[];
82
94
  }
83
95
 
@@ -105,25 +117,30 @@ const inFlight = new Set<WebGLContextManager>();
105
117
  const deferred = new Map<WebGLContextManager, (() => void)[]>();
106
118
 
107
119
  /**
108
- * Request a coalesced render of `glManager` whose body is
109
- * `fullRender`. Returns a promise that resolves when this entry's
110
- * Phase 2 (`awaitGpuFence` + `endFrame`) completes.
120
+ * Request a coalesced render of `glManager`. `fullRender` submits the
121
+ * GL commands (Phase 1, synchronous); `render2D` draws the 2D layers
122
+ * (gridlines + chrome) and runs in Phase 2 *after* `awaitGpuFence`, so
123
+ * the 2D canvases don't present ahead of the GL frame. Returns a
124
+ * promise that resolves when this entry's Phase 2 (`awaitGpuFence` +
125
+ * `render2D` + `endFrame`) completes.
111
126
  *
112
127
  * If a request is already pending for the same glManager, the new
113
- * call's `fullRender` closure replaces the prior one (latest call
114
- * wins; closures read chart state lazily so this is functionally a
115
- * no-op, but keeps the closure fresh) and the returned promise
116
- * resolves alongside the existing waiters.
128
+ * call's `fullRender` / `render2D` closures replace the prior ones
129
+ * (latest call wins; closures read chart state lazily so this is
130
+ * functionally a no-op, but keeps the closures fresh) and the returned
131
+ * promise resolves alongside the existing waiters.
117
132
  */
118
133
  export function requestRender(
119
134
  glManager: WebGLContextManager,
120
135
  fullRender: () => void,
136
+ render2D: () => void,
121
137
  ): Promise<void> {
122
138
  let entry = pending.get(glManager);
123
139
  if (entry) {
124
140
  entry.fullRender = fullRender;
141
+ entry.render2D = render2D;
125
142
  } else {
126
- entry = { glManager, fullRender, waiters: [] };
143
+ entry = { glManager, fullRender, render2D, waiters: [] };
127
144
  pending.set(glManager, entry);
128
145
  }
129
146
 
@@ -177,6 +194,41 @@ export function deferIfDraining(
177
194
  ops.push(op);
178
195
  }
179
196
 
197
+ /**
198
+ * Drop every scheduler reference to `glManager` — called from
199
+ * `WebGLContextManager`'s owning renderer at teardown, *before*
200
+ * `glManager.destroy()` loses the GL context.
201
+ *
202
+ * Without this, a frame queued for the next RAF (an `Entry` in
203
+ * `pending`) or a `deferIfDraining` op still parked in `deferred`
204
+ * would survive the destroy and, on the next `drain()`, drive
205
+ * `fullRender` / `present` against a context-lost manager. In blit
206
+ * mode that surfaces as `endFrame`'s `transferToImageBitmap` throwing
207
+ * "Cannot transfer to ImageBitmap because WebGL context is lost" —
208
+ * logged as "scheduler: present failed". In direct mode it's a wasted
209
+ * paint against a dead context.
210
+ *
211
+ * Outstanding waiters for a destroyed manager are resolved (not
212
+ * rejected): the caller asked to tear the chart down, so its awaited
213
+ * `draw()` should observe a clean no-op rather than an error it would
214
+ * have to suppress. An in-flight `present()` (manager in `inFlight`)
215
+ * is left to unwind on its own — its `finally` clause will not find a
216
+ * `deferred` entry to flush, and the manager is already gone from
217
+ * `pending`, so it cannot re-enqueue.
218
+ */
219
+ export function unregister(glManager: WebGLContextManager): void {
220
+ const entry = pending.get(glManager);
221
+ if (entry) {
222
+ pending.delete(glManager);
223
+ for (const w of entry.waiters) {
224
+ w.resolve();
225
+ }
226
+ }
227
+
228
+ deferred.delete(glManager);
229
+ inFlight.delete(glManager);
230
+ }
231
+
180
232
  /**
181
233
  * Test-only: clear pending state. Production callers must not use
182
234
  * this — outstanding waiters are silently dropped.
@@ -230,31 +282,39 @@ export function _resetForTest(): void {
230
282
  async function drain(): Promise<void> {
231
283
  const snapshot = Array.from(pending.values());
232
284
  pending.clear();
233
- const ready: Entry[] = [];
285
+
286
+ // Group ready entries by the GL context behind each manager. Under
287
+ // pooled blit mode many managers share one context (one drawing
288
+ // buffer): their renders MUST serialize — interleaving two charts'
289
+ // paints into one canvas would corrupt the bitmap the first ships.
290
+ // In the default 1:1 mode every manager has its own backend, so
291
+ // every group has exactly one entry and all groups run in parallel,
292
+ // exactly as before pooling.
293
+ const groups = new Map<number, Entry[]>();
234
294
  for (const entry of snapshot) {
235
- try {
236
- // Apply any dimension change recorded by
237
- // `glManager.requestResize` *before* the paint, in the
238
- // same un-yielded synchronous Phase 1 loop. This pairs
239
- // the canvas-clearing `canvas.width = N` assignment
240
- // with the immediately-following `_fullRender`, so the
241
- // browser's compositor only ever observes the canvas
242
- // post-paint. In direct/in-process modes the visible
243
- // canvas IS the GL canvas, and a clear-without-matching-
244
- // paint in the previous task would otherwise present an
245
- // empty frame to the user.
246
- entry.glManager.applyPendingResize();
247
- entry.fullRender();
248
- ready.push(entry);
249
- } catch (err) {
250
- console.error("scheduler: fullRender threw", err);
295
+ // The browser force-loses the oldest context when a page
296
+ // exceeds its per-agent WebGL context cap (~16). A frame queued
297
+ // before that eviction would paint + present against a dead
298
+ // context; skip it and settle its waiters cleanly rather than
299
+ // letting `endFrame`'s `transferToImageBitmap` throw.
300
+ if (entry.glManager.isContextLost()) {
251
301
  for (const w of entry.waiters) {
252
- w.reject(err);
302
+ w.resolve();
253
303
  }
304
+
305
+ continue;
306
+ }
307
+
308
+ const key = entry.glManager.backendId;
309
+ const group = groups.get(key);
310
+ if (group) {
311
+ group.push(entry);
312
+ } else {
313
+ groups.set(key, [entry]);
254
314
  }
255
315
  }
256
316
 
257
- await Promise.all(ready.map(present));
317
+ await Promise.all(Array.from(groups.values()).map(presentGroup));
258
318
 
259
319
  // Now (and only now) clear rafId. If new requests landed during
260
320
  // this drain, schedule the next RAF.
@@ -264,18 +324,62 @@ async function drain(): Promise<void> {
264
324
  }
265
325
  }
266
326
 
327
+ /**
328
+ * Render every entry sharing one GL context, sequentially: each chart
329
+ * gets the shared drawing buffer to itself for a full
330
+ * `beginFrame` → `fullRender` → `awaitGpuFence` → `endFrame` (present)
331
+ * cycle before the next chart touches it. Groups for *different*
332
+ * contexts run concurrently (via `Promise.all` in `drain`), so K pooled
333
+ * contexts give K-way parallelism and the 1:1 default keeps full
334
+ * cross-chart overlap.
335
+ *
336
+ * The synchronous prefix of each group (`beginFrame` + `fullRender`)
337
+ * runs before its first `awaitGpuFence` yields, so when groups run
338
+ * concurrently all first-chart draws are still submitted before any
339
+ * fence wait — preserving the GPU overlap the old two-phase drain had.
340
+ */
341
+ async function presentGroup(entries: Entry[]): Promise<void> {
342
+ for (const entry of entries) {
343
+ await present(entry);
344
+ }
345
+ }
346
+
267
347
  async function present(entry: Entry): Promise<void> {
268
- // Mark this glManager as in-flight *synchronously*, before the
269
- // first await. `Promise.all(ready.map(present))` calls each
270
- // `present` synchronously to collect its returned promise, so
271
- // every entry's glManager is registered in `inFlight` before
272
- // any fence-wait yields and before any sibling message handler
273
- // can run. Mutations posted by sibling handlers (resize, clear)
274
- // route through `deferIfDraining` and queue into `deferred`
275
- // until the `finally` block flushes them.
348
+ // Mark this glManager as in-flight *synchronously*, before the first
349
+ // await, so sibling message handlers' canvas mutations (resize,
350
+ // clear) route through `deferIfDraining` into `deferred` until the
351
+ // `finally` flushes them.
276
352
  inFlight.add(entry.glManager);
353
+ try {
354
+ // Apply the dimension change and (in pooled mode) reset shared
355
+ // GL state in the same un-yielded step as the paint that fills
356
+ // the buffer. Pairing the canvas-clearing `canvas.width = N`
357
+ // with `_fullRender` keeps the compositor from ever observing a
358
+ // cleared-but-unpainted canvas (visible flicker in direct/in-
359
+ // process modes, where the visible canvas IS the GL canvas).
360
+ entry.glManager.beginFrame();
361
+ entry.fullRender();
362
+ } catch (err) {
363
+ console.error("scheduler: fullRender threw", err);
364
+ for (const w of entry.waiters) {
365
+ w.reject(err);
366
+ }
367
+
368
+ flushDeferred(entry.glManager);
369
+ return;
370
+ }
371
+
277
372
  try {
278
373
  await entry.glManager.awaitGpuFence();
374
+ // Draw the 2D layers (gridlines + chrome) only now that the GL
375
+ // frame's GPU work has completed. Issuing the 2D-canvas commands
376
+ // here — rather than inline in `fullRender` (Phase 1) — keeps the
377
+ // 2D placeholder canvases from pushing their resized frame to the
378
+ // compositor during the fence-wait yields, a frame ahead of the
379
+ // GL present. That early push is the visible GL/2D misalignment
380
+ // on resize. Failures fall through to the same present-failed
381
+ // path as the fence.
382
+ entry.render2D();
279
383
  entry.glManager.endFrame();
280
384
  for (const w of entry.waiters) {
281
385
  w.resolve();
@@ -296,21 +400,26 @@ async function present(entry: Entry): Promise<void> {
296
400
  w.reject(err);
297
401
  }
298
402
  } finally {
299
- // Bitmap shipped (or error reported). Re-open the canvas to
300
- // mutations and flush any deferred ops in arrival order.
301
- // Deferred ops may call `requestRender`; the resulting
302
- // entry queues into `pending` and the drain's tail check
303
- // picks it up for the next RAF.
304
- inFlight.delete(entry.glManager);
305
- const ops = deferred.get(entry.glManager);
306
- if (ops) {
307
- deferred.delete(entry.glManager);
308
- for (const op of ops) {
309
- try {
310
- op();
311
- } catch (err) {
312
- console.error("scheduler: deferred op threw", err);
313
- }
403
+ flushDeferred(entry.glManager);
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Re-open a glManager's canvas to mutations and flush any ops deferred
409
+ * during its present, in arrival order. Deferred ops may call
410
+ * `requestRender`; the resulting entry queues into `pending` and the
411
+ * drain's tail check picks it up for the next RAF.
412
+ */
413
+ function flushDeferred(glManager: WebGLContextManager): void {
414
+ inFlight.delete(glManager);
415
+ const ops = deferred.get(glManager);
416
+ if (ops) {
417
+ deferred.delete(glManager);
418
+ for (const op of ops) {
419
+ try {
420
+ op();
421
+ } catch (err) {
422
+ console.error("scheduler: deferred op threw", err);
314
423
  }
315
424
  }
316
425
  }
@@ -31,6 +31,7 @@ export type ControlMsg =
31
31
  | SetBufferMaxCapacityMsg
32
32
  | LoadAndRenderMsg
33
33
  | RedrawMsg
34
+ | DeselectMsg
34
35
  | ResizeMsg
35
36
  | ClearMsg
36
37
  | InvalidateThemeMsg
@@ -53,6 +54,7 @@ export type WorkerMsg =
53
54
  | UserClickMsg
54
55
  | UserSelectMsg
55
56
  | LoadAndRenderAckMsg
57
+ | ResizeAckMsg
56
58
  | FrameBitmapMsg
57
59
  | ErrorMsg;
58
60
 
@@ -300,13 +302,44 @@ export interface RedrawMsg {
300
302
  kind: "redraw";
301
303
  }
302
304
 
305
+ /**
306
+ * Host → worker: silently clear the chart's selection state (pinned
307
+ * tooltip) — no `userSelect` reply, so the host filter-bar removal that
308
+ * triggered it can't echo back into the host's filter set. The worker's
309
+ * `TooltipController.dismiss()` posts `dismissTooltip` to tear down the
310
+ * host-side pinned artifact.
311
+ */
312
+ export interface DeselectMsg {
313
+ kind: "deselect";
314
+ }
315
+
316
+ /**
317
+ * Host → worker: resize the chart to a new CSS box. Replied with
318
+ * {@link ResizeAckMsg} AFTER the resized frame PRESENTS — the host's
319
+ * `plugin.resize()` promise is the viewer's presize contract ("the
320
+ * plugin has repainted at the target box when this resolves"), so an
321
+ * ack at message-receipt would let the settings-pane layout commit
322
+ * against the old-dimensions bitmap (the aspect-ratio warp).
323
+ */
303
324
  export interface ResizeMsg {
304
325
  kind: "resize";
326
+ msgId: number;
305
327
  cssWidth: number;
306
328
  cssHeight: number;
307
329
  dpr: number;
308
330
  }
309
331
 
332
+ /**
333
+ * Worker → host reply to a `ResizeMsg`, posted after the resized
334
+ * frame's present completes. Always sent — present failures and
335
+ * torn-down charts included — so the host's awaited promise resolves
336
+ * deterministically.
337
+ */
338
+ export interface ResizeAckMsg {
339
+ kind: "resizeAck";
340
+ msgId: number;
341
+ }
342
+
310
343
  export interface ClearMsg {
311
344
  kind: "clear";
312
345
  }