@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
@@ -13,10 +13,14 @@
13
13
  import type { Canvas2D, Context2D } from "../charts/canvas-types";
14
14
  import type { PlotLayout } from "../layout/plot-layout";
15
15
  import type { Theme } from "../theme/theme";
16
+ import {
17
+ measureTooltipGrid,
18
+ paintTooltipGrid,
19
+ DEFAULT_TOOLTIP_STYLE,
20
+ type TooltipContent,
21
+ type TooltipStyle,
22
+ } from "./tooltip-grid";
16
23
 
17
- /**
18
- * Minimal positioning input — PlotLayout satisfies this.
19
- */
20
24
  export interface CssBounds {
21
25
  cssWidth: number;
22
26
  cssHeight: number;
@@ -25,7 +29,7 @@ export interface CssBounds {
25
29
  export interface TooltipCallbacks {
26
30
  /**
27
31
  * RAF-throttled mouse position in CSS pixels, relative to the GL
28
- * canvas (host already subtracted `getBoundingClientRect`).
32
+ * canvas.
29
33
  */
30
34
  onHover(mx: number, my: number): void;
31
35
 
@@ -35,8 +39,7 @@ export interface TooltipCallbacks {
35
39
  onLeave(): void;
36
40
 
37
41
  /**
38
- * Fires on click with mouse position. Return true to consume the click
39
- * (skipping the default pin/dismiss flow — used for legend clicks).
42
+ * Fires on click with mouse position.
40
43
  */
41
44
  onClickPre?(mx: number, my: number): boolean;
42
45
 
@@ -46,93 +49,45 @@ export interface TooltipCallbacks {
46
49
  onPin?(mx: number, my: number): void;
47
50
 
48
51
  /**
49
- * Fires on dblclick (treemap drill-up gesture). Optional — charts
50
- * that don't bind a handler simply ignore the event.
52
+ * Fires on dblclick (treemap drill-up gesture).
51
53
  */
52
54
  onDblClick?(mx: number, my: number): void;
53
55
 
54
56
  /**
55
- * Fires when an active pin is dismissed by a click on the
56
- * already-pinned target (the "click again to unpin" gesture in
57
- * `dispatchClick`). Chart impls hook this to emit a
58
- * `perspective-global-filter` with `selected: false`. Does *not*
59
- * fire on the implicit dismiss inside `pin()` that replaces an
60
- * existing pin — that path is followed by a fresh `onPin` which
61
- * emits its own `selected: true`.
57
+ * Fires when an active pin is dismissed by a click.
62
58
  */
63
59
  onUnpin?(): void;
64
60
  }
65
61
 
66
62
  export interface RenderTooltipOptions {
67
- /**
68
- * Draw a dashed crosshair at `pos`. Used by scatter/line.
69
- */
70
63
  crosshair?: boolean;
71
-
72
- /**
73
- * Draw a ring of `radius` CSS pixels at `pos`. Used to highlight a
74
- * hovered point. Omit for bars (where the bar itself highlights).
75
- */
76
64
  highlightRadius?: number;
65
+ maxColumnPx?: number;
66
+ opacity?: number;
77
67
  }
78
68
 
79
69
  /**
80
- * Side-channel from the chart back to the host's DOM. The chart calls
81
- * into a sink rather than touching the DOM itself; the host
82
- * materializes the actual visual (`<div>` for pinned tooltip, cursor
83
- * mutation on the GL canvas).
84
- *
85
- * - `MessageHostSink` (in worker/) — forwards calls over a
86
- * `postMessage`-shaped channel back to
87
- * the host.
88
- * - `DomHostSink` (in host transport) — receives the matching
89
- * envelopes and applies them to the DOM.
90
- *
91
- * The controller's `_pinned` flag is the source of truth for whether
92
- * hover updates are gated; the sink only owns the visual artifact.
70
+ * Side-channel from the chart back to the host's DOM.
93
71
  */
94
72
  export interface HostSink {
95
73
  pin(
96
- lines: string[],
74
+ grid: TooltipContent,
97
75
  pos: { px: number; py: number },
98
76
  bounds: CssBounds,
77
+ style?: TooltipStyle,
99
78
  ): void;
100
79
  dismiss(): void;
101
80
  setCursor(cursor: string): void;
102
-
103
- /**
104
- * Forward a `perspective-click` to the host. Optional — only the
105
- * worker-bound `MessageHostSink` implements it; `DomHostSink` (the
106
- * host-side consumer of pin/dismiss) never sees user-event calls,
107
- * so omits the implementation.
108
- */
109
81
  emitUserClick?(detail: UserClickPayload): void;
110
-
111
- /**
112
- * Forward a `perspective-global-filter` to the host with the
113
- * `selected: true` / `selected: false` semantics. The host owns the
114
- * `removeConfigs` history (mirrors datagrid's
115
- * `model._last_insert_configs`); the sink only ships the new state.
116
- */
117
82
  emitUserSelect?(payload: UserSelectPayload): void;
118
83
  }
119
84
 
120
- /**
121
- * Plain-object payload for `HostSink.emitUserClick`. Matches
122
- * `PerspectiveClickDetail` byte-for-byte; defined locally to avoid a
123
- * cycle through `event-detail.ts`.
124
- */
125
85
  export interface UserClickPayload {
126
86
  row: Record<string, unknown>;
127
87
  column_names: string[];
128
88
  config: { filter?: unknown[] };
129
89
  }
130
90
 
131
- /**
132
- * Plain-object payload for `HostSink.emitUserSelect`. The host
133
- * transport reconstructs a `PerspectiveSelectDetail` class instance
134
- * from this plus its cached `_lastInsertConfig`.
135
- */
136
91
  export interface UserSelectPayload {
137
92
  selected: boolean;
138
93
  row: Record<string, unknown>;
@@ -142,13 +97,7 @@ export interface UserSelectPayload {
142
97
 
143
98
  /**
144
99
  * Owns the hover/click/dblclick state machine and the pinned-tooltip
145
- * lifecycle. The renderer drives this purely through
146
- * `dispatchHover` / `dispatchLeave` / `dispatchClick` /
147
- * `dispatchDblClick` — the host's `RawEventForwarder` captures DOM
148
- * events on the GL canvas and posts them as `InteractionEvent`s.
149
- *
150
- * Pinning + cursor changes go through a {@link HostSink} so the actual
151
- * DOM mutations happen host-side regardless of where the chart runs.
100
+ * lifecycle.
152
101
  */
153
102
  export class TooltipController {
154
103
  private _callbacks: TooltipCallbacks | null = null;
@@ -162,10 +111,7 @@ export class TooltipController {
162
111
  }
163
112
 
164
113
  /**
165
- * Replace the active host sink. Dismisses any existing pin via the
166
- * prior sink so we never leak a pinned artifact across resets —
167
- * though in practice each chart instance uses one sink for its
168
- * lifetime.
114
+ * Replace the active host sink.
169
115
  */
170
116
  setHost(sink: HostSink): void {
171
117
  if (this._pinned) {
@@ -177,18 +123,14 @@ export class TooltipController {
177
123
  }
178
124
 
179
125
  /**
180
- * Forward a cursor change to the host. No-op when no host sink is
181
- * installed (chart constructed without a transport).
126
+ * Forward a cursor change to the host.
182
127
  */
183
128
  setCursor(cursor: string): void {
184
129
  this._host?.setCursor(cursor);
185
130
  }
186
131
 
187
132
  /**
188
- * Install the chart's tooltip callbacks. The renderer drives the
189
- * controller via `dispatchHover` / `dispatchLeave` /
190
- * `dispatchClick` / `dispatchDblClick`; this controller never
191
- * touches the DOM directly.
133
+ * Install the chart's tooltip callbacks.
192
134
  */
193
135
  attach(callbacks: TooltipCallbacks): void {
194
136
  this.detach();
@@ -211,13 +153,7 @@ export class TooltipController {
211
153
 
212
154
  /**
213
155
  * Schedule an `onHover` callback for the given canvas-relative
214
- * coords. Coalesces multiple calls within one animation frame so
215
- * pointer streams don't backlog the chart's hit-test path.
216
- *
217
- * Workers ship with `requestAnimationFrame` (DedicatedWorkerGlobalScope
218
- * exposes it for OffscreenCanvas painting), so the same coalescer
219
- * works in both modes. We fall back to setTimeout if RAF is missing
220
- * (e.g. node tests without a polyfill).
156
+ * coords.
221
157
  */
222
158
  dispatchHover(mx: number, my: number): void {
223
159
  if (this._pinned || !this._callbacks) {
@@ -273,20 +209,19 @@ export class TooltipController {
273
209
  }
274
210
 
275
211
  /**
276
- * Pin a tooltip (or replace an active one). Forwards through the
277
- * configured sink and flips the controller's pinned flag so hover
278
- * dispatch is suppressed until dismissal.
212
+ * Pin a tooltip (or replace an active one).
279
213
  */
280
214
  pin(
281
- lines: string[],
215
+ grid: TooltipContent,
282
216
  pos: { px: number; py: number },
283
217
  bounds: CssBounds,
218
+ style: TooltipStyle = DEFAULT_TOOLTIP_STYLE,
284
219
  ): void {
285
- if (lines.length === 0) {
220
+ if (grid.length === 0) {
286
221
  return;
287
222
  }
288
223
 
289
- this._host?.pin(lines, pos, bounds);
224
+ this._host?.pin(grid, pos, bounds, style);
290
225
  this._pinned = true;
291
226
  }
292
227
 
@@ -298,17 +233,12 @@ export class TooltipController {
298
233
 
299
234
  /**
300
235
  * Paint a canvas tooltip (crosshair, highlight ring, box + text) onto
301
- * `canvas`. The helper normalizes the 2D context to a DPR-scaled
302
- * identity transform on entry and restores prior state on exit, so it
303
- * composes cleanly with other chrome painters that may have already
304
- * called `initCanvas` on the same canvas — re-applying `scale(dpr,dpr)`
305
- * blind would double-scale in that case, misplacing the tooltip
306
- * proportionally to its distance from the origin.
236
+ * `canvas`.
307
237
  */
308
238
  export function renderCanvasTooltip(
309
239
  canvas: Canvas2D | null,
310
240
  pos: { px: number; py: number },
311
- lines: string[],
241
+ grid: TooltipContent,
312
242
  layout: PlotLayout,
313
243
  theme: Theme,
314
244
  dpr: number,
@@ -327,18 +257,12 @@ export function renderCanvasTooltip(
327
257
  ctx.setTransform(1, 0, 0, 1, 0, 0);
328
258
  ctx.scale(dpr, dpr);
329
259
  ctx.font = `11px ${theme.fontFamily}`;
330
- const lineHeight = 16;
331
- const padding = 8;
332
- let maxWidth = 0;
333
- for (const line of lines) {
334
- const w = ctx.measureText(line).width;
335
- if (w > maxWidth) {
336
- maxWidth = w;
337
- }
338
- }
339
-
340
- const boxW = maxWidth + padding * 2;
341
- const boxH = lines.length * lineHeight + padding * 2 - 4;
260
+ const measured = measureTooltipGrid(
261
+ ctx,
262
+ grid,
263
+ options.maxColumnPx ?? DEFAULT_TOOLTIP_STYLE.maxColumnPx,
264
+ );
265
+ const { boxW, boxH } = measured;
342
266
  let tx = pos.px + 12;
343
267
  let ty = pos.py - boxH - 8;
344
268
  if (tx + boxW > layout.cssWidth) {
@@ -353,8 +277,6 @@ export function renderCanvasTooltip(
353
277
  ty = layout.cssHeight - boxH - 4;
354
278
  }
355
279
 
356
- const hasLines = lines.length > 0;
357
-
358
280
  // Crosshair
359
281
  if (options.crosshair) {
360
282
  ctx.strokeStyle = theme.tickColor;
@@ -382,25 +304,15 @@ export function renderCanvasTooltip(
382
304
  ctx.globalAlpha = 1.0;
383
305
  }
384
306
 
385
- // Box + text are only drawn when we have content. Callers pass an
386
- // empty `lines` array while a lazy row fetch is still in flight —
387
- // the crosshair / highlight ring above paint immediately so the
388
- // hover remains visible, but the tooltip chrome waits for data.
389
- if (hasLines) {
390
- ctx.fillStyle = theme.tooltipBg;
391
- ctx.strokeStyle = theme.tooltipBorder;
392
- ctx.lineWidth = 1;
393
- ctx.beginPath();
394
- ctx.roundRect(tx, ty, boxW, boxH, 4);
395
- ctx.fill();
396
- ctx.stroke();
397
-
398
- ctx.fillStyle = theme.tooltipText;
399
- ctx.textAlign = "left";
400
- ctx.textBaseline = "top";
401
- for (let i = 0; i < lines.length; i++) {
402
- ctx.fillText(lines[i], tx + padding, ty + padding + i * lineHeight);
403
- }
307
+ if (grid.length > 0) {
308
+ paintTooltipGrid(
309
+ ctx,
310
+ measured,
311
+ tx,
312
+ ty,
313
+ theme,
314
+ options.opacity ?? DEFAULT_TOOLTIP_STYLE.opacity,
315
+ );
404
316
  }
405
317
 
406
318
  ctx.restore();
@@ -0,0 +1,187 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ import type { Context2D } from "../charts/canvas-types";
14
+ import type { Theme } from "../theme/theme";
15
+
16
+ export type TooltipRow = string[];
17
+
18
+ export type TooltipContent = TooltipRow[];
19
+
20
+ export interface TooltipStyle {
21
+ opacity: number;
22
+ maxColumnPx: number;
23
+ }
24
+
25
+ export const DEFAULT_TOOLTIP_STYLE: TooltipStyle = {
26
+ opacity: 1.0,
27
+ maxColumnPx: 160,
28
+ };
29
+
30
+ export function tooltipStyleOf(cfg: {
31
+ tooltip_opacity: number;
32
+ tooltip_max_column_px: number;
33
+ }): TooltipStyle {
34
+ return {
35
+ opacity: cfg.tooltip_opacity,
36
+ maxColumnPx: cfg.tooltip_max_column_px,
37
+ };
38
+ }
39
+
40
+ export const NUMERIC_RE =
41
+ /^[-+−]?[$€£¥]?\s?\d[\d.,  \s]*(?:[eE][-+]?\d+|[KMB%])?$/;
42
+
43
+ export const TOOLTIP_ROW_HEIGHT = 16;
44
+ export const TOOLTIP_PADDING = 8;
45
+ export const TOOLTIP_COL_GAP = 8;
46
+
47
+ export interface MeasuredTooltipGrid {
48
+ rows: string[][];
49
+
50
+ colWidths: number[];
51
+
52
+ colRightAlign: boolean[];
53
+
54
+ boxW: number;
55
+ boxH: number;
56
+ }
57
+
58
+ /**
59
+ * Measure a tooltip grid under the current `ctx.font`.
60
+ */
61
+ export function measureTooltipGrid(
62
+ ctx: Context2D,
63
+ grid: TooltipContent,
64
+ maxColumnPx: number,
65
+ ): MeasuredTooltipGrid {
66
+ const spanCap = maxColumnPx * 2 + TOOLTIP_COL_GAP;
67
+ const colWidths: number[] = [];
68
+ const colNumeric: boolean[] = [];
69
+ const rows: string[][] = [];
70
+ let spanWidth = 0;
71
+
72
+ for (const row of grid) {
73
+ if (row.length <= 1) {
74
+ const text = ellipsize(ctx, row[0] ?? "", spanCap);
75
+ rows.push([text]);
76
+ spanWidth = Math.max(spanWidth, ctx.measureText(text).width);
77
+ continue;
78
+ }
79
+
80
+ const cells: string[] = [];
81
+ for (let j = 0; j < row.length; j++) {
82
+ const original = row[j] ?? "";
83
+ const text = ellipsize(ctx, original, maxColumnPx);
84
+ cells.push(text);
85
+ colWidths[j] = Math.max(
86
+ colWidths[j] ?? 0,
87
+ ctx.measureText(text).width,
88
+ );
89
+ if (j >= 1 && original !== "") {
90
+ const numeric = NUMERIC_RE.test(original);
91
+ colNumeric[j] =
92
+ colNumeric[j] === undefined
93
+ ? numeric
94
+ : colNumeric[j] && numeric;
95
+ }
96
+ }
97
+
98
+ rows.push(cells);
99
+ }
100
+
101
+ let columnsWidth = 0;
102
+ for (let j = 0; j < colWidths.length; j++) {
103
+ columnsWidth += colWidths[j] + (j > 0 ? TOOLTIP_COL_GAP : 0);
104
+ }
105
+
106
+ const innerW = Math.max(spanWidth, columnsWidth);
107
+ return {
108
+ rows,
109
+ colWidths,
110
+ colRightAlign: colWidths.map(
111
+ (_, j) => (colNumeric[j] ?? false) && j >= 1,
112
+ ),
113
+ boxW: innerW + TOOLTIP_PADDING * 2,
114
+ boxH: grid.length * TOOLTIP_ROW_HEIGHT + TOOLTIP_PADDING * 2 - 4,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Paint a measured tooltip grid at `(tx, ty)`.
120
+ */
121
+ export function paintTooltipGrid(
122
+ ctx: Context2D,
123
+ measured: MeasuredTooltipGrid,
124
+ tx: number,
125
+ ty: number,
126
+ theme: Theme,
127
+ opacity: number,
128
+ ): void {
129
+ const { rows, colWidths, colRightAlign, boxW, boxH } = measured;
130
+
131
+ ctx.globalAlpha = Math.min(1, Math.max(0, opacity));
132
+ ctx.fillStyle = theme.tooltipBg;
133
+ ctx.strokeStyle = theme.tooltipBorder;
134
+ ctx.lineWidth = 1;
135
+ ctx.beginPath();
136
+ ctx.rect(tx, ty, boxW, boxH);
137
+ ctx.fill();
138
+ ctx.stroke();
139
+ ctx.globalAlpha = 1.0;
140
+
141
+ ctx.fillStyle = theme.tooltipText;
142
+ ctx.textBaseline = "top";
143
+ for (let i = 0; i < rows.length; i++) {
144
+ const row = rows[i];
145
+ const rowY = ty + TOOLTIP_PADDING + i * TOOLTIP_ROW_HEIGHT;
146
+ if (row.length <= 1) {
147
+ ctx.textAlign = "left";
148
+ ctx.fillText(row[0], tx + TOOLTIP_PADDING, rowY);
149
+ continue;
150
+ }
151
+
152
+ let colX = tx + TOOLTIP_PADDING;
153
+ for (let j = 0; j < row.length; j++) {
154
+ const width = colWidths[j] ?? 0;
155
+ if (colRightAlign[j]) {
156
+ ctx.textAlign = "right";
157
+ ctx.fillText(row[j], colX + width, rowY);
158
+ } else {
159
+ ctx.textAlign = "left";
160
+ ctx.fillText(row[j], colX, rowY);
161
+ }
162
+
163
+ colX += width + TOOLTIP_COL_GAP;
164
+ }
165
+ }
166
+
167
+ ctx.textAlign = "left";
168
+ }
169
+
170
+ export function ellipsize(ctx: Context2D, text: string, maxW: number): string {
171
+ if (ctx.measureText(text).width <= maxW) {
172
+ return text;
173
+ }
174
+
175
+ let lo = 0;
176
+ let hi = text.length;
177
+ while (lo < hi) {
178
+ const mid = (lo + hi + 1) >> 1;
179
+ if (ctx.measureText(text.slice(0, mid) + "…").width <= maxW) {
180
+ lo = mid;
181
+ } else {
182
+ hi = mid - 1;
183
+ }
184
+ }
185
+
186
+ return text.slice(0, lo) + "…";
187
+ }
@@ -50,6 +50,13 @@ export interface FacetGridOptions {
50
50
  */
51
51
  hasLegend?: boolean;
52
52
 
53
+ /**
54
+ * Width of the legend gutter when `hasLegend` is set. Defaults to
55
+ * the historical `LEGEND_GUTTER` (96). Callers resolve this from
56
+ * `plugin_config.legend_width_px` via `legendSidebarWidth`.
57
+ */
58
+ legendWidth?: number;
59
+
53
60
  /** Axis-label allowance (consumed only when the corresponding axis
54
61
  * mode produces a gutter — outer band or per-cell). */
55
62
  hasXLabel?: boolean;
@@ -220,7 +227,7 @@ export function buildFacetGrid(
220
227
  }
221
228
 
222
229
  const titleBand = opts.titleBand ?? TITLE_BAND_DEFAULT;
223
- const legendW = opts.hasLegend ? LEGEND_GUTTER : 0;
230
+ const legendW = opts.hasLegend ? (opts.legendWidth ?? LEGEND_GUTTER) : 0;
224
231
 
225
232
  const xMode: AxisMode = opts.xAxis ?? "cell";
226
233
  const yMode: AxisMode = opts.yAxis ?? "cell";
@@ -69,6 +69,18 @@ export function computeNiceTicks(
69
69
  return ticks;
70
70
  }
71
71
 
72
+ export function stepTickFormatter(
73
+ isDate: boolean | undefined,
74
+ ticks: number[] | null | undefined,
75
+ ): (v: number) => string {
76
+ if (!isDate) {
77
+ return formatTickValue;
78
+ }
79
+
80
+ const step = ticks && ticks.length > 1 ? ticks[1] - ticks[0] : 0;
81
+ return (v: number) => formatDateTickValue(v, step);
82
+ }
83
+
72
84
  /**
73
85
  * Format a numeric tick value for display.
74
86
  * Uses K/M/B suffixes for large numbers, fixed decimals for small.
@@ -119,7 +119,22 @@ export class TileLoader {
119
119
  url: string,
120
120
  signal: AbortSignal,
121
121
  ): Promise<ImageBitmap | null> {
122
- const resp = await fetch(url, { signal });
122
+ // Explicit same-origin `referrer`: in worker mode this code runs
123
+ // in a blob-URL worker, whose client URL is unreferable — its
124
+ // fetches carry NO `Referer` under ANY document Referrer-Policy
125
+ // (verified empirically, Chromium 139). Tile providers gate on
126
+ // it: OSM serves "403r — referer is required" blocked TILES
127
+ // (HTTP 200, error drawn into the image; see the OSM wiki's
128
+ // Blocked_tiles page) to referer-less requests. An explicit
129
+ // same-origin referrer serializes to the page origin under the
130
+ // default policy, which is exactly the identification those
131
+ // providers ask for. Opaque/non-http origins (`"null"`) are
132
+ // skipped — the option would throw.
133
+ const referrer = self.origin?.startsWith("http")
134
+ ? `${self.origin}/`
135
+ : undefined;
136
+
137
+ const resp = await fetch(url, { signal, referrer });
123
138
  if (!resp.ok) {
124
139
  return null;
125
140
  }