@perspective-dev/viewer-charts 4.5.0 → 4.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE.md +193 -0
  2. package/dist/cdn/perspective-viewer-charts.js +2 -2
  3. package/dist/cdn/perspective-viewer-charts.js.map +3 -3
  4. package/dist/esm/axis/bar-axis.d.ts +9 -1
  5. package/dist/esm/axis/categorical-axis.d.ts +0 -2
  6. package/dist/esm/charts/cartesian/cartesian.d.ts +26 -0
  7. package/dist/esm/charts/common/category-axis-resolver.d.ts +43 -1
  8. package/dist/esm/charts/common/expand-domain.d.ts +20 -0
  9. package/dist/esm/charts/common/tree-chart.d.ts +7 -0
  10. package/dist/esm/charts/common/tree-chrome.d.ts +23 -1
  11. package/dist/esm/charts/common/tree-interact.d.ts +46 -0
  12. package/dist/esm/charts/series/glyphs/draw-lines.d.ts +11 -4
  13. package/dist/esm/charts/series/series-build.d.ts +38 -2
  14. package/dist/esm/charts/series/series-render.d.ts +1 -4
  15. package/dist/esm/charts/series/series-type.d.ts +19 -17
  16. package/dist/esm/charts/series/series.d.ts +16 -0
  17. package/dist/esm/charts/sunburst/sunburst-interact.d.ts +1 -1
  18. package/dist/esm/charts/treemap/treemap-interact.d.ts +1 -6
  19. package/dist/esm/config.d.ts +19 -0
  20. package/dist/esm/interaction/host-sink-message.d.ts +10 -28
  21. package/dist/esm/interaction/raw-event-forwarder.d.ts +6 -7
  22. package/dist/esm/interaction/zoom-controller.d.ts +31 -20
  23. package/dist/esm/interaction/zoom-router.d.ts +3 -26
  24. package/dist/esm/perspective-viewer-charts.js +2 -2
  25. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  26. package/dist/esm/plugin/plugin.d.ts +15 -3
  27. package/dist/esm/render/scheduler.d.ts +23 -0
  28. package/dist/esm/theme/palette.d.ts +0 -5
  29. package/dist/esm/transport/protocol.d.ts +2 -7
  30. package/dist/esm/webgl/context-manager.d.ts +100 -117
  31. package/dist/esm/webgl/context-pool.d.ts +40 -0
  32. package/dist/esm/webgl/gl-context.d.ts +54 -0
  33. package/dist/esm/worker/renderer.worker.d.ts +2 -4
  34. package/package.json +45 -45
  35. package/src/ts/axis/bar-axis.ts +74 -45
  36. package/src/ts/axis/categorical-axis.ts +0 -2
  37. package/src/ts/charts/candlestick/candlestick-render.ts +10 -7
  38. package/src/ts/charts/candlestick/candlestick.ts +10 -29
  39. package/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +36 -2
  40. package/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +36 -2
  41. package/src/ts/charts/cartesian/cartesian-build.ts +143 -9
  42. package/src/ts/charts/cartesian/cartesian-render.ts +205 -30
  43. package/src/ts/charts/cartesian/cartesian.ts +43 -4
  44. package/src/ts/charts/cartesian/glyphs/density.ts +36 -41
  45. package/src/ts/charts/cartesian/glyphs/lines.ts +13 -15
  46. package/src/ts/charts/cartesian/glyphs/points.ts +12 -17
  47. package/src/ts/charts/chart-base.ts +20 -6
  48. package/src/ts/charts/chart.ts +1 -1
  49. package/src/ts/charts/common/category-axis-resolver.ts +135 -1
  50. package/src/ts/charts/common/expand-domain.ts +40 -0
  51. package/src/ts/charts/common/tree-chart.ts +16 -0
  52. package/src/ts/charts/common/tree-chrome.ts +86 -1
  53. package/src/ts/charts/common/tree-interact.ts +209 -0
  54. package/src/ts/charts/heatmap/heatmap-render.ts +9 -11
  55. package/src/ts/charts/series/glyphs/draw-areas.ts +30 -1
  56. package/src/ts/charts/series/glyphs/draw-lines.ts +151 -76
  57. package/src/ts/charts/series/series-build.ts +394 -21
  58. package/src/ts/charts/series/series-render.ts +159 -38
  59. package/src/ts/charts/series/series-type.ts +37 -17
  60. package/src/ts/charts/series/series.ts +63 -68
  61. package/src/ts/charts/sunburst/sunburst-interact.ts +18 -162
  62. package/src/ts/charts/sunburst/sunburst-render.ts +24 -89
  63. package/src/ts/charts/sunburst/sunburst.ts +1 -15
  64. package/src/ts/charts/treemap/treemap-interact.ts +22 -189
  65. package/src/ts/charts/treemap/treemap-render.ts +19 -46
  66. package/src/ts/charts/treemap/treemap.ts +1 -16
  67. package/src/ts/config.ts +21 -1
  68. package/src/ts/interaction/host-sink-message.ts +33 -22
  69. package/src/ts/interaction/raw-event-forwarder.ts +10 -12
  70. package/src/ts/interaction/zoom-controller.ts +120 -83
  71. package/src/ts/interaction/zoom-router.ts +3 -126
  72. package/src/ts/map/tile-layer.ts +13 -13
  73. package/src/ts/plugin/plugin.ts +176 -196
  74. package/src/ts/render/scheduler.ts +125 -42
  75. package/src/ts/shaders/line-uniform.frag.glsl +2 -1
  76. package/src/ts/shaders/line-uniform.vert.glsl +19 -0
  77. package/src/ts/theme/palette.ts +1 -4
  78. package/src/ts/transport/protocol.ts +3 -8
  79. package/src/ts/webgl/context-manager.ts +195 -189
  80. package/src/ts/webgl/context-pool.ts +78 -0
  81. package/src/ts/webgl/gl-context.ts +191 -0
  82. package/src/ts/worker/dispatch.ts +0 -1
  83. package/src/ts/worker/renderer.worker.ts +72 -65
  84. package/src/ts/worker/session-host.ts +29 -0
