@perspective-dev/viewer-charts 4.5.2 → 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 (61) 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/data/view-reader.d.ts +10 -1
  15. package/dist/esm/event-detail.d.ts +7 -0
  16. package/dist/esm/layout/ticks.d.ts +5 -0
  17. package/dist/esm/perspective-viewer-charts.js +2 -2
  18. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  19. package/dist/esm/plugin/charts.d.ts +6 -3
  20. package/dist/esm/plugin/plugin.d.ts +31 -2
  21. package/dist/esm/render/scheduler.d.ts +11 -8
  22. package/dist/esm/transport/protocol.d.ts +31 -2
  23. package/dist/esm/transport/renderer-transport.d.ts +74 -3
  24. package/dist/esm/webgl/instanced-attrs.d.ts +7 -1
  25. package/dist/esm/worker/renderer.worker.d.ts +12 -0
  26. package/package.json +1 -1
  27. package/src/ts/charts/candlestick/candlestick-render.ts +4 -1
  28. package/src/ts/charts/candlestick/candlestick.ts +12 -17
  29. package/src/ts/charts/cartesian/cartesian-render.ts +80 -63
  30. package/src/ts/charts/chart-base.ts +85 -5
  31. package/src/ts/charts/chart.ts +47 -9
  32. package/src/ts/charts/common/category-axis-resolver.ts +5 -1
  33. package/src/ts/charts/common/tree-data.ts +61 -16
  34. package/src/ts/charts/heatmap/heatmap-render.ts +6 -2
  35. package/src/ts/charts/series/glyphs/draw-areas.ts +10 -1
  36. package/src/ts/charts/series/glyphs/draw-bars.ts +30 -9
  37. package/src/ts/charts/series/glyphs/draw-lines.ts +10 -1
  38. package/src/ts/charts/series/glyphs/draw-scatter.ts +56 -3
  39. package/src/ts/charts/series/series-build.ts +21 -3
  40. package/src/ts/charts/series/series-interact.ts +86 -13
  41. package/src/ts/charts/series/series-render.ts +660 -17
  42. package/src/ts/charts/series/series.ts +96 -26
  43. package/src/ts/charts/sunburst/sunburst-interact.ts +2 -3
  44. package/src/ts/charts/sunburst/sunburst-render.ts +3 -1
  45. package/src/ts/charts/treemap/treemap-interact.ts +2 -5
  46. package/src/ts/charts/treemap/treemap-render.ts +3 -1
  47. package/src/ts/charts/treemap/treemap.ts +6 -1
  48. package/src/ts/data/view-reader.ts +22 -2
  49. package/src/ts/event-detail.ts +8 -0
  50. package/src/ts/index.ts +13 -7
  51. package/src/ts/interaction/raw-event-forwarder.ts +7 -0
  52. package/src/ts/interaction/zoom-controller.ts +8 -0
  53. package/src/ts/layout/ticks.ts +9 -0
  54. package/src/ts/plugin/charts.ts +20 -4
  55. package/src/ts/plugin/plugin.ts +86 -16
  56. package/src/ts/render/scheduler.ts +39 -13
  57. package/src/ts/transport/protocol.ts +33 -0
  58. package/src/ts/transport/renderer-transport.ts +185 -9
  59. package/src/ts/webgl/instanced-attrs.ts +15 -1
  60. package/src/ts/worker/dispatch.ts +4 -1
  61. package/src/ts/worker/renderer.worker.ts +32 -3
@@ -154,7 +154,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement
154
154
  private _renderer: RendererTransport | null = null;
155
155
  private _rendererPromise: Promise<RendererTransport> | null = null;
156
156
  private _rawEventForwarder: RawEventForwarder | null = null;
157
- private _generation = 0;
158
157
  private _resetClickAbort: AbortController | null = null;
159
158
 
