@perspective-dev/viewer-charts 5.3.0 → 5.4.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 (92) 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/cartesian/cartesian.d.ts +6 -0
  7. package/dist/esm/charts/cartesian/glyph.d.ts +2 -1
  8. package/dist/esm/charts/cartesian/glyphs/density.d.ts +2 -1
  9. package/dist/esm/charts/cartesian/glyphs/lines.d.ts +2 -1
  10. package/dist/esm/charts/cartesian/glyphs/points.d.ts +2 -1
  11. package/dist/esm/charts/cartesian/tooltip-lines.d.ts +1 -1
  12. package/dist/esm/charts/chart-base.d.ts +2 -0
  13. package/dist/esm/charts/chart.d.ts +77 -6
  14. package/dist/esm/charts/common/draw-tooltip-box.d.ts +2 -1
  15. package/dist/esm/charts/common/tree-interact.d.ts +1 -1
  16. package/dist/esm/charts/series/series-build.d.ts +7 -0
  17. package/dist/esm/charts/series/series-interact.d.ts +1 -1
  18. package/dist/esm/charts/series/series.d.ts +6 -0
  19. package/dist/esm/index.d.ts +2 -0
  20. package/dist/esm/interaction/axis-indicators.d.ts +11 -0
  21. package/dist/esm/interaction/host-sink-dom.d.ts +3 -2
  22. package/dist/esm/interaction/host-sink-message.d.ts +3 -2
  23. package/dist/esm/interaction/lazy-tooltip.d.ts +2 -2
  24. package/dist/esm/interaction/legend-controller.d.ts +246 -0
  25. package/dist/esm/interaction/tooltip-controller.d.ts +20 -95
  26. package/dist/esm/interaction/tooltip-grid.d.ts +33 -0
  27. package/dist/esm/layout/facet-grid.d.ts +6 -0
  28. package/dist/esm/layout/ticks.d.ts +1 -0
  29. package/dist/esm/map/tile-source.d.ts +77 -8
  30. package/dist/esm/perspective-viewer-charts.js +1 -2
  31. package/dist/esm/perspective-viewer-charts.js.map +4 -4
  32. package/dist/esm/plugin/charts.d.ts +2 -0
  33. package/dist/esm/plugin/format-defaults.d.ts +9 -0
  34. package/dist/esm/plugin/plugin.d.ts +20 -0
  35. package/dist/esm/theme/gradient.d.ts +1 -0
  36. package/dist/esm/transport/protocol.d.ts +45 -3
  37. package/dist/esm/transport/renderer-transport.d.ts +3 -0
  38. package/dist/esm/worker/renderer.worker.d.ts +19 -1
  39. package/package.json +6 -6
  40. package/src/css/perspective-viewer-charts.css +0 -1
  41. package/src/ts/axis/bar-axis.ts +2 -7
  42. package/src/ts/axis/legend.ts +351 -24
  43. package/src/ts/axis/map-ticks.ts +94 -0
  44. package/src/ts/axis/numeric-axis.ts +2 -9
  45. package/src/ts/charts/candlestick/candlestick-interact.ts +46 -40
  46. package/src/ts/charts/candlestick/candlestick-render.ts +73 -10
  47. package/src/ts/charts/cartesian/cartesian-interact.ts +9 -3
  48. package/src/ts/charts/cartesian/cartesian-render.ts +369 -75
  49. package/src/ts/charts/cartesian/cartesian.ts +9 -0
  50. package/src/ts/charts/cartesian/glyph.ts +6 -2
  51. package/src/ts/charts/cartesian/glyphs/density.ts +2 -2
  52. package/src/ts/charts/cartesian/glyphs/lines.ts +15 -11
  53. package/src/ts/charts/cartesian/glyphs/points.ts +9 -5
  54. package/src/ts/charts/cartesian/tooltip-lines.ts +8 -8
  55. package/src/ts/charts/chart-base.ts +34 -32
  56. package/src/ts/charts/chart.ts +106 -7
  57. package/src/ts/charts/common/draw-tooltip-box.ts +13 -30
  58. package/src/ts/charts/common/tree-chrome.ts +102 -23
  59. package/src/ts/charts/common/tree-interact.ts +18 -17
  60. package/src/ts/charts/heatmap/heatmap-interact.ts +65 -7
  61. package/src/ts/charts/heatmap/heatmap-render.ts +100 -24
  62. package/src/ts/charts/map/map.ts +3 -5
  63. package/src/ts/charts/series/series-build.ts +14 -2
  64. package/src/ts/charts/series/series-interact.ts +12 -11
  65. package/src/ts/charts/series/series-render.ts +305 -132
  66. package/src/ts/charts/series/series.ts +10 -0
  67. package/src/ts/charts/sunburst/sunburst-render.ts +7 -1
  68. package/src/ts/charts/treemap/treemap-render.ts +7 -1
  69. package/src/ts/data/view-reader.ts +1 -4
  70. package/src/ts/index.ts +2 -0
  71. package/src/ts/interaction/axis-indicators.ts +137 -0
  72. package/src/ts/interaction/host-sink-dom.ts +66 -2
  73. package/src/ts/interaction/host-sink-message.ts +8 -2
  74. package/src/ts/interaction/lazy-tooltip.ts +5 -5
  75. package/src/ts/interaction/legend-controller.ts +1084 -0
  76. package/src/ts/interaction/tooltip-controller.ts +43 -131
  77. package/src/ts/interaction/tooltip-grid.ts +187 -0
  78. package/src/ts/layout/facet-grid.ts +8 -1
  79. package/src/ts/layout/ticks.ts +12 -0
  80. package/src/ts/map/tile-loader.ts +16 -1
  81. package/src/ts/map/tile-source.ts +199 -53
  82. package/src/ts/map/tile-sources.json +18 -0
  83. package/src/ts/plugin/charts.ts +40 -6
  84. package/src/ts/plugin/format-defaults.ts +37 -0
  85. package/src/ts/plugin/plugin.ts +236 -67
  86. package/src/ts/shaders/line.vert.glsl +5 -2
  87. package/src/ts/shaders/scatter.vert.glsl +0 -7
  88. package/src/ts/theme/gradient.ts +24 -0
  89. package/src/ts/transport/protocol.ts +46 -2
  90. package/src/ts/transport/renderer-transport.ts +83 -4
  91. package/src/ts/worker/dispatch.ts +6 -2
  92. package/src/ts/worker/renderer.worker.ts +78 -2
