@perspective-dev/viewer-charts 5.2.0 → 5.3.1

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 (109) hide show
  1. package/dist/cdn/perspective-viewer-charts.js +1 -2
  2. package/dist/cdn/perspective-viewer-charts.js.map +4 -4
  3. package/dist/esm/axis/legend.d.ts +53 -5
  4. package/dist/esm/axis/map-ticks.d.ts +15 -0
  5. package/dist/esm/charts/candlestick/candlestick-interact.d.ts +2 -6
  6. package/dist/esm/charts/candlestick/candlestick-render.d.ts +3 -0
  7. package/dist/esm/charts/cartesian/cartesian.d.ts +7 -0
  8. package/dist/esm/charts/cartesian/glyph.d.ts +2 -1
  9. package/dist/esm/charts/cartesian/glyphs/density.d.ts +2 -1
  10. package/dist/esm/charts/cartesian/glyphs/lines.d.ts +2 -1
  11. package/dist/esm/charts/cartesian/glyphs/points.d.ts +2 -1
  12. package/dist/esm/charts/cartesian/tooltip-lines.d.ts +1 -1
  13. package/dist/esm/charts/chart-base.d.ts +24 -9
  14. package/dist/esm/charts/chart.d.ts +81 -6
  15. package/dist/esm/charts/common/category-axis-resolver.d.ts +1 -1
  16. package/dist/esm/charts/common/draw-tooltip-box.d.ts +2 -1
  17. package/dist/esm/charts/common/tree-chart.d.ts +1 -0
  18. package/dist/esm/charts/common/tree-interact.d.ts +1 -1
  19. package/dist/esm/charts/heatmap/heatmap.d.ts +1 -0
  20. package/dist/esm/charts/series/series-build.d.ts +7 -0
  21. package/dist/esm/charts/series/series-interact.d.ts +1 -1
  22. package/dist/esm/charts/series/series-type.d.ts +18 -0
  23. package/dist/esm/charts/series/series.d.ts +10 -1
  24. package/dist/esm/charts/sunburst/sunburst-render.d.ts +4 -0
  25. package/dist/esm/index.d.ts +2 -0
  26. package/dist/esm/interaction/axis-indicators.d.ts +11 -0
  27. package/dist/esm/interaction/host-sink-dom.d.ts +3 -2
  28. package/dist/esm/interaction/host-sink-message.d.ts +3 -2
  29. package/dist/esm/interaction/lazy-tooltip.d.ts +2 -2
  30. package/dist/esm/interaction/legend-controller.d.ts +246 -0
  31. package/dist/esm/interaction/tooltip-controller.d.ts +20 -95
  32. package/dist/esm/interaction/tooltip-grid.d.ts +33 -0
  33. package/dist/esm/layout/facet-grid.d.ts +6 -0
  34. package/dist/esm/layout/ticks.d.ts +1 -0
  35. package/dist/esm/map/tile-source.d.ts +77 -8
  36. package/dist/esm/perspective-viewer-charts.js +1 -2
  37. package/dist/esm/perspective-viewer-charts.js.map +4 -4
  38. package/dist/esm/plugin/charts.d.ts +2 -0
  39. package/dist/esm/plugin/format-defaults.d.ts +9 -0
  40. package/dist/esm/plugin/plugin.d.ts +28 -1
  41. package/dist/esm/theme/gradient.d.ts +32 -0
  42. package/dist/esm/theme/overrides.d.ts +19 -0
  43. package/dist/esm/transport/protocol.d.ts +58 -5
  44. package/dist/esm/transport/renderer-transport.d.ts +3 -0
  45. package/dist/esm/utils/css.d.ts +4 -0
  46. package/dist/esm/worker/renderer.worker.d.ts +46 -1
  47. package/package.json +1 -1
  48. package/src/css/perspective-viewer-charts.css +0 -1
  49. package/src/ts/axis/bar-axis.ts +2 -7
  50. package/src/ts/axis/legend.ts +351 -24
  51. package/src/ts/axis/map-ticks.ts +94 -0
  52. package/src/ts/axis/numeric-axis.ts +2 -9
  53. package/src/ts/charts/candlestick/candlestick-build.ts +1 -0
  54. package/src/ts/charts/candlestick/candlestick-interact.ts +46 -40
  55. package/src/ts/charts/candlestick/candlestick-render.ts +81 -10
  56. package/src/ts/charts/candlestick/candlestick.ts +4 -3
  57. package/src/ts/charts/cartesian/cartesian-interact.ts +9 -3
  58. package/src/ts/charts/cartesian/cartesian-render.ts +374 -75
  59. package/src/ts/charts/cartesian/cartesian.ts +13 -0
  60. package/src/ts/charts/cartesian/glyph.ts +6 -2
  61. package/src/ts/charts/cartesian/glyphs/density.ts +2 -2
  62. package/src/ts/charts/cartesian/glyphs/lines.ts +15 -11
  63. package/src/ts/charts/cartesian/glyphs/points.ts +9 -5
  64. package/src/ts/charts/cartesian/tooltip-lines.ts +8 -8
  65. package/src/ts/charts/chart-base.ts +76 -42
  66. package/src/ts/charts/chart.ts +111 -7
  67. package/src/ts/charts/common/category-axis-resolver.ts +44 -5
  68. package/src/ts/charts/common/draw-tooltip-box.ts +13 -30
  69. package/src/ts/charts/common/tree-chart.ts +4 -0
  70. package/src/ts/charts/common/tree-chrome.ts +102 -23
  71. package/src/ts/charts/common/tree-interact.ts +18 -17
  72. package/src/ts/charts/heatmap/heatmap-build.ts +7 -1
  73. package/src/ts/charts/heatmap/heatmap-interact.ts +75 -8
  74. package/src/ts/charts/heatmap/heatmap-render.ts +105 -24
  75. package/src/ts/charts/heatmap/heatmap.ts +8 -3
  76. package/src/ts/charts/map/map.ts +3 -5
  77. package/src/ts/charts/series/series-build.ts +15 -2
  78. package/src/ts/charts/series/series-interact.ts +14 -24
  79. package/src/ts/charts/series/series-render.ts +310 -132
  80. package/src/ts/charts/series/series-type.ts +65 -0
  81. package/src/ts/charts/series/series.ts +31 -8
  82. package/src/ts/charts/sunburst/sunburst-render.ts +16 -1
  83. package/src/ts/charts/treemap/treemap-render.ts +12 -1
  84. package/src/ts/data/view-reader.ts +1 -4
  85. package/src/ts/index.ts +2 -0
  86. package/src/ts/interaction/axis-indicators.ts +137 -0
  87. package/src/ts/interaction/host-sink-dom.ts +66 -2
  88. package/src/ts/interaction/host-sink-message.ts +8 -2
  89. package/src/ts/interaction/lazy-tooltip.ts +5 -5
  90. package/src/ts/interaction/legend-controller.ts +1084 -0
  91. package/src/ts/interaction/tooltip-controller.ts +43 -131
  92. package/src/ts/interaction/tooltip-grid.ts +187 -0
  93. package/src/ts/layout/facet-grid.ts +8 -1
  94. package/src/ts/layout/ticks.ts +48 -12
  95. package/src/ts/map/tile-loader.ts +16 -1
  96. package/src/ts/map/tile-source.ts +199 -53
  97. package/src/ts/map/tile-sources.json +18 -0
  98. package/src/ts/plugin/charts.ts +40 -6
  99. package/src/ts/plugin/format-defaults.ts +37 -0
  100. package/src/ts/plugin/plugin.ts +330 -76
  101. package/src/ts/shaders/line.vert.glsl +5 -2
  102. package/src/ts/shaders/scatter.vert.glsl +0 -7
  103. package/src/ts/theme/gradient.ts +265 -1
  104. package/src/ts/theme/overrides.ts +76 -0
  105. package/src/ts/transport/protocol.ts +60 -4
  106. package/src/ts/transport/renderer-transport.ts +107 -14
  107. package/src/ts/utils/css.ts +12 -0
  108. package/src/ts/worker/dispatch.ts +6 -2
  109. package/src/ts/worker/renderer.worker.ts +191 -7