@@ -24,134 +24,11 @@ export interface ZoomTarget {
24
24
  controller: ZoomController;
25
25
  layout: PlotLayout;
26
26
  }
27
- export type ZoomTargetResolver = (mx: number, my: number) => ZoomTarget | null;
28
27
 
29
28
  /**
30
- * One set of wheel / pointer listeners on the GL canvas that dispatches
31
- * zoom + pan events to a {@link ZoomController} resolved from the
32
- * cursor position. Replaces `ZoomController.attach` so multiple
33
- * controllers (one per facet) can coexist on a single canvas.
34
- */
35
- export class ZoomRouter {
36
- private _element: HTMLElement | null = null;
37
- private _resolve: ZoomTargetResolver | null = null;
38
- private _onUpdate: (() => void) | null = null;
39
-
40
- private _pointerDown = false;
41
- private _pointerTarget: ZoomTarget | null = null;
42
- private _lastPointerX = 0;
43
- private _lastPointerY = 0;
44
-
45
- private _onWheel: ((e: WheelEvent) => void) | null = null;
46
- private _onPointerDown: ((e: PointerEvent) => void) | null = null;
47
- private _onPointerMove: ((e: PointerEvent) => void) | null = null;
48
- private _onPointerUp: ((e: PointerEvent) => void) | null = null;
49
-
50
- attach(
51
- element: HTMLElement,
52
- resolve: ZoomTargetResolver,
53
- onUpdate: () => void,
54
- ): void {
55
- this.detach();
56
- this._element = element;
57
- this._resolve = resolve;
58
- this._onUpdate = onUpdate;
59
-
60
- this._onWheel = (e: WheelEvent) => {
61
- const rect = element.getBoundingClientRect();
62
- const mouseX = e.clientX - rect.left;
63
- const mouseY = e.clientY - rect.top;
64
- const target = resolve(mouseX, mouseY);
65
- if (!target) {
66
- return;
67
- }
68
-
69
- e.preventDefault();
70
- applyWheel(target, mouseX, mouseY, e.deltaY);
71
- onUpdate();
72
- };
73
-
74
- this._onPointerDown = (e: PointerEvent) => {
75
- const rect = element.getBoundingClientRect();
76
- const mouseX = e.clientX - rect.left;
77
- const mouseY = e.clientY - rect.top;
78
- const target = resolve(mouseX, mouseY);
79
- if (!target) {
80
- return;
81
- }
82
-
83
- this._pointerDown = true;
84
- this._pointerTarget = target;
85
- this._lastPointerX = e.clientX;
86
- this._lastPointerY = e.clientY;
87
- element.setPointerCapture(e.pointerId);
88
- };
89
-
90
- this._onPointerMove = (e: PointerEvent) => {
91
- if (!this._pointerDown || !this._pointerTarget) {
92
- return;
93
- }
94
-
95
- const dx = e.clientX - this._lastPointerX;
96
- const dy = e.clientY - this._lastPointerY;
97
- this._lastPointerX = e.clientX;
98
- this._lastPointerY = e.clientY;
99
- applyPan(this._pointerTarget, dx, dy);
100
- onUpdate();
101
- };
102
-
103
- this._onPointerUp = () => {
104
- this._pointerDown = false;
105
- this._pointerTarget = null;
106
- };
107
-
108
- element.addEventListener("wheel", this._onWheel, { passive: false });
109
- element.addEventListener("pointerdown", this._onPointerDown);
110
- element.addEventListener("pointermove", this._onPointerMove);
111
- element.addEventListener("pointerup", this._onPointerUp);
112
- }
113
-
114
- detach(): void {
115
- if (this._element) {
116
- if (this._onWheel) {
117
- this._element.removeEventListener("wheel", this._onWheel);
118
- }
119
-
120
- if (this._onPointerDown) {
121
- this._element.removeEventListener(
122
- "pointerdown",
123
- this._onPointerDown,
124
- );
125
- }
126
-
127
- if (this._onPointerMove) {
128
- this._element.removeEventListener(
129
- "pointermove",
130
- this._onPointerMove,
131
- );
132
- }
133
-
134
- if (this._onPointerUp) {
135
- this._element.removeEventListener(
136
- "pointerup",
137
- this._onPointerUp,
138
- );
139
- }
140
- }
141
-
142
- this._element = null;
143
- this._resolve = null;
144
- this._onUpdate = null;
145
- this._pointerDown = false;
146
- this._pointerTarget = null;
147
- }
148
- }
149
-
150
- /**
151
- * Apply a wheel-zoom delta to the resolved target. Exported for
152
- * re-use inside the worker, where the renderer dispatches interaction
153
- * events forwarded from the host's `RawEventForwarder` instead of
154
- * receiving DOM events directly.
29
+ * Apply a wheel-zoom delta to the resolved target. The worker
30
+ * dispatches interaction events forwarded from the host's
31
+ * `RawEventForwarder` and calls this directly.
155
32
  */