@@ -17,7 +17,12 @@ import type {
17
17
  IPerspectiveViewerPlugin,
18
18
  PluginStaticConfig,
19
19
  } from "@perspective-dev/viewer";
20
- import { ChartTypeConfig, PluginConfigField } from "./charts";
20
+ import {
21
+ ChartTypeConfig,
22
+ LEGEND_FIELDS,
23
+ TOOLTIP_FIELDS,
24
+ PluginConfigField,
25
+ } from "./charts";
21
26
  import style from "../../css/perspective-viewer-charts.css";
22
27
  import {
23
28
  DEFAULT_FACET_CONFIG,
@@ -25,8 +30,14 @@ import {
25
30
  type FacetConfig,
26
31
  type PluginConfig,
27
32
  } from "../charts/chart";
33
+ import {
34
+ CHART_DATE_DEFAULTS,
35
+ CHART_DATETIME_DEFAULTS,
36
+ CHART_NUMBER_DEFAULTS,
37
+ } from "./format-defaults";
28
38
  import { RawEventForwarder } from "../interaction/raw-event-forwarder";
29
39
  import { RendererTransport } from "../transport/renderer-transport";
40
+ import { TILE_SOURCES, type TileSourceSpec } from "../map/tile-source";
30
41
  import { RENDER_BLIT_MODE } from "../config";
31
42
  import { snapshotThemeVars } from "../theme/theme-snapshot";
32
43
  import { resolveThemeFromVars, type Theme } from "../theme/theme";
@@ -42,6 +53,26 @@ import { vec3ToHexColor } from "../utils/css";
42
53
  */
43
54
  const FACET_CONFIG_DEFAULTS: FacetConfig = { ...DEFAULT_FACET_CONFIG };
44
55
 
56
+ /**
57
+ * Register a raster XYZ tile provider at runtime. The provider joins
58
+ * the bundled [map/tile-sources.json] entries in the settings panel's
59
+ * `map_tile_provider` enum and resolves on any map chart from its next
60
+ * config forward onward — the resolved spec rides every outgoing
61
+ * `setPluginConfig` / `init` message alongside the config that names
62
+ * it, so no separate registry-sync operation exists. Re-registering an
63
+ * id replaces it (a changed template drops cached tiles via the
64
+ * content-derived cache identity); a chart currently displaying that
65
+ * id picks the replacement up on its next `restore()`. Throws
66
+ * `TypeError` on a malformed spec. Returns the normalized spec.
67
+ *
68
+ * A key-gated provider is registered with the key embedded in its
69
+ * `template` — keys never enter `plugin_config`, so they never appear
70
+ * in `save()` output.
71
+ */
72
+ export function registerTileSource(spec: unknown): TileSourceSpec {
73
+ return TILE_SOURCES.register(spec);
74
+ }
75
+
45
76
  /**
46
77
  * Static UI-control spec per `plugin_config` field. Mirrors the shape
47
78
  * `column_config_schema` already returns (datagrid). The runtime default
@@ -57,72 +88,157 @@ type FieldSpec =
57
88
  }
58
89
  | { kind: "Number"; min: number; max: number; step?: number };
59
90
 
60
- const FIELD_SCHEMAS: Record<PluginConfigField, FieldSpec> = {
61
- auto_alt_y_axis: { kind: "Bool" },
62
- include_zero: { kind: "Bool" },
63
- domain_mode: {
64
- kind: "Enum",
65
- variants: [
66
- { value: "fit", label: "Fit" },
67
- { value: "expand", label: "Expand" },
68
- ],
69
- },
70
- facet_mode: {
71
- kind: "Enum",
72
- variants: [
73
- { value: "grid", label: "Grid" },
74
- { value: "overlay", label: "Overlay" },
75
- ],
76
- },
77
- facet_zoom_mode: {
78
- kind: "Enum",
79
- variants: [
80
- { value: "shared", label: "Shared" },
81
- { value: "independent", label: "Independent" },
91
+ /**
92
+ * A `FieldSpec` entry may be a thunk when its contents depend on
93
+ * runtime state — `map_tile_provider`'s variants come from the tile-
94
+ * source registry, which grows via `registerTileSource`, so the enum
95
+ * must be resolved per `plugin_config_schema()` call rather than at
96
+ * module init.
97
+ */
98
+ const FIELD_SCHEMAS: Record<PluginConfigField, FieldSpec | (() => FieldSpec)> =
99
+ {
100
+ auto_alt_y_axis: { kind: "Bool" },
101
+ include_zero: { kind: "Bool" },
102
+ domain_mode: {
103
+ kind: "Enum",
104
+ variants: [
105
+ { value: "fit", label: "Fit" },
106
+ { value: "expand", label: "Expand" },
107
+ ],
108
+ },
109
+ facet_mode: {
110
+ kind: "Enum",
111
+ variants: [
112
+ { value: "grid", label: "Grid" },
113
+ { value: "overlay", label: "Overlay" },
114
+ ],
115
+ },
116
+ facet_zoom_mode: {
117
+ kind: "Enum",
118
+ variants: [
119
+ { value: "shared", label: "Shared" },
120
+ { value: "independent", label: "Independent" },
121
+ ],
122
+ },
123
+ series_zoom_mode: {
124
+ kind: "Enum",
125
+ variants: [
126
+ { value: "dynamic", label: "Dynamic" },
127
+ { value: "fixed", label: "Fixed" },
128
+ ],
129
+ },
130
+ line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 16 },
131
+ point_size_px: { kind: "Number", min: 1, max: 32 },
132
+ band_inner_frac: { kind: "Number", min: 0.1, max: 1, step: 0.01 },
133
+ bar_inner_pad: { kind: "Number", min: 0, max: 0.9, step: 0.01 },
134
+ wick_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 },
135
+ ohlc_line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 },
136
+ gradient_radius_px: { kind: "Number", min: 2, step: 1, max: 256 },
137
+ gradient_intensity: { kind: "Number", min: 0.05, step: 0.05, max: 4 },
138
+ gradient_heat_max: { kind: "Number", min: 0.1, step: 0.1, max: 64 },
139
+ gradient_color_mode: {
140
+ kind: "Enum",
141
+ variants: [
142
+ { value: "mean", label: "Mean (density-weighted)" },
143
+ { value: "density", label: "Density only" },
144
+ { value: "extreme", label: "Extremes" },
145
+ { value: "signed", label: "Signed sum" },
146
+ ],
147
+ },
148
+ map_tile_provider: () => ({
149
+ kind: "Enum",
150
+ variants: TILE_SOURCES.list().map((s) => ({
151
+ value: s.id,
152
+ label: s.label,
153
+ })),
154
+ }),
155
+ map_tile_alpha: { kind: "Number", min: 0, max: 1, step: 0.05 },
156
+ numeric_axes: { kind: "Bool" },
157
+ legend_mode: {
158
+ kind: "Enum",
159
+ variants: [
160
+ { value: "auto", label: "Auto" },
161
+ { value: "sidebar", label: "Sidebar" },
162
+ { value: "none", label: "None" },
163
+ { value: "floating", label: "Floating" },
164
+ ],
165
+ },
166
+ legend_size_mode: {
167
+ kind: "Enum",
168
+ variants: [
169
+ { value: "auto", label: "Auto" },
170
+ { value: "fixed", label: "Fixed" },
171
+ ],
172
+ },
173
+ // 0 = auto (the chart family's historical gutter width).
174
+ legend_width_px: { kind: "Number", min: 0, max: 512, step: 1 },
175
+ legend_height_px: { kind: "Number", min: 48, max: 1024, step: 1 },
176
+ legend_anchor: {
177
+ kind: "Enum",
178
+ variants: [
179
+ { value: "top-right", label: "Top Right" },
180
+ { value: "top-left", label: "Top Left" },
181
+ { value: "bottom-right", label: "Bottom Right" },
182
+ { value: "bottom-left", label: "Bottom Left" },
183
+ ],
184
+ },
185
+ legend_x: { kind: "Number", min: 0, max: 1, step: 0.01 },
186
+ legend_y: { kind: "Number", min: 0, max: 1, step: 0.01 },
187
+ legend_opacity: { kind: "Number", min: 0, max: 1, step: 0.05 },
188
+ tooltip_max_column_px: { kind: "Number", min: 40, max: 512, step: 1 },
189
+ tooltip_opacity: { kind: "Number", min: 0, max: 1, step: 0.05 },
190
+ };
191
+
192
+ function fieldSpec(
193
+ key: PluginConfigField,
194
+ defaults: PluginConfig,
195
+ ): Record<string, unknown> & { kind: string } {
196
+ const entry = FIELD_SCHEMAS[key];
197
+ const spec = typeof entry === "function" ? entry() : entry;
198
+ return { ...spec, key, default: defaults[key] };
199
+ }
200
+
201
+ const PLUGIN_FIELD_GROUPS: ReadonlyArray<{
202
+ key: string;
203
+ fields: readonly PluginConfigField[];
204
+ }> = [
205
+ {
206
+ key: "axes",
207
+ fields: [
208
+ "auto_alt_y_axis",
209
+ "include_zero",
210
+ "domain_mode",
211
+ "numeric_axes",
82
212
  ],
83
213
  },
84
- series_zoom_mode: {
85
- kind: "Enum",
86
- variants: [
87
- { value: "dynamic", label: "Dynamic" },
88
- { value: "fixed", label: "Fixed" },
89
- ],
214
+ {
215
+ key: "facets",
216
+ fields: ["facet_mode", "facet_zoom_mode", "series_zoom_mode"],
90
217
  },
91
- line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 16 },
92
- point_size_px: { kind: "Number", min: 1, max: 32 },
93
- band_inner_frac: { kind: "Number", min: 0.1, max: 1, step: 0.01 },
94
- bar_inner_pad: { kind: "Number", min: 0, max: 0.9, step: 0.01 },
95
- wick_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 },
96
- ohlc_line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 },
97
- gradient_radius_px: { kind: "Number", min: 2, step: 1, max: 256 },
98
- gradient_intensity: { kind: "Number", min: 0.05, step: 0.05, max: 4 },
99
- gradient_heat_max: { kind: "Number", min: 0.1, step: 0.1, max: 64 },
100
- gradient_color_mode: {
101
- kind: "Enum",
102
- variants: [
103
- { value: "mean", label: "Mean (density-weighted)" },
104
- { value: "density", label: "Density only" },
105
- { value: "extreme", label: "Extremes" },
106
- { value: "signed", label: "Signed sum" },
218
+ {
219
+ key: "glyph",
220
+ fields: [
221
+ "line_width_px",
222
+ "point_size_px",
223
+ "band_inner_frac",
224
+ "bar_inner_pad",
225
+ "wick_width_px",
226
+ "ohlc_line_width_px",
107
227
  ],
108
228
  },
109
- map_tile_provider: {
110
- kind: "Enum",
111
- variants: [
112
- { value: "carto-positron", label: "Light (Positron)" },
113
- { value: "carto-dark-matter", label: "Dark Matter" },
114
- { value: "carto-voyager", label: "Voyager" },
229
+ {
230
+ key: "density",
231
+ fields: [
232
+ "gradient_color_mode",
233
+ "gradient_radius_px",
234
+ "gradient_intensity",
235
+ "gradient_heat_max",
115
236
  ],
116
237
  },
117
- map_tile_alpha: { kind: "Number", min: 0, max: 1, step: 0.05 },
118
- };
119
-
120
- function fieldSpec(
121
- key: PluginConfigField,
122
- defaults: PluginConfig,
123
- ): Record<string, unknown> & { kind: string } {
124
- return { ...FIELD_SCHEMAS[key], key, default: defaults[key] };
125
- }
238
+ { key: "basemap", fields: ["map_tile_provider", "map_tile_alpha"] },
239
+ { key: "legend", fields: LEGEND_FIELDS },
240
+ { key: "tooltip", fields: TOOLTIP_FIELDS },
241
+ ];
126
242
 
127
243
  const GLOBAL_STYLES = (() => {
128
244
  const sheet = new CSSStyleSheet();
@@ -399,6 +515,19 @@ export class HTMLPerspectiveViewerWebGLPluginElement
399
515
  zoomControls.classList.toggle("visible", !isDefault);
400
516
  }
401
517
  },
518
+ onPluginConfigDelta: (fields) => {
519
+ this._pluginConfig = { ...this._pluginConfig, ...fields };
520
+ const host = this
521
+ .parentElement as HTMLPerspectiveViewerElement | null;
522
+ (
523
+ host?.restore(
524
+ { plugin_config: fields },
525
+ panel ? { panel } : undefined,
526
+ ) as Promise<void> | undefined
527
+ )?.catch((e: unknown) => {
528
+ console.error("legend config persistence failed", e);
529
+ });
530
+ },
402
531
  });