@@ -14,6 +14,7 @@ import type { Client, View, ViewConfig } from "@perspective-dev/client";
14
14
  import type { FacetConfig, PluginConfig } from "../charts/chart";
15
15
  import type {
16
16
  ControlMsg,
17
+ ErrorMsg,
17
18
  InitMsg,
18
19
  InteractionEvent,
19
20
  LoadAndRenderMsg,
@@ -26,6 +27,7 @@ import {
26
27
  } from "../event-detail";
27
28
  import { snapshotThemeVars } from "../theme/theme-snapshot";
28
29
  import { snapshotFontFaces } from "../utils/font-snapshot";
30
+ import { TILE_SOURCES } from "../map/tile-source";
29
31
  import { DomHostSink } from "../interaction/host-sink-dom";
30
32
  import { RUNTIME_MODE } from "../config";
31
33
 
@@ -72,6 +74,24 @@ async function getSharedWorker(): Promise<Worker> {
72
74
  const env = e.data as WorkerEnvelope;
73
75
  HOST_LISTENERS.get(env.sessionId)?.(env.msg);
74
76
  });
77
+
78
+ let poisoned = false;
79
+ w.addEventListener("error", (event: ErrorEvent) => {
80
+ if (poisoned) {
81
+ return;
82
+ }
83
+
84
+ poisoned = true;
85
+ SHARED_WORKER = null;
86
+ const detail = event.message || `failed to load ${url}`;
87
+ const message = `perspective-viewer-charts renderer worker error: ${detail}`;
88
+ console.error(message, event.error ?? "");
89
+ const errorMsg: ErrorMsg = { kind: "error", message };
90
+ for (const listener of HOST_LISTENERS.values()) {
91
+ listener(errorMsg);
92
+ }
93
+ });
94
+
75
95
  return w;