156
33
  export function applyWheel(
157
34
  target: ZoomTarget,
@@ -18,6 +18,7 @@ import { TileLoader, tileKey } from "./tile-loader";
18
18
  import type { TileSource } from "./tile-source";
19
19
  import tileVert from "../shaders/tile.vert.glsl";
20
20
  import tileFrag from "../shaders/tile.frag.glsl";
21
+ import { compileProgram } from "../webgl/program-cache";
21
22
 
22
23
  type GL = WebGL2RenderingContext | WebGLRenderingContext;
23
24
 
@@ -344,24 +345,23 @@ export class TileLayer {
344
345
  }
345
346
 
346
347
  const gl = glManager.gl;
347
- const program = glManager.shaders.getOrCreate(
348
+ this._program = compileProgram<TileProgramCache>(
349
+ glManager,
348
350
  "map-tile",
349
351
  tileVert,
350
352
  tileFrag,
353
+ [
354
+ "u_projection",
355
+ "u_extent_min",
356
+ "u_extent_max",
357
+ "u_uv_min",
358
+ "u_uv_max",
359
+ "u_tile",
360
+ "u_alpha",
361
+ ],
362
+ ["a_corner"],
351
363
  );
352
364
 
353
- this._program = {
354
- program,
355
- u_projection: gl.getUniformLocation(program, "u_projection"),
356
- u_extent_min: gl.getUniformLocation(program, "u_extent_min"),
357
- u_extent_max: gl.getUniformLocation(program, "u_extent_max"),
358
- u_uv_min: gl.getUniformLocation(program, "u_uv_min"),
359
- u_uv_max: gl.getUniformLocation(program, "u_uv_max"),
360
- u_tile: gl.getUniformLocation(program, "u_tile"),
361
- u_alpha: gl.getUniformLocation(program, "u_alpha"),
362
- a_corner: gl.getAttribLocation(program, "a_corner"),
363
- };
364
-
365
365
  const buf = gl.createBuffer();
366
366
  if (!buf) {
367
367
  return;
@@ -38,173 +38,85 @@ import { RENDER_BLIT_MODE } from "../config";
38
38
  const FACET_CONFIG_DEFAULTS: FacetConfig = { ...DEFAULT_FACET_CONFIG };
39
39
 
40
40
  /**
41
- * Build a UI control spec for one plugin-config field. Mirrors the
42
- * shape `column_config_schema` already returns (datagrid). Numeric
43
- * fields get a `Number` control with min/max clamps; fractions get a
44
- * 0..1 range; enums + booleans pass through their variant list.
41
+ * Static UI-control spec per `plugin_config` field. Mirrors the shape
42
+ * `column_config_schema` already returns (datagrid). The runtime default
43
+ * is sourced separately from the chart-type-effective defaults at
44
+ * `fieldSpec` call time so per-chart overrides like
45
+ * `include_zero=true` for Y Bar / Y Area / X Bar surface in the UI.
45
46
  */
47
+ type FieldSpec =
48
+ | { kind: "Bool" }
49
+ | {
50
+ kind: "Enum";
51
+ variants: ReadonlyArray<{ value: string; label: string }>;
52
+ }
53
+ | { kind: "Number"; min: number; max: number; step?: number };
54
+
55
+ const FIELD_SCHEMAS: Record<PluginConfigField, FieldSpec> = {
56
+ auto_alt_y_axis: { kind: "Bool" },
57
+ include_zero: { kind: "Bool" },
58
+ domain_mode: {
59
+ kind: "Enum",
60
+ variants: [
61
+ { value: "fit", label: "Fit" },
62
+ { value: "expand", label: "Expand" },
63
+ ],
64
+ },
65
+ facet_mode: {
66
+ kind: "Enum",
67
+ variants: [
68
+ { value: "grid", label: "Grid" },
69
+ { value: "overlay", label: "Overlay" },
70
+ ],
71
+ },
72
+ facet_zoom_mode: {
73
+ kind: "Enum",
74
+ variants: [
75
+ { value: "shared", label: "Shared" },
76
+ { value: "independent", label: "Independent" },
77
+ ],
78
+ },
79
+ series_zoom_mode: {
80
+ kind: "Enum",
81
+ variants: [
82
+ { value: "dynamic", label: "Dynamic" },
83
+ { value: "fixed", label: "Fixed" },
84
+ ],
85
+ },
86
+ line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 16 },
87
+ point_size_px: { kind: "Number", min: 1, max: 32 },
88
+ band_inner_frac: { kind: "Number", min: 0.1, max: 1, step: 0.01 },
89
+ bar_inner_pad: { kind: "Number", min: 0, max: 0.9, step: 0.01 },
90
+ wick_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 },
91
+ ohlc_line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 },
92
+ gradient_radius_px: { kind: "Number", min: 2, step: 1, max: 256 },
93
+ gradient_intensity: { kind: "Number", min: 0.05, step: 0.05, max: 4 },
94
+ gradient_heat_max: { kind: "Number", min: 0.1, step: 0.1, max: 64 },
95
+ gradient_color_mode: {
96
+ kind: "Enum",
97
+ variants: [
98
+ { value: "mean", label: "Mean (density-weighted)" },
99
+ { value: "density", label: "Density only" },
100
+ { value: "extreme", label: "Extremes" },
101
+ { value: "signed", label: "Signed sum" },
102
+ ],
103
+ },
104
+ map_tile_provider: {
105
+ kind: "Enum",
106
+ variants: [
107
+ { value: "carto-positron", label: "Light (Positron)" },
108
+ { value: "carto-dark-matter", label: "Dark Matter" },
109
+ { value: "carto-voyager", label: "Voyager" },
110
+ ],
111
+ },
112
+ map_tile_alpha: { kind: "Number", min: 0, max: 1, step: 0.05 },
113
+ };
114
+
46
115
  function fieldSpec(
47
116
  key: PluginConfigField,
48
117
  defaults: PluginConfig,
49
118
  ): Record<string, unknown> & { kind: string } {
50
- switch (key) {
51
- case "auto_alt_y_axis":
52
- return { kind: "Bool", key, default: defaults.auto_alt_y_axis };
53
- case "include_zero":
54
- return { kind: "Bool", key, default: defaults.include_zero };
55
- case "domain_mode":
56
- return {
57
- kind: "Enum",
58
- key,
59
- default: defaults.domain_mode,
60
- variants: [
61
- { value: "fit", label: "Fit" },
62
- { value: "expand", label: "Expand" },
63
- ],
64
- };
65
- case "facet_mode":
66
- return {
67
- kind: "Enum",
68
- key,
69
- default: DEFAULT_PLUGIN_CONFIG.facet_mode,
70
- variants: [
71
- { value: "grid", label: "Grid" },
72
- { value: "overlay", label: "Overlay" },
73
- ],
74
- };
75
- case "facet_zoom_mode":
76
- return {
77
- kind: "Enum",
78
- key,
79
- default: DEFAULT_PLUGIN_CONFIG.facet_zoom_mode,
80
- variants: [
81
- { value: "shared", label: "Shared" },
82
- { value: "independent", label: "Independent" },
83
- ],
84
- };
85
- case "series_zoom_mode":
86
- return {
87
- kind: "Enum",
88
- key,
89
- default: DEFAULT_PLUGIN_CONFIG.series_zoom_mode,
90
- variants: [
91
- { value: "dynamic", label: "Dynamic" },
92
- { value: "fixed", label: "Fixed" },
93
- ],
94
- };
95
- case "line_width_px":
96
- return {
97
- kind: "Number",
98
- key,
99
- default: DEFAULT_PLUGIN_CONFIG.line_width_px,
100
- min: 0.5,
101
- step: 0.5,
102
- max: 16,
103
- };
104
- case "point_size_px":
105
- return {
106
- kind: "Number",
107
- key,
108
- default: DEFAULT_PLUGIN_CONFIG.point_size_px,
109
- min: 1,
110
- max: 32,
111
- };
112
- case "band_inner_frac":
113
- return {
114
- kind: "Number",
115
- key,
116
- default: DEFAULT_PLUGIN_CONFIG.band_inner_frac,
117
- min: 0.1,
118
- max: 1,
119
- step: 0.01,
120
- };
121
- case "bar_inner_pad":
122
- return {
123
- kind: "Number",
124
- key,
125
- default: DEFAULT_PLUGIN_CONFIG.bar_inner_pad,
126
- min: 0,
127
- max: 0.9,
128
- step: 0.01,
129
- };
130
- case "wick_width_px":
131
- return {
132
- kind: "Number",
133
- key,
134
- default: DEFAULT_PLUGIN_CONFIG.wick_width_px,
135
- min: 0.5,
136
- step: 0.5,
137
- max: 8,
138
- };
139
- case "ohlc_line_width_px":
140
- return {
141
- kind: "Number",
142
- key,
143
- default: DEFAULT_PLUGIN_CONFIG.ohlc_line_width_px,
144
- min: 0.5,
145
- step: 0.5,
146
- max: 8,
147
- };
148
- case "gradient_radius_px":
149
- return {
150
- kind: "Number",
151
- key,
152
- default: DEFAULT_PLUGIN_CONFIG.gradient_radius_px,
153
- min: 2,
154
- step: 1,
155
- max: 256,
156
- };
157
- case "gradient_intensity":
158
- return {
159
- kind: "Number",
160
- key,
161
- default: DEFAULT_PLUGIN_CONFIG.gradient_intensity,
162
- min: 0.05,
163
- step: 0.05,
164
- max: 4,
165
- };
166
- case "gradient_heat_max":
167
- return {
168
- kind: "Number",
169
- key,
170
- default: DEFAULT_PLUGIN_CONFIG.gradient_heat_max,
171
- min: 0.1,
172
- step: 0.1,
173
- max: 64,
174
- };
175
- case "gradient_color_mode":
176
- return {
177
- kind: "Enum",
178
- key,
179
- default: DEFAULT_PLUGIN_CONFIG.gradient_color_mode,
180
- variants: [
181
- { value: "mean", label: "Mean (density-weighted)" },
182
- { value: "density", label: "Density only" },
183
- { value: "extreme", label: "Extremes" },
184
- { value: "signed", label: "Signed sum" },
185
- ],
186
- };
187
- case "map_tile_provider":
188
- return {
189
- kind: "Enum",
190
- key,
191
- default: DEFAULT_PLUGIN_CONFIG.map_tile_provider,
192
- variants: [
193
- { value: "carto-positron", label: "Light (Positron)" },
194
- { value: "carto-dark-matter", label: "Dark Matter" },
195
- { value: "carto-voyager", label: "Voyager" },
196
- ],
197
- };
198
- case "map_tile_alpha":
199
- return {
200
- kind: "Number",
201
- key,
202
- default: DEFAULT_PLUGIN_CONFIG.map_tile_alpha,
203
- min: 0,
204
- max: 1,
205
- step: 0.05,
206
- };
207
- }
119
+ return { ...FIELD_SCHEMAS[key], key, default: defaults[key] };
208
120
  }