403
532
 
404
533
  await transport.init({
@@ -437,6 +566,14 @@ export class HTMLPerspectiveViewerWebGLPluginElement
437
566
  BLIT_MODE = mode;
438
567
  }
439
568
 
569
+ static registerTileSource(spec: unknown): TileSourceSpec {
570
+ return registerTileSource(spec);
571
+ }
572
+
573
+ static tileSources(): readonly TileSourceSpec[] {
574
+ return TILE_SOURCES.list();
575
+ }
576
+
440
577
  get_static_config(): PluginStaticConfig {
441
578
  return {
442
579
  name: this._chartType.name,
@@ -592,9 +729,20 @@ export class HTMLPerspectiveViewerWebGLPluginElement
592
729
  // Per-column formatter widgets. Surfaced for every chart type so
593
730
  // axes / tooltips / legends honor the user's format choice.
594
731
  if (column_type === "integer" || column_type === "float") {
595
- fields.push({ kind: "NumberFormat" });
596
- } else if (column_type === "date" || column_type === "datetime") {
597
- fields.push({ kind: "DatetimeFormat" });
732
+ fields.push({
733
+ kind: "NumberFormat",
734
+ default: CHART_NUMBER_DEFAULTS,
735
+ });
736
+ } else if (column_type === "datetime") {
737
+ fields.push({
738
+ kind: "DatetimeFormat",
739
+ default: CHART_DATETIME_DEFAULTS,
740
+ });
741
+ } else if (column_type === "date") {
742
+ fields.push({
743
+ kind: "DatetimeFormat",
744
+ default: CHART_DATE_DEFAULTS,
745
+ });
598
746
  }
599
747
 
600
748
  return { fields };
@@ -605,11 +753,32 @@ export class HTMLPerspectiveViewerWebGLPluginElement
605
753
  group_rollup_mode?: string;
606
754
  }) {
607
755
  const defaults = this._effectiveDefaults();
608
- const fields = this._chartType.applicable_plugin_fields.map((key) =>
609
- fieldSpec(key, defaults),
610
- );
756
+ const applicable = this._chartType.applicable_plugin_fields;
757
+ const present = new Set(applicable);
758
+
759
+ const grouped = new Set<PluginConfigField>();
760
+ const groups: Array<Record<string, unknown> & { kind: string }> = [];
761
+ for (const group of PLUGIN_FIELD_GROUPS) {
762
+ const members = group.fields.filter((k) => present.has(k));
763
+ if (members.length >= 1) {
764
+ for (const k of members) {
765
+ grouped.add(k);
766
+ }
611
767
 
612
- return { fields };
768
+ groups.push({
769
+ kind: "Group",
770
+ key: group.key,
771
+ fields: members.map((k) => fieldSpec(k, defaults)),
772
+ });
773
+ }
774
+ }
775
+
776
+ const fields: Array<Record<string, unknown> & { kind: string }> =
777
+ applicable
778
+ .filter((k) => !grouped.has(k))
779
+ .map((k) => fieldSpec(k, defaults));
780
+
781
+ return { fields: [...fields, ...groups] };
613
782
  }
614
783
 
615
784
  private _resolvedTheme(): Theme {
@@ -21,8 +21,11 @@
21
21
  // `a_color_start` / `a_color_end` carry the segment endpoints' raw
22
22
  // color values (numeric data value for gradient, dictionary index for
23
23
  // categorical). The gradient LUT is sampled using the same mapping the
24
- // scatter shader uses — `(v - cmin) / (cmax - cmin)` with sign-aware
25
- // handling for zero-crossing domains. The two endpoints' colors are
24
+ // scatter shader uses — `u_color_range` arrives PRE-SHAPED by the host:
25
+ // the symmetric sign-pivot range (`colorRangePivot`) for numeric
26
+ // columns, so linear normalization matches the CPU `colorValueToT`
27
+ // used by the legend / heatmap / tree charts; raw `[0, N-1]` extents
28
+ // for categorical index domains. The two endpoints' colors are
26
29
  // averaged so the segment reads as a single chord in gradient space.
27
30
  attribute vec2 a_start;
28
31
  attribute vec2 a_end;
@@ -47,13 +47,6 @@ void main() {
47
47
 
48
48
  v_point_size = gl_PointSize;
49
49
 
50
- // Color-t mapping. Linear across `[cmin, cmax]` for single-sign
51
- // domains (which includes categorical `[0, N-1]` split / string
52
- // indices, so the colors match `interpolatePalette`'s even sampling
53
- // used by the legend). When the domain actually crosses zero we
54
- // switch to sign-aware so the value 0 always lands at the 50% stop
55
- // of the diverging gradient — matching heatmap and the Canvas2D
56
- // tooltip paths.
57
50
  float cmin = u_color_range.x;
58
51
  float cmax = u_color_range.y;
59
52
  if(cmax <= cmin) {
@@ -497,6 +497,30 @@ export function colorValueToT(
497
497
  return t < 0 ? 0 : t > 1 ? 1 : t;
498
498
  }
499
499
 
500
+ export function colorRangePivot(
501
+ colorMin: number,
502
+ colorMax: number,
503
+ ): [number, number] {
504
+ if (!isFinite(colorMin) || !isFinite(colorMax) || colorMin >= colorMax) {
505
+ return [0, 0];
506
+ }
507
+
508
+ let denom: number;
509
+ if (colorMin >= 0) {
510
+ denom = colorMax;
511
+ } else if (colorMax <= 0) {
512
+ denom = -colorMin;
513
+ } else {
514
+ denom = Math.max(-colorMin, colorMax);
515
+ }
516
+
517
+ if (denom <= 0) {
518
+ return [0, 0];
519
+ }
520
+
521
+ return [-denom, denom];
522
+ }
523
+
500
524
  /**
501
525
  * Convert a discrete series palette (from `--psp-charts--series-N--color`)
502
526
  * into a `GradientStop[]` with stops at `i / (N - 1)`. The resulting
@@ -13,6 +13,7 @@
13
13
  import type { FacetConfig, PluginConfig } from "../charts/chart";
14
14
  import type { PerspectiveClickDetail } from "../event-detail";
15
15
  import type { ThemeSnapshot } from "../theme/theme";
16
+ import type { TileSourceSpec } from "../map/tile-source";
16
17
  import type { ViewConfig } from "@perspective-dev/client";
17
18
 
18
19
  export type { ThemeSnapshot };
@@ -53,6 +54,7 @@ export type WorkerMsg =
53
54
  | SetCursorMsg
54
55
  | UserClickMsg
55
56
  | UserSelectMsg
57
+ | PluginConfigDeltaMsg
56
58
  | LoadAndRenderAckMsg
57
59
  | ResizeAckMsg
58
60
  | FrameBitmapMsg
@@ -185,6 +187,17 @@ export interface InitMsg {
185
187
  columnsConfig?: Record<string, any>;
186
188
  defaultChartType?: string;
187
189
 
190
+ /**
191
+ * Resolved spec for `pluginConfig.map_tile_provider`, when the
192
+ * plugin realm's registry knows the id — see
193
+ * {@link SetPluginConfigMsg.tileSource} for the invariant. Applied
194
+ * to the worker realm's registry before the chart impl is
195
+ * constructed. Bundled [map/tile-sources.json] entries ship inside
196
+ * the worker bundle, so this matters only for runtime-registered
197
+ * providers.
198
+ */
199
+ tileSource?: TileSourceSpec;
200
+
188
201
  /**
189
202
  * Pre-resolved CSS-variable theme snapshot from the host.
190
203
  */
@@ -271,6 +284,18 @@ export interface SetColumnsConfigMsg {
271
284
  export interface SetPluginConfigMsg {
272
285
  kind: "setPluginConfig";
273
286
  cfg: PluginConfig;
287
+
288
+ /**
289
+ * Resolved spec for `cfg.map_tile_provider`, when the plugin
290
+ * realm's registry knows the id. Riding the config keeps the two
291
+ * realms' registries convergent with NO eager mirroring: the
292
+ * worker registers this spec before applying `cfg`, so it can
293
+ * never hold a config whose provider it cannot resolve —
294
+ * regardless of when `registerTileSource` ran relative to
295
+ * renderer construction. Absent for unknown ids (the worker falls
296
+ * back to the default basemap).
297
+ */
298
+ tileSource?: TileSourceSpec;
274
299
  }
275
300
 
276
301
  export interface SetBufferMaxCapacityMsg {
@@ -317,6 +342,7 @@ export interface LoadAndRenderMsg {
317
342
  export interface LoadAndRenderAckMsg {
318
343
  kind: "loadAndRenderAck";
319
344
  msgId: number;
345
+ error?: string;
320
346
  }
321
347
 
322
348
  export interface RedrawMsg {
@@ -417,7 +443,8 @@ export interface SnapshotPngReqMsg {
417
443
  export interface SnapshotPngReplyMsg {
418
444
  kind: "snapshotPngReply";
419
445
  requestId: number;
420
- blob: Blob;
446
+ blob?: Blob;
447
+ error?: string;
421
448
  }
422
449
 
423
450
  export interface DestroyMsg {
@@ -481,7 +508,9 @@ export interface ErrorMsg {
481
508
  */
482
509
  export interface PinTooltipMsg {
483
510
  kind: "pinTooltip";
484
- lines: string[];
511
+
512
+ grid: string[][];
513
+ style: { opacity: number; maxColumnPx: number };
485
514
  pos: { px: number; py: number };
486
515
  bounds: { cssWidth: number; cssHeight: number };
487
516
  }
@@ -501,6 +530,21 @@ export interface SetCursorMsg {
501
530
  cursor: string;
502
531
  }
503
532
 
533
+ /**
534
+ * Renderer → host: a completed legend gesture (sidebar width drag,
535
+ * floating move / resize) produced new values for the legend's
536
+ * `plugin_config` fields. Posted ONCE per gesture, at pointerup — never
537
+ * per pointermove — with only the fields the gesture changed. The host
538
+ * plugin persists them through the viewer's public `restore` surface
539
+ * (a user-gesture echo), which merges the host bucket, refreshes the
540
+ * settings form, and echoes one `setPluginConfig` back with the same
541
+ * values (a no-op by the worker's legend-field equality guard).
542
+ */
543
+ export interface PluginConfigDeltaMsg {
544
+ kind: "pluginConfigDelta";
545
+ fields: Record<string, string | number | boolean>;
546
+ }
547
+
504
548
  /**
505
549
  * Renderer → host: a user click landed on a chart glyph. Host
506
550
  * re-dispatches as `CustomEvent<PerspectiveClickDetail>` on the