76
96
  })();
77
97
 
@@ -156,6 +176,10 @@ export class RendererTransport {
156
176
  private _pendingCounter = 0;
157
177
  private _onZoomChanged: ((isDefault: boolean) => void) | null = null;
158
178
 
179
+ private _onPluginConfigDelta:
180
+ | ((fields: Record<string, string | number | boolean>) => void)
181
+ | null = null;
182
+
159
183
  /**
160
184
  * Cached zoom-default flag pushed by the renderer after each zoom
161
185
  * mutation. Surfaced sync via `allZoomsDefault()`; updates between
@@ -235,6 +259,9 @@ export class RendererTransport {
235
259
  maxCells: number;
236
260
  precompileShaders?: boolean;
237
261
  onZoomChanged?: (isDefault: boolean) => void;
262
+ onPluginConfigDelta?: (
263
+ fields: Record<string, string | number | boolean>,
264
+ ) => void;
238
265
  }) {
239
266
  this._client = opts.client;
240
267
  this._view = opts.view;
@@ -246,6 +273,7 @@ export class RendererTransport {
246
273
  this._maxCells = opts.maxCells;
247
274
  this._precompileShaders = opts.precompileShaders ?? false;
248
275
  this._onZoomChanged = opts.onZoomChanged ?? null;
276
+ this._onPluginConfigDelta = opts.onPluginConfigDelta ?? null;
249
277
  this._ready = new Promise((resolve, reject) => {
250
278
  this._resolveReady = resolve;
251
279
  this._rejectReady = reject;
@@ -299,15 +327,24 @@ export class RendererTransport {
299
327
  // `ImageBitmap`. Direct mode transfers the visible canvas's
300
328
  // drawing buffer to the renderer so GL paints straight to
301
329
  // screen.
330
+ // Blit mode also keeps the gridlines/chrome layers worker-side:
331
+ // a transferred canvas presents on the compositor's schedule,
332
+ // unsynchronized with the host's layout commits, so it can't
333
+ // participate in the staged-present hold. The renderer
334
+ // composites both layers into the shipped frame instead, and
335
+ // the host-side placeholder canvases stay untouched
336
+ // (transparent). Direct mode transfers all three surfaces.
302
337
  let glOC: OffscreenCanvas | undefined;
338
+ let gridlinesOC: OffscreenCanvas | undefined;
339
+ let chromeOC: OffscreenCanvas | undefined;
303
340
  if (opts.renderBlitMode === "blit") {
304
341
  this._displayCtx = opts.gl.getContext("2d");
305
342
  } else {
306
343
  glOC = opts.gl.transferControlToOffscreen();
344
+ gridlinesOC = opts.gridlines.transferControlToOffscreen();
345
+ chromeOC = opts.chrome.transferControlToOffscreen();
307
346
  }
308
347
 
309
- const gridlinesOC = opts.gridlines.transferControlToOffscreen();
310
- const chromeOC = opts.chrome.transferControlToOffscreen();
311
348
  const rect = opts.gl.getBoundingClientRect();
312
349
  const dpr = window.devicePixelRatio || 1;
313
350
  const themeVars = snapshotThemeVars(opts.gl);
@@ -342,6 +379,9 @@ export class RendererTransport {
342
379
  pluginConfig: opts.pluginConfig,
343
380
  columnsConfig: opts.columnsConfig,
344
381
  defaultChartType: opts.defaultChartType,
382
+ tileSource: TILE_SOURCES.specFor(
383
+ opts.pluginConfig.map_tile_provider,
384
+ ),
345
385
  themeVars,
346
386
  fontFaces,
347
387
  cssWidth: rect.width,
@@ -365,14 +405,19 @@ export class RendererTransport {
365
405
  // Worker mode: the bootstrap is triggered by posting the
366
406
  // init message into the worker's scope (which the
367
407
  // `if (IS_WORKER_SCOPE)` block in `renderer.worker.ts`
368
- // listens for). `glOC` is omitted in blit mode (the
369
- // renderer allocates its own offscreen) — only include the
370
- // GL canvas in the transfer list when present.
371
- const transfer: Transferable[] = [
372
- gridlinesOC,
373
- chromeOC,
374
- this._proxyChannel!.port2,
375
- ];
408
+ // listens for). All three canvases are omitted in blit mode
409
+ // (the renderer allocates its own offscreens and ships
410
+ // composited frames) — only include the transferred
411
+ // surfaces present on this mode's init message.
412
+ const transfer: Transferable[] = [this._proxyChannel!.port2];
413
+ if (chromeOC) {
414
+ transfer.unshift(chromeOC);
415
+ }
416
+
417
+ if (gridlinesOC) {
418
+ transfer.unshift(gridlinesOC);
419
+ }
420
+
376
421
  if (glOC) {
377
422
  transfer.unshift(glOC);
378
423
  }
@@ -474,7 +519,11 @@ export class RendererTransport {
474
519
  }
475
520
 
476
521
  setPluginConfig(cfg: PluginConfig): void {
477
- this._post({ kind: "setPluginConfig", cfg });
522
+ this._post({
523
+ kind: "setPluginConfig",
524
+ cfg,
525
+ tileSource: TILE_SOURCES.specFor(cfg.map_tile_provider),
526
+ });
478
527
  }
479
528
 
480
529
  setBufferMaxCapacity(n: number): void {
@@ -793,7 +842,12 @@ export class RendererTransport {
793
842
  this._resolvePending(msg.requestId, "saveZoom", msg.state);
794
843
  break;
795
844
  case "pinTooltip":
796
- this._ensureHostSink()?.pin(msg.lines, msg.pos, msg.bounds);
845
+ this._ensureHostSink()?.pin(
846
+ msg.grid,
847
+ msg.pos,
848
+ msg.bounds,
849
+ msg.style,
850
+ );
797
851
  break;
798
852
  case "dismissTooltip":
799
853
  this._hostSink?.dismiss();
@@ -801,6 +855,9 @@ export class RendererTransport {
801
855
  case "setCursor":
802
856
  this._ensureHostSink()?.setCursor(msg.cursor);
803
857
  break;
858
+ case "pluginConfigDelta":
859
+ this._onPluginConfigDelta?.(msg.fields);
860
+ break;
804
861
  case "userClick":
805
862
  this._dispatchOnViewer(
806
863
  new CustomEvent<PerspectiveClickDetail>(
@@ -864,13 +921,35 @@ export class RendererTransport {
864
921
  this._rejectReady(new Error(msg.message));
865
922
  break;
866
923
  case "loadAndRenderAck":
867
- this._resolvePending(msg.msgId, "loadAndRender", undefined);
924
+ if (msg.error !== undefined) {
925
+ this._rejectPending(
926
+ msg.msgId,
927
+ "loadAndRender",
928
+ new Error(msg.error),
929
+ );
930
+ } else {
931
+ this._resolvePending(msg.msgId, "loadAndRender", undefined);
932
+ }
933
+
868
934
  break;
869
935
  case "resizeAck":
870
936
  this._resolvePending(msg.msgId, "resize", undefined);
871
937
  break;
872
938
  case "snapshotPngReply":
873
- this._resolvePending(msg.requestId, "snapshotPng", msg.blob);
939
+ if (msg.error !== undefined) {
940
+ this._rejectPending(
941
+ msg.requestId,
942
+ "snapshotPng",
943
+ new Error(msg.error),
944
+ );
945
+ } else {
946
+ this._resolvePending(
947
+ msg.requestId,
948
+ "snapshotPng",
949
+ msg.blob,
950
+ );
951
+ }
952
+
874
953
  break;
875
954
  }
876
955
  }
@@ -896,6 +975,20 @@ export class RendererTransport {
896
975
  entry.resolve(value);
897
976
  }
898
977
 
978
+ private _rejectPending(
979
+ id: number,
980
+ kind: PendingRenderType,
981
+ error: Error,
982
+ ): void {
983
+ const entry = this._pending.get(id);
984
+ if (!entry || entry.kind !== kind) {
985
+ return;
986
+ }
987
+
988
+ this._pending.delete(id);
989
+ entry.reject(error);
990
+ }
991
+
899
992
  /**
900
993
  * Blit-mode handler: draw a renderer-emitted frame into the
901
994
  * visible 2D-context display canvas, then close the bitmap so its
@@ -34,3 +34,15 @@ export function parseCSSColorToVec3(
34
34
 
35
35
  return [0.5, 0.5, 0.5];
36
36
  }
37
+
38
+ /**
39
+ * Format a `[0, 1]`-range RGB triple as a plain `#rrggbb` string.
40
+ */
41
+ export function vec3ToHexColor(c: [number, number, number]): string {
42
+ const channel = (x: number) =>
43
+ Math.round(Math.max(0, Math.min(1, x)) * 255)
44
+ .toString(16)
45
+ .padStart(2, "0");
46
+
47
+ return `#${channel(c[0])}${channel(c[1])}${channel(c[2])}`;
48
+ }
@@ -28,7 +28,7 @@ export function dispatch(r: WorkerRenderer, msg: ControlMsg): void {
28
28
  r.chartImpl.setColumnsConfig?.(msg.cfg);
29
29
  break;
30
30
  case "setPluginConfig":
31
- r.chartImpl.setPluginConfig?.(msg.cfg);
31
+ r.setPluginConfig(msg.cfg, msg.tileSource);
32
32
  r.redraw();
33
33
  break;
34
34
  case "setBufferMaxCapacity":
@@ -93,7 +93,11 @@ export function dispatch(r: WorkerRenderer, msg: ControlMsg): void {
93
93
  r.post({ kind: "snapshotPngReply", requestId, blob });
94
94
  })
95
95
  .catch((err) => {
96
- r.post({ kind: "error", message: String(err) });
96
+ r.post({
97
+ kind: "snapshotPngReply",
98
+ requestId,
99
+ error: String(err),
100
+ });
97
101
  });
98
102
  break;
99
103
  }