209
121
 
210
122
  const GLOBAL_STYLES = (() => {
@@ -213,6 +125,22 @@ const GLOBAL_STYLES = (() => {
213
125
  return [sheet];
214
126
  })();
215
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
+
216
144
  export class HTMLPerspectiveViewerWebGLPluginElement
217
145
  extends HTMLElement
218
146
  implements IPerspectiveViewerPlugin
@@ -227,7 +155,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement
227
155
  private _rendererPromise: Promise<RendererTransport> | null = null;
228
156
  private _rawEventForwarder: RawEventForwarder | null = null;
229
157
  private _generation = 0;
230
- private _renderBlitMode: "direct" | "blit" = RENDER_BLIT_MODE;
231
158
  private _resetClickAbort: AbortController | null = null;
232
159
 
233
160
  /**
@@ -345,13 +272,41 @@ export class HTMLPerspectiveViewerWebGLPluginElement
345
272
  return this._rendererPromise;
346
273
  }
347
274
 
348
- this._rendererPromise = this._buildRenderer(view).then((r) => {
349
- this._renderer = r;
350
- this._setupInteraction(r);
351
- return r;
352
- });
275
+ // `_buildRenderer` is async — it awaits `getClient`, `getTable`,
276
+ // and a full worker handshake — so a `disconnectedCallback`
277
+ // (toggle / chart-type switch) frequently lands *before* this
278
+ // `.then` runs. At that point `this._renderer` is still null,
279
+ // so `delete()`'s `if (this._renderer)` teardown is skipped and
280
+ // the freshly-built transport (and its WebGL context) would be
281
+ // assigned to a detached element and leak — one context per
282
+ // raced toggle, until the browser evicts the oldest.
283
+ //
284
+ // `delete()` sets `_rendererPromise = null`, so promise identity
285
+ // is the dispose signal: if `this._rendererPromise` no longer
286
+ // points at *this* build when it resolves, the element was
287
+ // 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
290
+ // right token: a rapid draw→update must NOT tear down the
291
+ // single in-flight build it shares via this memoized promise.
292
+ const p: Promise<RendererTransport> = this._buildRenderer(view).then(
293
+ (r) => {
294
+ if (this._rendererPromise !== p) {
295
+ r.destroy();
296
+ throw new Error("renderer disposed during init");
297
+ }
298
+
299
+ this._renderer = r;
300
+ this._setupInteraction(r);
301
+ return r;
302
+ },
303
+ );
353
304
 
354
- return this._rendererPromise;
305
+ // Swallow the dispose rejection so it doesn't surface as an
306
+ // unhandled rejection; `_drawImpl` catches it and bails.
307
+ p.catch(() => {});
308
+ this._rendererPromise = p;
309
+ return p;
355
310
  }
356
311
 
357
312
  /**
@@ -438,15 +393,28 @@ export class HTMLPerspectiveViewerWebGLPluginElement
438
393
  },
439
394
  pluginConfig: this._pluginConfig,
440
395
  defaultChartType: this._chartType.default_chart_type,
441
- renderBlitMode: this._renderBlitMode,
396
+ renderBlitMode: BLIT_MODE,
442
397
  });
443
398
 
444
399
  return transport;
445
400
  }
446
401
 
447
- setBlitMode(mode: "direct" | "blit") {
448
- console.assert(this._initialized, "Already initialized");
449
- this._renderBlitMode = mode;
402
+ /**
403
+ * Select the GL presentation strategy for *all* chart-type plugins
404
+ * in this renderer. Static + process-global: the value is shared by
405
+ * every per-chart-type subclass, so it must be set once before the
406
+ * charts that should use it build their renderers (a renderer reads
407
+ * {@link BLIT_MODE} at construction in `_buildRenderer`; charts
408
+ * already built keep their mode until torn down and rebuilt).
409
+ *
410
+ * - `"direct"` — each chart owns a GL context 1:1 with its visible
411
+ * canvas (lowest latency; bounded by the browser's ~16-context
412
+ * cap).
413
+ * - `"blit"` — charts render off-screen and share a pool of GL
414
+ * contexts ([webgl/context-pool.ts]), so a page can exceed the cap.
415
+ */
416
+ static setBlitMode(mode: "direct" | "blit") {
417
+ BLIT_MODE = mode;
450
418
  }
451
419
 
452
420
  get_static_config(): PluginStaticConfig {
@@ -519,6 +487,32 @@ export class HTMLPerspectiveViewerWebGLPluginElement
519
487
  default: false,
520
488
  });
521
489
  }