160
159
  /**
@@ -285,8 +284,7 @@ export class HTMLPerspectiveViewerWebGLPluginElement
285
284
  // is the dispose signal: if `this._rendererPromise` no longer
286
285
  // points at *this* build when it resolves, the element was
287
286
  // 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
287
+ // the orphan instead of adopting it. Promise identity is the
290
288
  // right token: a rapid draw→update must NOT tear down the
291
289
  // single in-flight build it shares via this memoized promise.
292
290
  const p: Promise<RendererTransport> = this._buildRenderer(view).then(
@@ -353,11 +351,16 @@ export class HTMLPerspectiveViewerWebGLPluginElement
353
351
 
354
352
  private async _buildRenderer(view: View): Promise<RendererTransport> {
355
353
  const viewer = this.parentElement as HTMLPerspectiveViewerElement;
356
- 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 });
357
360
  const viewer_class = customElements.get("perspective-viewer");
358
361
  const clientWasm = viewer_class.get_wasm_module();
359
362
  const clientWorkerURL = viewer_class.get_worker_url();
360
- const table = await viewer?.getTable?.();
363
+ const table = await viewer?.getTable?.({ panel });
361
364
  const tableName: string | undefined = table
362
365
  ? await table.get_name()
363
366
  : undefined;
@@ -370,6 +373,7 @@ export class HTMLPerspectiveViewerWebGLPluginElement
370
373
  client,
371
374
  view,
372
375
  tableName,
376
+ panel,
373
377
  clientWorkerURL,
374
378
  clientWasm,
375
379
  chartTag: this._chartType.tag,
@@ -553,8 +557,16 @@ export class HTMLPerspectiveViewerWebGLPluginElement
553
557
  return this._drawImpl(view);
554
558
  }
555
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
+ */
556
569
  private async _drawImpl(view: View): Promise<void> {
557
- const gen = ++this._generation;
558
570
  let renderer: RendererTransport;
559
571
  try {
560
572
  renderer = await this._ensureRenderer(view);
@@ -564,19 +576,17 @@ export class HTMLPerspectiveViewerWebGLPluginElement
564
576
  return;
565
577
  }
566
578
 
567
- if (this._generation !== gen) {
568
- return;
569
- }
570
-
571
579
  renderer.setView(view);
572
580
  renderer.setBufferMaxCapacity(this._chartType.max_cells);
581
+ const panel = this.getAttribute("slot") ?? undefined;
573
582
  const viewer = this
574
583
  .parentElement as HTMLPerspectiveViewerElement | null;
575
- const viewerConfig = (await viewer?.getViewConfig?.()) ?? {};
576
- if (this._generation !== gen) {
584
+
585
+ if (viewer === null) {
577
586
  return;
578
587
  }
579
588
 
589
+ const viewerConfig = await viewer.getViewConfig({ panel });
580
590
  await renderer.loadAndRender({
581
591
  viewerConfig: {
582
592
  group_by: viewerConfig?.group_by ?? [],
@@ -588,17 +598,78 @@ export class HTMLPerspectiveViewerWebGLPluginElement
588
598
  }
589
599
 
590
600
  async clear(): Promise<void> {
591
- this._generation++;
592
601
  this._renderer?.clear();
593
602
  }
594
603
 
595
604
  async resize(): Promise<void> {
596
- 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
+ );
597
656
  }
598
657
 
599
658
  restyle() {
600
659
  this._renderer?.invalidateTheme();
601
- 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();
602
673
  }
603
674
 
604
675
  async render(view: View): Promise<Blob> {
@@ -627,7 +698,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement
627
698
  }
628
699
 
629
700
  delete() {
630
- this._generation++;
631
701
  if (this._rawEventForwarder) {
632
702
  this._rawEventForwarder.detach();
633
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
 
@@ -354,6 +371,15 @@ async function present(entry: Entry): Promise<void> {
354
371
 
355
372
  try {
356
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();
357
383
  entry.glManager.endFrame();
358
384
  for (const w of entry.waiters) {
359
385
  w.resolve();
@@ -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
  }
@@ -84,7 +84,11 @@ interface RendererHandle {
84
84
  terminate(): void;
85
85
  }
86
86
 
87
- type PendingRenderType = "saveZoom" | "loadAndRender" | "snapshotPng";
87
+ type PendingRenderType =
88
+ | "saveZoom"
89
+ | "loadAndRender"
90
+ | "snapshotPng"
91
+ | "resize";
88
92
  interface PendingRenderRequest {
89
93
  kind: PendingRenderType;
90
94
  resolve: (v: any) => void;
@@ -110,11 +114,22 @@ interface PendingRenderRequest {
110
114
  */
111
115
  export class RendererTransport {
112
116
  private _handle: RendererHandle | null = null;
117
+
118
+ /**
119
+ * Set by {@link destroy}. Requests allocated after teardown settle
120
+ * immediately in `_allocPending` — `_post` is already a no-op once
121
+ * `_handle` is null, so a late request would otherwise never
122
+ * receive its reply and pend forever.
123
+ */
124
+ private _destroyed = false;
113
125
  private _proxyChannel: MessageChannel | null = null;
114
126
  private _proxySession: any = null;
115
127
  private _client: Client;
116
128
  private _view: View;
117
129
  private _tableName: string | undefined;
130
+ /** Source panel `slot`, tagged onto dispatched interaction events so the
131
+ * host can route them (master/detail, global filter) to the right panel. */
132
+ private _panel: string | undefined;
118
133
  private _clientWorkerURL: URL;
119
134
  private _clientWasm: WebAssembly.Module;
120
135
  private _chartTag: string;
@@ -158,6 +173,37 @@ export class RendererTransport {
158
173
  */
159
174
  private _displayCtx: CanvasRenderingContext2D | null = null;
160
175
 
176
+ /**
177
+ * Present-hold state for the staged-present presize protocol
178
+ * ({@link presize}). While holding, inbound `frameBitmap`s are
179
+ * STAGED (latest wins) instead of blitted, so the on-screen frame
180
+ * — old dimensions in the old, unchanged box — stays put until the
181
+ * host invokes the present closure `presize` resolved to, in the
182
+ * same task as its layout commit. Entered only by `presize`;
183
+ * exited by the present closure, by `presize`'s rejection path, or
184
+ * by `destroy` — every settle path releases the hold or hands the
185
+ * caller the release.
186
+ */
187
+ /**
188
+ * The last `(cssWidth, cssHeight, dpr)` posted to the worker — at init
189
+ * or any resize — i.e. the dimensions the worker has already rendered
190
+ * (or has an in-flight render for). {@link resize} short-circuits when
191
+ * its measurement matches (±0.5px, the presize protocol's own
192
+ * threshold): reactive resizes fan in from several host paths per
193
+ * layout transition (the post-commit fan-out, activation nudges, the
194
+ * fresh-restore tail), and every worker resize is an unconditional
195
+ * full re-render.
196
+ */
197
+ private _lastPostedSize: {
198
+ cssWidth: number;
199
+ cssHeight: number;
200
+ dpr: number;
201
+ } | null = null;
202
+
203
+ private _holdPresent = false;
204
+
205
+ private _stagedFrame: ImageBitmap | null = null;
206
+
161
207
  /**
162
208
  * Host-side sink for tooltip + cursor side-effects. The chart
163
209
  * inside the renderer calls into a `MessageHostSink` that posts
@@ -182,6 +228,7 @@ export class RendererTransport {
182
228
  client: Client;
183
229
  view: View;
184
230
  tableName?: string;
231
+ panel?: string;
185
232
  clientWasm: WebAssembly.Module;
186
233
  clientWorkerURL: URL;
187
234
  chartTag: string;
@@ -192,6 +239,7 @@ export class RendererTransport {
192
239
  this._client = opts.client;
193
240
  this._view = opts.view;
194
241
  this._tableName = opts.tableName;
242
+ this._panel = opts.panel;
195
243
  this._clientWorkerURL = opts.clientWorkerURL;
196
244
  this._clientWasm = opts.clientWasm;
197
245
  this._chartTag = opts.chartTag;
@@ -301,6 +349,11 @@ export class RendererTransport {
301
349
  precompileShaders: this._precompileShaders,
302
350
  };
303
351
 
352
+ this._lastPostedSize = {
353
+ cssWidth: rect.width,
354
+ cssHeight: rect.height,
355
+ dpr,
356
+ };
304
357
  this._handle = await this._createHandle(workerURL, initMsg);
305
358
  this._handle.addMessageListener((msg) =>
306
359
  this._handleRendererMsg(msg as WorkerMsg),
@@ -438,7 +491,8 @@ export class RendererTransport {
438
491
  * `loadAndRenderAck`. Per the worker's "resolve on stale"
439
492
  * contract, a mid-flight cancellation (a newer `loadAndRender`
440
493
  * superseding this one) still acks — the host's awaiter just
441
- * resolves quietly.
494
+ * resolves quietly. Calls issued after `destroy()` resolve
495
+ * immediately (see `_allocPending`).
442
496
  */
443
497
  loadAndRender(opts: {
444
498
  viewerConfig: {
@@ -464,19 +518,96 @@ export class RendererTransport {
464
518
  this._post({ kind: "redraw" });
465
519
  }
466
520
 
467
- resize(): void {
521
+ /**
522
+ * Silently clear the worker chart's selection state (pinned tooltip) —
523
+ * no selection events are emitted; the worker echoes `dismissTooltip`
524
+ * to drop the host-side pinned artifact. See `DeselectMsg`.
525
+ */
526
+ deselect(): void {
527
+ this._post({ kind: "deselect" });
528
+ }
529
+
530
+ /**
531
+ * Resize the chart to the GL canvas's CURRENT CSS box and resolve
532
+ * once the resized frame has PRESENTED (the worker's `resizeAck`) —
533
+ * not at message-post, which would let callers proceed while the
534
+ * old-dimensions bitmap is still on screen. Reactive geometry
535
+ * changes only; anticipated ones go through {@link presize}.
536
+ *
537
+ * A measurement matching {@link _lastPostedSize} resolves immediately
538
+ * WITHOUT a worker round trip: the worker has already rendered (or is
539
+ * presenting) that exact frame, and re-posting would be a full
540
+ * re-render of an identical bitmap.
541
+ */
542
+ resize(): Promise<void> {
468
543
  if (!this._hostGlCanvas) {
469
- return;
544
+ return Promise.resolve();
470
545
  }
471
546
 
472
547
  const rect = this._hostGlCanvas.getBoundingClientRect();
473
548
  const dpr = window.devicePixelRatio || 1;
549
+ const last = this._lastPostedSize;
550
+ if (
551
+ last &&
552
+ Math.abs(last.cssWidth - rect.width) <= 0.5 &&
553
+ Math.abs(last.cssHeight - rect.height) <= 0.5 &&
554
+ last.dpr === dpr
555
+ ) {
556
+ return Promise.resolve();
557
+ }
558
+
559
+ return this._postResize(rect.width, rect.height);
560
+ }
561
+
562
+ /**
563
+ * Staged-present variant of {@link resize}: render at the given
564
+ * TARGET canvas CSS box (no measurement — the caller supplies the
565
+ * box the pending layout commit will produce), HOLDING the
566
+ * resulting frame (and any others that arrive meanwhile) offscreen
567
+ * instead of blitting, so nothing on screen changes. Resolves,
568
+ * once the resized frame is staged, to a present closure: calling
569
+ * it blits the latest staged frame and exits the hold — the host
570
+ * runs it in the same task as its layout commit so geometry and
571
+ * pixels land in one paint. On rejection (teardown) the hold is
572
+ * released here, so every settle path either frees the display or
573
+ * hands the caller the release.
574
+ */
575
+ presize(cssWidth: number, cssHeight: number): Promise<(() => void) | void> {
576
+ if (!this._hostGlCanvas) {
577
+ return Promise.resolve();
578
+ }
579
+
580
+ this._holdPresent = true;
581
+ return this._postResize(cssWidth, cssHeight).then(
582
+ () => () => this._presentStaged(),
583
+ (err) => {
584
+ this._presentStaged();
585
+ throw err;
586
+ },
587
+ );
588
+ }
589
+
590
+ private _postResize(cssWidth: number, cssHeight: number): Promise<void> {
591
+ const dpr = window.devicePixelRatio || 1;
592
+ const { id, promise } = this._allocPending<void>("resize");
593
+ this._lastPostedSize = { cssWidth, cssHeight, dpr };
474
594
  this._post({
475
595
  kind: "resize",
476
- cssWidth: rect.width,
477
- cssHeight: rect.height,
596
+ msgId: id,
597
+ cssWidth,
598
+ cssHeight,
478
599
  dpr,
479
600
  });
601
+ return promise;
602
+ }
603
+
604
+ private _presentStaged(): void {
605
+ this._holdPresent = false;
606
+ const staged = this._stagedFrame;
607
+ this._stagedFrame = null;
608
+ if (staged) {
609
+ this._drawFrameBitmap(staged);
610
+ }
480
611
  }
481
612
 
482
613
  clear() {
@@ -501,12 +632,39 @@ export class RendererTransport {
501
632
  * Allocate a pending request slot of the given `kind`. Returns the
502
633
  * id (encoded into the outgoing `ControlMsg`) and a promise that
503
634
  * resolves / rejects when the matching reply arrives or
504
- * `destroy()` drains the table.
635
+ * `destroy()` drains the table. Requests issued *after* `destroy()`
636
+ * settle immediately with the same per-kind semantics as the drain.
505
637
  */
506
638
  private _allocPending<T>(kind: PendingRenderType): {
507
639
  id: number;
508
640
  promise: Promise<T>;
509
641
  } {
642
+ // A request issued after `destroy()` would never settle: `_post`
643
+ // no-ops with no handle, so no reply ever lands — and the host
644
+ // awaits `loadAndRender` while holding its per-renderer draw
645
+ // lock, so a pending-forever promise wedges that panel's lock
646
+ // permanently. (Reachable when an external DOM disconnect
647
+ // `delete()`s the element mid-draw; the host's own teardown
648
+ // paths are serialized behind the same lock.) Mirror the
649
+ // `destroy()` drain: `loadAndRender` resolves silently,
650
+ // reply-bearing kinds reject. The pre-attached no-op `catch`
651
+ // marks the rejection handled for fire-and-forget callers
652
+ // (`saveZoom`) without affecting real awaiters.
653
+ if (this._destroyed) {
654
+ if (kind === "loadAndRender") {
655
+ return {
656
+ id: -1,
657
+ promise: Promise.resolve(undefined as unknown as T),
658
+ };
659
+ }
660
+
661
+ const promise = Promise.reject<T>(
662
+ new Error("RendererTransport destroyed"),
663
+ );
664
+ promise.catch(() => {});
665
+ return { id: -1, promise };
666
+ }
667
+
510
668
  const id = ++this._pendingCounter;
511
669
  const promise = new Promise<T>((resolve, reject) => {
512
670
  this._pending.set(id, { kind, resolve, reject });
@@ -548,6 +706,7 @@ export class RendererTransport {
548
706
  }
549
707
 
550
708
  destroy(): void {
709
+ this._destroyed = true;
551
710
  this._post({ kind: "destroy" });
552
711
  if (this._proxySession) {
553
712
  this._proxySession.close().catch(() => {});
@@ -572,6 +731,9 @@ export class RendererTransport {
572
731
  // them, and so the GPU-backed 2D context can release earlier.
573
732
  this._hostGlCanvas = null;
574
733
  this._displayCtx = null;
734
+ this._holdPresent = false;
735
+ this._stagedFrame?.close();
736
+ this._stagedFrame = null;
575
737
 
576
738
  // Drain pending request promises with kind-aware semantics:
577
739
  // - `loadAndRender` resolves silently (the host's awaited draw
@@ -633,7 +795,9 @@ export class RendererTransport {
633
795
  {
634
796
  bubbles: true,
635
797
  composed: true,
636
- detail: msg.detail,
798
+ // Tag with the source panel so a multi-panel host
799
+ // can attribute the click to the right panel.
800
+ detail: { ...msg.detail, panel: this._panel },
637
801
  },
638
802
  ),
639
803
  );
@@ -658,6 +822,9 @@ export class RendererTransport {
658
822
  removeConfigs as any,
659
823
  insertConfigs as any,
660
824
  );
825
+ // Tag with the source panel so the host's master/detail +
826
+ // global-filter routing can identify the originating panel.
827
+ detail.panel = this._panel;
661
828
  this._dispatchOnViewer(
662
829
  new CustomEvent<PerspectiveSelectDetail>(
663
830
  "perspective-global-filter",
@@ -672,7 +839,13 @@ export class RendererTransport {
672
839
  }
673
840
 
674
841
  case "frameBitmap":
675
- this._drawFrameBitmap(msg.bitmap);
842
+ if (this._holdPresent) {
843
+ this._stagedFrame?.close();
844
+ this._stagedFrame = msg.bitmap;
845
+ } else {
846
+ this._drawFrameBitmap(msg.bitmap);
847
+ }
848
+
676
849
  break;
677
850
  case "error":
678
851
  this._rejectReady(new Error(msg.message));
@@ -680,6 +853,9 @@ export class RendererTransport {
680
853
  case "loadAndRenderAck":
681
854
  this._resolvePending(msg.msgId, "loadAndRender", undefined);
682
855
  break;
856
+ case "resizeAck":
857
+ this._resolvePending(msg.msgId, "resize", undefined);
858
+ break;
683
859
  case "snapshotPngReply":
684
860
  this._resolvePending(msg.requestId, "snapshotPng", msg.blob);
685
861
  break;
@@ -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
  }