@@ -18,7 +18,7 @@ import type * as wasm_module_type from "@perspective-dev/viewer/dist/wasm/perspe
18
18
  import { WebGLContextManager } from "../webgl/context-manager";
19
19
  import { ContextPool } from "../webgl/context-pool";
20
20
  import { RENDER_CONTEXT_POOL_SIZE } from "../config";
21
- import { ChartImplementation } from "../charts/chart";
21
+ import { ChartImplementation, type PluginConfig } from "../charts/chart";
22
22
  import { ZoomController } from "../interaction/zoom-controller";
23
23
  import {
24
24
  applyPan,
@@ -36,6 +36,7 @@ import type {
36
36
  WorkerMsg,
37
37
  } from "../transport/protocol";
38
38
  import { viewToColumnDataMap } from "../data/view-reader";
39
+ import { TILE_SOURCES, type TileSourceSpec } from "../map/tile-source";
39
40
  import { loadFontDeduped } from "./font-loader";
40
41
  import { dispatch } from "./dispatch";
41
42
  import { installSessionHost } from "./session-host";
@@ -106,6 +107,20 @@ export class WorkerRenderer {
106
107
  cssWidth: number;
107
108
  cssHeight: number;
108
109
  dpr: number;
110
+
111
+ /**
112
+ * Blit-mode compose surface: gridlines + GL frame + chrome are
113
+ * drawn here each `endFrame` and shipped as ONE `ImageBitmap`, so
114
+ * every layer rides the host's staged-present hold (see
115
+ * `InitMsg.gridlinesCanvas`). `null` in direct mode.
116
+ */
117
+ private _composeCanvas: OffscreenCanvas | null = null;
118
+ private _composeCtx: OffscreenCanvasRenderingContext2D | null = null;
119
+
120
+ private _lastPlot: ImageBitmap | null = null;
121
+
122
+ private _frameSerial = 0;
123
+ private _overlayPresentQueued = false;
109
124
  client: Client;
110
125
  view: View;
111
126
 
@@ -152,6 +167,13 @@ export class WorkerRenderer {
152
167
 
153
168
  this.chartImpl = new ImplClass();
154
169
 
170
+ // Registry write must precede the chart impl's first
171
+ // `setPluginConfig` — `pluginConfig.map_tile_provider` may
172
+ // name this runtime-registered source.
173
+ if (msg.tileSource) {
174
+ TILE_SOURCES.register(msg.tileSource);
175
+ }
176
+
155
177
  // Three surfaces, by mode:
156
178
  // - direct: the host's transferred `.webgl-canvas` (1:1 with a
157
179
  // context, permanently).
@@ -182,20 +204,31 @@ export class WorkerRenderer {
182
204
  });
183
205
  }
184
206
 
207
+ // Blit mode: the 2D layers are worker-local (the host omits
208
+ // them from the init message) and every shipped frame is the
209
+ // full composite — gridlines under the GL plot under chrome,
210
+ // the same stacking as the host's canvas elements and the
211
+ // `snapshotPng` composite. Direct mode draws into the host's
212
+ // transferred surfaces and ships nothing.
213
+ const w = Math.max(1, Math.round(msg.cssWidth * msg.dpr));
214
+ const h = Math.max(1, Math.round(msg.cssHeight * msg.dpr));
215
+ this.gridlines = msg.gridlinesCanvas ?? new OffscreenCanvas(w, h);
216
+ this.chrome = msg.chromeCanvas ?? new OffscreenCanvas(w, h);
185
217
  if (msg.renderMode === "blit") {
186
218
  this.glManager.setFrameCallback((bitmap) => {
187
- this.post({ kind: "frameBitmap", bitmap }, [bitmap]);
219
+ this._frameSerial += 1;
220
+ const frame = this._composeFrame(bitmap);
221
+ this.post({ kind: "frameBitmap", bitmap: frame }, [frame]);
188
222
  });
223
+ this.chartImpl.setOverlayPresenter?.(() => this._presentOverlay());
189
224
  }
190
225
 
191
- this.gridlines = msg.gridlinesCanvas;
192
- this.chrome = msg.chromeCanvas;
193
226
  this.cssWidth = msg.cssWidth;
194
227
  this.cssHeight = msg.cssHeight;
195
228
  this.dpr = msg.dpr;
196
229
 
197
- this.chartImpl.setGridlineCanvas?.(msg.gridlinesCanvas);
198
- this.chartImpl.setChromeCanvas?.(msg.chromeCanvas);
230
+ this.chartImpl.setGridlineCanvas?.(this.gridlines);
231
+ this.chartImpl.setChromeCanvas?.(this.chrome);
199
232
  this.chartImpl.setTheme?.(msg.themeVars);
200
233
 
201
234
  if (msg.defaultChartType) {
@@ -228,6 +261,20 @@ export class WorkerRenderer {
228
261
  this.chartImpl.setView?.(this.view);
229
262
  }
230
263
 
264
+ /**
265
+ * Registry write precedes the config apply — `cfg` may name the
266
+ * spec riding alongside it (see `SetPluginConfigMsg.tileSource`),
267
+ * and a replaced template must be registered first so the map
268
+ * chart's rebind sees the new content-derived cache id.
269
+ */
270
+ setPluginConfig(cfg: PluginConfig, tileSource?: TileSourceSpec): void {
271
+ if (tileSource) {
272
+ TILE_SOURCES.register(tileSource);
273
+ }
274
+
275
+ this.chartImpl.setPluginConfig?.(cfg);
276
+ }
277
+
231
278
  /**
232
279
  * Full data-fetch + render pipeline. Owns every `Client`/`Table`/
233
280
  * `View` await on the render path:
@@ -258,6 +305,7 @@ export class WorkerRenderer {
258
305
  */
259
306
  async loadAndRender(msg: LoadAndRenderMsg): Promise<void> {
260
307
  const myGen = ++this._renderGen;
308
+ let error: string | undefined;
261
309
  try {
262
310
  const [numRows, schema, exprSchema, tableSchema] =
263
311
  await Promise.all([
@@ -327,9 +375,10 @@ export class WorkerRenderer {
327
375
  } catch (err) {
328
376
  if ((err + "").indexOf("View not found") === -1) {
329
377
  console.error("loadAndRender failed", err);
378
+ error = String(err);
330
379
  }
331
380
  } finally {
332
- this.post({ kind: "loadAndRenderAck", msgId: msg.msgId });
381
+ this.post({ kind: "loadAndRenderAck", msgId: msg.msgId, error });
333
382
  }
334
383
  }
335
384
 
@@ -498,7 +547,59 @@ export class WorkerRenderer {
498
547
  return { controller: this.zoomController, layout };
499
548
  }
500
549
 
550
+ /**
551
+ * Legend-first interaction routing. The chart's `LegendController`
552
+ * sees every forwarded event BEFORE the zoom / tooltip paths, so
553
+ * legend gestures (scroll, width drag, floating move / resize)
554
+ * structurally preempt plot pan / zoom / hover — a wheel over the
555
+ * legend can never zoom the plot under it, and a floating-panel
556
+ * drag can never start a pan.
557
+ */
558
+ private _legendInteraction(event: InteractionEvent): boolean {
559
+ const chart = this.chartImpl as any;
560
+ const legend = chart?._legend;
561
+ const cfg = chart?._pluginConfig;
562
+ if (!legend || !cfg) {
563
+ return false;
564
+ }
565
+
566
+ return legend.handleEvent(event, {
567
+ cfg,
568
+ cssWidth: this.cssWidth,
569
+ cssHeight: this.cssHeight,
570
+ legendRects: chart._legendRects ?? [],
571
+ repaint: (relayout: boolean) => {
572
+ if (!relayout && typeof chart.repaintChrome === "function") {
573
+ chart.repaintChrome();
574
+ } else {
575
+ this.chartImpl.requestRender(this.glManager);
576
+ }
577
+ },
578
+ postDelta: (fields: Record<string, string | number | boolean>) =>
579
+ this.post({ kind: "pluginConfigDelta", fields }),
580
+ setCursor: (cursor: string) => chart._hostSink?.setCursor?.(cursor),
581
+ dispatchLeave: () => this._tooltip()?.dispatchLeave(),
582
+ });
583
+ }
584
+
501
585
  onInteraction(event: InteractionEvent): void {
586
+ try {
587
+ this._routeInteraction(event);
588
+ } catch (err) {
589
+ this._dragTarget = null;
590
+ (this.chartImpl as any)?._legend?.cancelGesture?.();
591
+ console.error("interaction dispatch failed", err);
592
+ }
593
+ }
594
+
595
+ private _routeInteraction(event: InteractionEvent): void {
596
+ const plotDragging =
597
+ this._dragTarget !== null &&
598
+ (event.type === "pointermove" || event.type === "pointerup");
599
+ if (!plotDragging && this._legendInteraction(event)) {
600
+ return;
601
+ }
602
+
502
603
  switch (event.type) {
503
604
  case "wheel": {
504
605
  const target = this._resolveTarget(event.mx, event.my);
@@ -588,6 +689,87 @@ export class WorkerRenderer {
588
689
  return tt ?? null;
589
690
  }
590
691
 
692
+ /**
693
+ * Blit-mode frame composite: draw the gridlines (bottom), the GL
694
+ * plot bitmap (middle) and the chrome/axes (top) into the compose
695
+ * surface and transfer the result — the single `ImageBitmap` the
696
+ * host blits, and the unit the staged-present hold stages. Runs in
697
+ * `endFrame`, which the scheduler orders after `render2D`'s 2D
698
+ * flush, so both layers hold this frame's content.
699
+ *
700
+ * A layer whose buffer differs from the plot's dimensions is
701
+ * skipped rather than scaled: its content is a stale-sized frame
702
+ * mid-resize (the 2D draws re-size their canvas to `css × dpr` on
703
+ * the next flush), and scaling it would re-introduce exactly the
704
+ * warp this composite exists to prevent.
705
+ */
706
+ private _composeFrame(plot: ImageBitmap): ImageBitmap {
707
+ const w = plot.width;
708
+ const h = plot.height;
709
+ if (!this._composeCanvas) {
710
+ this._composeCanvas = new OffscreenCanvas(w, h);
711
+ this._composeCtx = this._composeCanvas.getContext("2d");
712
+ }
713
+
714
+ const canvas = this._composeCanvas;
715
+ const ctx = this._composeCtx;
716
+ if (!ctx) {
717
+ return plot;
718
+ }
719
+
720
+ if (canvas.width !== w || canvas.height !== h) {
721
+ canvas.width = w;
722
+ canvas.height = h;
723
+ } else {
724
+ ctx.clearRect(0, 0, w, h);
725
+ }
726
+
727
+ if (this.gridlines.width === w && this.gridlines.height === h) {
728
+ ctx.drawImage(this.gridlines, 0, 0);
729
+ }
730
+
731
+ ctx.drawImage(plot, 0, 0);
732
+ if (this._lastPlot !== plot) {
733
+ this._lastPlot?.close();
734
+ this._lastPlot = plot;
735
+ }
736
+
737
+ if (this.chrome.width === w && this.chrome.height === h) {
738
+ ctx.drawImage(this.chrome, 0, 0);
739
+ }
740
+
741
+ return canvas.transferToImageBitmap();
742
+ }
743
+
744
+ private _presentOverlay(): void {
745
+ if (this._overlayPresentQueued) {
746
+ return;
747
+ }
748
+
749
+ this._overlayPresentQueued = true;
750
+ const mark = this._frameSerial;
751
+ queueMicrotask(() => {
752
+ this._overlayPresentQueued = false;
753
+ const plot = this._lastPlot;
754
+ if (this._frameSerial !== mark || !plot) {
755
+ return;
756
+ }
757
+
758
+ const w = Math.max(1, Math.round(this.cssWidth * this.dpr));
759
+ const h = Math.max(1, Math.round(this.cssHeight * this.dpr));
760
+ if (plot.width !== w || plot.height !== h) {
761
+ return;
762
+ }
763
+
764
+ const frame = this._composeFrame(plot);
765
+ if (frame === plot) {
766
+ return;
767
+ }
768
+
769
+ this.post({ kind: "frameBitmap", bitmap: frame }, [frame]);
770
+ });
771
+ }
772
+
591
773
  /**
592
774
  * Composite the three layers into a single PNG `Blob`.
593
775
  */
@@ -649,6 +831,8 @@ export class WorkerRenderer {
649
831
  // next-RAF `drain()` can't paint/present against a dead
650
832
  // context (the "scheduler: present failed" path).
651
833
  unregister(this.glManager);
834
+ this._lastPlot?.close();
835
+ this._lastPlot = null;
652
836
  this.chartImpl.destroy();
653
837
  this.glManager.destroy();
654
838
  }