490
+
491
+ // Line / area glyphs can bridge interior nulls by linear
492
+ // interpolation. Bar / scatter ignore the flag.
493
+ const supports_interpolate =
494
+ effective_chart_type === "line" ||
495
+ effective_chart_type === "area";
496
+
497
+ if (supports_interpolate) {
498
+ const variants =
499
+ effective_chart_type === "area"
500
+ ? [
501
+ { value: "skip", label: "Skip" },
502
+ { value: "solid", label: "Solid" },
503
+ ]
504
+ : [
505
+ { value: "skip", label: "Skip" },
506
+ { value: "solid", label: "Solid" },
507
+ { value: "transparent", label: "Transparent" },
508
+ ];
509
+ fields.push({
510
+ kind: "Enum",
511
+ key: "interpolate",
512
+ default: "solid",
513
+ variants,
514
+ });
515
+ }
522
516
  }
523
517
 
524
518
  // Per-column formatter widgets. Surfaced for every chart type so
@@ -561,7 +555,15 @@ export class HTMLPerspectiveViewerWebGLPluginElement
561
555
 
562
556
  private async _drawImpl(view: View): Promise<void> {
563
557
  const gen = ++this._generation;
564
- const renderer = await this._ensureRenderer(view);
558
+ let renderer: RendererTransport;
559
+ try {
560
+ renderer = await this._ensureRenderer(view);
561
+ } catch {
562
+ // Renderer was disposed mid-init (element disconnected
563
+ // during the async build) — nothing to draw.
564
+ return;
565
+ }
566
+
565
567
  if (this._generation !== gen) {
566
568
  return;
567
569
  }
@@ -599,28 +601,6 @@ export class HTMLPerspectiveViewerWebGLPluginElement
599
601
  return 5;
600
602
  }
601
603
 
602
- save() {
603
- const state: any = {};
604
- const zoom = this._renderer?.saveZoom();
605
- if (zoom) {
606
- state.zoom = zoom;
607
- }
608
-
609
- // Only emit the keys this chart actually consumes.
610
- const cfg: Partial<PluginConfig> = {};
611
- for (const key of this._chartType.applicable_plugin_fields) {
612
- // `key` is `PluginConfigField` = `keyof PluginConfig`, so this
613
- // indexed assignment is type-safe without a cast.
614
- (cfg[key] as PluginConfig[typeof key]) = this._pluginConfig[key];
615
- }
616
-
617
- if (Object.keys(cfg).length > 0) {
618
- state.plugin_config = cfg;
619
- }
620
-
621
- return state;
622
- }
623
-
624
604
  async render(view: View): Promise<Blob> {
625
605
  await this._ensureRenderer(view);
626
606
  await this.draw(view);