@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
@@ -0,0 +1,1084 @@
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 {
14
+ DEFAULT_PLUGIN_CONFIG,
15
+ type LegendAnchor,
16
+ type LegendSizeMode,
17
+ type PluginConfig,
18
+ } from "../charts/chart";
19
+ import type { PlotRect } from "../layout/plot-layout";
20
+ import type { InteractionEvent } from "../transport/protocol";
21
+
22
+ /** Minimum legend width for any mode (drag clamp + config clamp). */
23
+ export const LEGEND_MIN_WIDTH = 48;
24
+
25
+ /** Maximum configurable legend width (further clamped to canvas/2). */
26
+ export const LEGEND_MAX_WIDTH = 512;
27
+
28
+ /** Minimum floating-panel height. */
29
+ export const LEGEND_MIN_HEIGHT = 48;
30
+
31
+ /**
32
+ * Floating-panel fallbacks: used when `legend_size_mode: "fixed"` has no
33
+ * saved value (`0`), and when `"auto"` has nothing to size against — an
34
+ * unmeasurable width, or a gradient legend's row-less height.
35
+ */
36
+ const FLOATING_AUTO_WIDTH = 160;
37
+ const FLOATING_AUTO_HEIGHT = 160;
38
+
39
+ /** Floating-panel header strip height (title + move grip). */
40
+ export const LEGEND_HEADER_H = 18;
41
+
42
+ /** Entry row height shared by every swatch-list legend painter. */
43
+ export const LEGEND_LINE_HEIGHT = 18;
44
+
45
+ /**
46
+ * Floating-frame chrome, as consumed from the panel box by
47
+ * `paintFloatingLegendFrame`: `PAD_L` insets the content from the left
48
+ * edge, `W` is the total width the frame takes, and `H` the total
49
+ * height it takes BELOW the header (split evenly above/below). The
50
+ * auto-size math is the frame's inverse, so both must read the same
51
+ * constants — a drifted pair sizes a panel that clips its own last row.
52
+ */
53
+ export const LEGEND_FRAME_PAD_L = 8;
54
+ export const LEGEND_FRAME_W = 12;
55
+ export const LEGEND_FRAME_H = 8;
56
+
57
+ /** Width the header title is laid out against: `box.width - PAD`. */
58
+ export const LEGEND_TITLE_PAD = 16;
59
+
60
+ /**
61
+ * Entry-row chrome left of the label text: swatch (10) + gap (6).
62
+ * Gradient legends substitute their own (bar 16 + gap 5).
63
+ */
64
+ export const LEGEND_ENTRY_LEADING = 16;
65
+
66
+ /**
67
+ * Width the entry painters give up to the scroll thumb once the content
68
+ * overflows. The two painters reserve 8 and 10 respectively; auto-width
69
+ * budgets the larger so neither truncates a label it just sized for.
70
+ */
71
+ const LEGEND_SCROLLBAR_ALLOWANCE = 10;
72
+
73
+ /** Edge-proximity in CSS px that reads as a resize handle. */
74
+ const EDGE = 5;
75
+
76
+ /** Corner zone size for the combined SE resize handle. */
77
+ const CORNER = 12;
78
+
79
+ /** Scrollbar hit-zone width (paint width is narrower). */
80
+ const SCROLLBAR_HIT_W = 10;
81
+
82
+ /** Pointer travel in CSS px before a body-press becomes a move drag. */
83
+ const MOVE_THRESHOLD = 3;
84
+
85
+ function clamp(v: number, lo: number, hi: number): number {
86
+ return Math.min(hi, Math.max(lo, v));
87
+ }
88
+
89
+ function clamp01(v: number): number {
90
+ return Number.isFinite(v) ? clamp(v, 0, 1) : 0;
91
+ }
92
+
93
+ /** Normalized-span coordinate: `px` along a free span of `span` px. */
94
+ function norm(px: number, span: number): number {
95
+ if (span <= 0) {
96
+ return 0;
97
+ }
98
+
99
+ const whole = Math.round(px);
100
+ if (whole <= 0) {
101
+ return 0;
102
+ }
103
+
104
+ if (whole >= Math.round(span)) {
105
+ return 1;
106
+ }
107
+
108
+ return clamp01(whole / span);
109
+ }
110
+
111
+ function anchorRight(a: LegendAnchor): boolean {
112
+ return a === "top-right" || a === "bottom-right";
113
+ }
114
+
115
+ function anchorBottom(a: LegendAnchor): boolean {
116
+ return a === "bottom-left" || a === "bottom-right";
117
+ }
118
+
119
+ /**
120
+ * Sidebar gutter width when the sidebar legend is active: the
121
+ * configured `legend_width_px`, or `legacy` (the chart family's
122
+ * historical gutter constant) when the config is 0 (auto).
123
+ */
124
+ export function legendSidebarWidth(cfg: PluginConfig, legacy: number): number {
125
+ return cfg.legend_width_px > 0
126
+ ? clamp(cfg.legend_width_px, LEGEND_MIN_WIDTH, LEGEND_MAX_WIDTH)
127
+ : legacy;
128
+ }
129
+
130
+ /**
131
+ * `legend_mode` with `"auto"` resolved away — the only form the
132
+ * layout/paint/interaction paths consume. Painters record the resolved
133
+ * mode in `setPainted`, so the controller's drag semantics follow it
134
+ * with no separate resolution.
135
+ */
136
+ export type ResolvedLegendMode = "sidebar" | "none" | "floating";
137
+
138
+ /**
139
+ * `"auto"` resolves to `"floating"` when every legend entry fits the
140
+ * DEFAULT floating panel (160 px ≈ header + 7 rows) without scrolling —
141
+ * a compact overlay that frees the whole gutter — and to `"sidebar"`
142
+ * otherwise, where a long list gets dedicated, scrollable space instead
143
+ * of occluding the plot. Continuous gradient legends have no entry
144
+ * list; callers pass `entryCount = 0` and they resolve to floating.
145
+ */
146
+ export const AUTO_FLOATING_MAX_ROWS = 7;
147
+
148
+ export function resolveLegendMode(
149
+ cfg: PluginConfig,
150
+ entryCount: number,
151
+ ): ResolvedLegendMode {
152
+ if (cfg.legend_mode !== "auto") {
153
+ return cfg.legend_mode;
154
+ }
155
+
156
+ return entryCount <= AUTO_FLOATING_MAX_ROWS ? "floating" : "sidebar";
157
+ }
158
+
159
+ /**
160
+ * Right-margin width a plot layout should reserve for the legend.
161
+ * `legacy` is the family's historical `hasLegend` gutter (80 for
162
+ * single-plot layouts, 96 for facet grids). Modes `"none"` and
163
+ * `"floating"` collapse the gutter to the no-legend breathing margin —
164
+ * the plot widens and the floating panel overlays it. `entryCount`
165
+ * feeds the `"auto"` resolution ({@link resolveLegendMode}); pass the
166
+ * same count the legend painter will enumerate.
167
+ */
168
+ export function legendRightGutter(
169
+ cfg: PluginConfig,
170
+ hasLegend: boolean,
171
+ legacy: number = 80,
172
+ entryCount: number = 0,
173
+ ): number {
174
+ if (!hasLegend || resolveLegendMode(cfg, entryCount) !== "sidebar") {
175
+ return 16;
176
+ }
177
+
178
+ return legendSidebarWidth(cfg, legacy);
179
+ }
180
+
181
+ /**
182
+ * Tree-chart variant of {@link legendRightGutter}: trees reserve `0`
183
+ * (fully flush cells) rather than a 16px breathing margin when no
184
+ * sidebar legend is present.
185
+ */
186
+ export function legendTreeGutter(
187
+ cfg: PluginConfig,
188
+ hasLegend: boolean,
189
+ legacy: number,
190
+ entryCount: number = 0,
191
+ ): number {
192
+ if (!hasLegend || resolveLegendMode(cfg, entryCount) !== "sidebar") {
193
+ return 0;
194
+ }
195
+
196
+ return legendSidebarWidth(cfg, legacy);
197
+ }
198
+
199
+ /**
200
+ * Content measurements a floating panel needs to size itself in
201
+ * `legend_size_mode: "auto"`. Supplied by the call site (which owns the
202
+ * chrome canvas and the label set) and consumed ONLY by that mode, so a
203
+ * `"fixed"` panel — and every sidebar legend — pays nothing.
204
+ */
205
+ export interface LegendAutoFit {
206
+ /**
207
+ * Entry rows the painter will enumerate. `0` marks a continuous
208
+ * gradient legend, which has no row list and keeps the default
209
+ * panel height.
210
+ */
211
+ entryCount: number;
212
+
213
+ /**
214
+ * Panel width in CSS px that fits the widest entry label AND the
215
+ * header title, frame chrome included. Invoked at most once per
216
+ * `floatingBox` call; see `legendAutoFit`.
217
+ */
218
+ boxWidth?: () => number;
219
+ }
220
+
221
+ /**
222
+ * Cursor-zone classification for a point over the painted legend.
223
+ * Sidebar legends expose only `resize-w` (their left edge) plus
224
+ * `scrollbar` / `body`; floating panels add the E/S/SE handles and the
225
+ * `move` grip (header strip — the body also moves, gated by
226
+ * {@link MOVE_THRESHOLD} so entry clicks still land).
227
+ */
228
+ export type LegendHitZone =
229
+ | "none"
230
+ | "body"
231
+ | "move"
232
+ | "scrollbar"
233
+ | "resize-w"
234
+ | "resize-e"
235
+ | "resize-s"
236
+ | "resize-se";
237
+
238
+ /**
239
+ * Frame snapshot the legend painters hand back after painting: the
240
+ * panel's outer box, the scrollable content rect inside it, and the
241
+ * full (unclipped) content height. Hit-testing and wheel/drag routing
242
+ * all consult the LAST PAINTED geometry — between paints the on-screen
243
+ * pixels are exactly this snapshot, so events resolve against what the
244
+ * user actually sees.
245
+ */
246
+ export interface PaintedLegend {
247
+ mode: "sidebar" | "floating";
248
+ box: PlotRect;
249
+ content: PlotRect;
250
+ contentHeight: number;
251
+
252
+ /**
253
+ * The resolved right-gutter width this frame (sidebar mode only).
254
+ * The width-drag applies its delta to this — NOT to `box.width`,
255
+ * which excludes the gutter's padding — so a drag that starts on
256
+ * an auto-width (config 0) legend continues from the family's
257
+ * legacy width without a jump.
258
+ */
259
+ sidebarGutter?: number;
260
+ }
261
+
262
+ /** The `PluginConfig` fields this controller owns. */
263
+ const LEGEND_FIELDS = [
264
+ "legend_mode",
265
+ "legend_size_mode",
266
+ "legend_width_px",
267
+ "legend_height_px",
268
+ "legend_anchor",
269
+ "legend_x",
270
+ "legend_y",
271
+ "legend_opacity",
272
+ ] as const;
273
+
274
+ type LegendFieldSnapshot = {
275
+ legend_size_mode: LegendSizeMode;
276
+ legend_width_px: number;
277
+ legend_height_px: number;
278
+ legend_x: number;
279
+ legend_y: number;
280
+ };
281
+
282
+ type DragState =
283
+ | {
284
+ kind: "resize-w" | "resize-e" | "resize-s" | "resize-se";
285
+ startMx: number;
286
+ startMy: number;
287
+ startBox: PlotRect;
288
+ startGutter: number;
289
+ snapshot: LegendFieldSnapshot;
290
+ }
291
+ | {
292
+ kind: "move";
293
+ startMx: number;
294
+ startMy: number;
295
+ startBox: PlotRect;
296
+ moved: boolean;
297
+ snapshot: LegendFieldSnapshot;
298
+ }
299
+ | {
300
+ kind: "scrollbar";
301
+ startMy: number;
302
+ startScroll: number;
303
+ };
304
+
305
+ /**
306
+ * Per-event services the worker renderer supplies. The controller is
307
+ * pure state — it never reaches into the chart or transport itself.
308
+ */
309
+ export interface LegendEventCtx {
310
+ /**
311
+ * The chart's live plugin config. Drag gestures mutate the legend
312
+ * fields on this object directly (the worker-local copy), so the
313
+ * next paint lays out from the in-progress values; the persisted
314
+ * delta is posted once at pointerup.
315
+ */
316
+ cfg: PluginConfig;
317
+ cssWidth: number;
318
+ cssHeight: number;
319
+
320
+ /**
321
+ * Visible toggleable entry rects (series legends). A floating-panel
322
+ * click that lands on one passes through to the chart's own legend
323
+ * click handler instead of being consumed.
324
+ */
325
+ legendRects: ReadonlyArray<{ rect: PlotRect }>;
326
+
327
+ /**
328
+ * Schedule a repaint. `relayout: true` when the gesture changed the
329
+ * plot geometry (sidebar width) and a full GL pass is required;
330
+ * `false` for chrome-only changes (scroll, floating move/resize).
331
+ */
332
+ repaint(relayout: boolean): void;
333
+
334
+ /** Post a persisted config delta (pointerup, changed fields only). */
335
+ postDelta(fields: Partial<PluginConfig>): void;
336
+
337
+ setCursor(cursor: string): void;
338
+
339
+ /** Dismiss any active plot hover (cursor entered the legend). */
340
+ dispatchLeave(): void;
341
+ }
342
+
343
+ /**
344
+ * Single owner of legend interaction state: last-painted geometry,
345
+ * scroll offset, hover cursor, and the drag state machine. One
346
+ * instance per chart (`AbstractChart._legend`); the worker renderer
347
+ * consults {@link handleEvent} FIRST for every forwarded interaction
348
+ * event, so legend gestures structurally preempt plot zoom / pan /
349
+ * tooltip routing rather than racing them.
350
+ *
351
+ * The scroll offset is transient (never persisted); geometry fields
352
+ * (`legend_size_mode`, `legend_width_px`, `legend_height_px`,
353
+ * `legend_x`, `legend_y`) round-trip through `plugin_config`.
354
+ */
355
+ export class LegendController {
356
+ private _painted: PaintedLegend | null = null;
357
+ private _scroll = 0;
358
+ private _drag: DragState | null = null;
359
+ private _suppressClick = false;
360
+ private _hoverInside = false;
361
+ private _lastCursor = "";
362
+
363
+ // Painter surface
364
+
365
+ /**
366
+ * Clamp and return the scroll offset for this frame. Painters call
367
+ * this before drawing the entry window so the offset is always
368
+ * valid against the CURRENT content/viewport pair (content can
369
+ * shrink between frames — data updates, panel resizes).
370
+ */
371
+ clampScroll(viewHeight: number, contentHeight: number): number {
372
+ this._scroll = clamp(
373
+ this._scroll,
374
+ 0,
375
+ Math.max(0, contentHeight - viewHeight),
376
+ );
377
+ return this._scroll;
378
+ }
379
+
380
+ /** Record this frame's painted geometry. */
381
+ setPainted(p: PaintedLegend): void {
382
+ this._painted = p;
383
+ }
384
+
385
+ /**
386
+ * Record that no legend was painted this frame (mode `"none"`, or
387
+ * no legend content). Kills every hit zone until the next paint.
388
+ */
389
+ clearPainted(): void {
390
+ this._painted = null;
391
+ }
392
+
393
+ /**
394
+ * Resolve the floating panel's outer box from config. Offsets are
395
+ * normalized to the free span and measured from the
396
+ * `legend_anchor` corner, so every `legend_x`/`legend_y` in [0, 1]
397
+ * yields a fully on-canvas box at any canvas size.
398
+ *
399
+ * `fit` drives `legend_size_mode: "auto"` (see {@link LegendAutoFit});
400
+ * omitting it in auto mode falls back to the fixed-mode defaults,
401
+ * so a call site that cannot measure still paints a sane panel. The
402
+ * canvas clamps apply to BOTH modes — an auto panel never grows
403
+ * past half the canvas width or its full height, it scrolls
404
+ * instead.
405
+ */
406
+ floatingBox(
407
+ cfg: PluginConfig,
408
+ cssWidth: number,
409
+ cssHeight: number,
410
+ fit?: LegendAutoFit,
411
+ ): PlotRect {
412
+ // Each dimension resolves independently, and each falls back to
413
+ // the fixed-mode default when auto cannot answer — an
414
+ // unmeasurable width (no 2D context) or a gradient legend, which
415
+ // has no rows to hug. `0` from a measurer means "could not
416
+ // measure", never "zero wide".
417
+ const auto = cfg.legend_size_mode !== "fixed";
418
+ const measured = auto ? (fit?.boxWidth?.() ?? 0) : 0;
419
+
420
+ // Auto height is the frame's inverse: N rows of content, plus
421
+ // the header and the frame's vertical padding.
422
+ const rows = auto ? (fit?.entryCount ?? 0) : 0;
423
+ const wantWidth =
424
+ measured > 0
425
+ ? measured
426
+ : cfg.legend_width_px > 0 && !auto
427
+ ? cfg.legend_width_px
428
+ : FLOATING_AUTO_WIDTH;
429
+ const wantHeight =
430
+ rows > 0
431
+ ? LEGEND_HEADER_H + LEGEND_FRAME_H + rows * LEGEND_LINE_HEIGHT
432
+ : cfg.legend_height_px > 0 && !auto
433
+ ? cfg.legend_height_px
434
+ : FLOATING_AUTO_HEIGHT;
435
+
436
+ const height = clamp(
437
+ wantHeight,
438
+ LEGEND_MIN_HEIGHT,
439
+ Math.max(LEGEND_MIN_HEIGHT, cssHeight - 8),
440
+ );
441
+
442
+ // A row list too tall for the canvas keeps its measured width
443
+ // but now scrolls, and the thumb is drawn INSIDE the content
444
+ // rect — so budget for it, or the labels auto-width just fit
445
+ // truncate anyway.
446
+ const scrolls = height < wantHeight;
447
+ const width = clamp(
448
+ wantWidth +
449
+ (scrolls && measured > 0 ? LEGEND_SCROLLBAR_ALLOWANCE : 0),
450
+ LEGEND_MIN_WIDTH,
451
+ Math.max(LEGEND_MIN_WIDTH, Math.floor(cssWidth / 2)),
452
+ );
453
+ const freeW = Math.max(0, cssWidth - width);
454
+ const freeH = Math.max(0, cssHeight - height);
455
+ const rx = clamp01(cfg.legend_x) * freeW;
456
+ const ry = clamp01(cfg.legend_y) * freeH;
457
+ return {
458
+ x: Math.round(anchorRight(cfg.legend_anchor) ? freeW - rx : rx),
459
+ y: Math.round(anchorBottom(cfg.legend_anchor) ? freeH - ry : ry),
460
+ width,
461
+ height,
462
+ };
463
+ }
464
+
465
+ // Config lifecycle
466
+
467
+ /**
468
+ * Called by `setPluginConfig` BEFORE the incoming config replaces
469
+ * the current one. A restore whose legend fields EQUAL the current
470
+ * values is the persistence echo of a completed drag — a no-op
471
+ * here, so scroll and any in-flight gesture survive. Different
472
+ * values (the user typed in the settings form, or a restore raced
473
+ * in) win unconditionally: any in-flight drag is cancelled.
474
+ */
475
+ reconcileConfig(prev: PluginConfig, next: PluginConfig): void {
476
+ if (!this._drag) {
477
+ return;
478
+ }
479
+
480
+ for (const key of LEGEND_FIELDS) {
481
+ if (prev[key] !== next[key]) {
482
+ this._drag = null;
483
+ return;
484
+ }
485
+ }
486
+ }
487
+
488
+ // Interaction
489
+
490
+ cancelGesture(): void {
491
+ this._drag = null;
492
+ this._suppressClick = false;
493
+ }
494
+
495
+ /**
496
+ * Route one forwarded interaction event. Returns `true` when the
497
+ * event was consumed (the caller must not forward it to zoom /
498
+ * tooltip routing).
499
+ */
500
+ handleEvent(event: InteractionEvent, ctx: LegendEventCtx): boolean {
501
+ switch (event.type) {
502
+ case "wheel":
503
+ return this._onWheel(event.mx, event.my, event.deltaY, ctx);
504
+ case "pointerdown":
505
+ return this._onPointerDown(event.mx, event.my, ctx);
506
+ case "pointermove":
507
+ return this._onPointerMove(event.mx, event.my, ctx);
508
+ case "pointerup":
509
+ return this._onPointerUp(ctx);
510
+ case "click":
511
+ return this._onClick(event.mx, event.my, ctx);
512
+ case "dblclick":
513
+ return this._onDblClick(event.mx, event.my, ctx);
514
+ case "pointerleave":
515
+ this._hoverInside = false;
516
+ this._setCursor("", ctx);
517
+ return false;
518
+ }
519
+ }
520
+
521
+ /** Classify a canvas-CSS-px point against the last painted legend. */
522
+ hitTest(mx: number, my: number): LegendHitZone {
523
+ const p = this._painted;
524
+ if (!p) {
525
+ return "none";
526
+ }
527
+
528
+ const b = p.box;
529
+ if (p.mode === "floating") {
530
+ const inX = mx >= b.x - EDGE && mx <= b.x + b.width + EDGE;
531
+ const inY = my >= b.y - EDGE && my <= b.y + b.height + EDGE;
532
+ if (!inX || !inY) {
533
+ return "none";
534
+ }
535
+
536
+ const nearW = Math.abs(mx - b.x) <= EDGE;
537
+ const nearE = Math.abs(mx - (b.x + b.width)) <= EDGE;
538
+ const nearS = Math.abs(my - (b.y + b.height)) <= EDGE;
539
+ if (
540
+ (nearE && my >= b.y + b.height - CORNER) ||
541
+ (nearS && mx >= b.x + b.width - CORNER)
542
+ ) {
543
+ return "resize-se";
544
+ }
545
+
546
+ if (nearE) {
547
+ return "resize-e";
548
+ }
549
+
550
+ if (nearW) {
551
+ return "resize-w";
552
+ }
553
+
554
+ if (nearS) {
555
+ return "resize-s";
556
+ }
557
+
558
+ if (this._inScrollbar(mx, my)) {
559
+ return "scrollbar";
560
+ }
561
+
562
+ if (my <= b.y + LEGEND_HEADER_H) {
563
+ return "move";
564
+ }
565
+
566
+ return "body";
567
+ }
568
+
569
+ // Sidebar: the grab zone straddles the legend's left edge and
570
+ // the 12px pad between it and the plot rect.
571
+ const inY = my >= b.y && my <= b.y + b.height;
572
+ if (inY && mx >= b.x - 14 && mx <= b.x + 2) {
573
+ return "resize-w";
574
+ }
575
+
576
+ if (inY && mx >= b.x && mx <= b.x + b.width) {
577
+ if (this._inScrollbar(mx, my)) {
578
+ return "scrollbar";
579
+ }
580
+
581
+ return "body";
582
+ }
583
+
584
+ return "none";
585
+ }
586
+
587
+ private _scrollable(): boolean {
588
+ const p = this._painted;
589
+ return !!p && p.contentHeight > p.content.height + 0.5;
590
+ }
591
+
592
+ private _inScrollbar(mx: number, my: number): boolean {
593
+ const p = this._painted;
594
+ if (!p || !this._scrollable()) {
595
+ return false;
596
+ }
597
+
598
+ const c = p.content;
599
+ return (
600
+ mx >= c.x + c.width - SCROLLBAR_HIT_W &&
601
+ mx <= c.x + c.width &&
602
+ my >= c.y &&
603
+ my <= c.y + c.height
604
+ );
605
+ }
606
+
607
+ private _onWheel(
608
+ mx: number,
609
+ my: number,
610
+ deltaY: number,
611
+ ctx: LegendEventCtx,
612
+ ): boolean {
613
+ if (this._drag) {
614
+ return true;
615
+ }
616
+
617
+ if (this.hitTest(mx, my) === "none") {
618
+ return false;
619
+ }
620
+
621
+ if (this._scrollable()) {
622
+ this._setScroll(this._scroll + deltaY, ctx);
623
+ }
624
+
625
+ // Consume regardless: a wheel over the legend must never zoom
626
+ // the plot underneath (floating), and the sidebar gutter has no
627
+ // zoom target anyway.
628
+ return true;
629
+ }
630
+
631
+ private _onPointerDown(
632
+ mx: number,
633
+ my: number,
634
+ ctx: LegendEventCtx,
635
+ ): boolean {
636
+ const zone = this.hitTest(mx, my);
637
+ if (zone === "none") {
638
+ return false;
639
+ }
640
+
641
+ const p = this._painted!;
642
+ this._suppressClick = false;
643
+ if (zone === "scrollbar") {
644
+ this._drag = {
645
+ kind: "scrollbar",
646
+ startMy: my,
647
+ startScroll: this._scroll,
648
+ };
649
+ return true;
650
+ }
651
+
652
+ const snapshot: LegendFieldSnapshot = {
653
+ legend_size_mode: ctx.cfg.legend_size_mode,
654
+ legend_width_px: ctx.cfg.legend_width_px,
655
+ legend_height_px: ctx.cfg.legend_height_px,
656
+ legend_x: ctx.cfg.legend_x,
657
+ legend_y: ctx.cfg.legend_y,
658
+ };
659
+
660
+ if (
661
+ zone === "resize-w" ||
662
+ zone === "resize-e" ||
663
+ zone === "resize-s" ||
664
+ zone === "resize-se"
665
+ ) {
666
+ this._drag = {
667
+ kind: zone,
668
+ startMx: mx,
669
+ startMy: my,
670
+ startBox: { ...p.box },
671
+ startGutter: p.sidebarGutter ?? p.box.width,
672
+ snapshot,
673
+ };
674
+ this._setCursor(
675
+ zone === "resize-s"
676
+ ? "ns-resize"
677
+ : zone === "resize-se"
678
+ ? "nwse-resize"
679
+ : "ew-resize",
680
+ ctx,
681
+ );
682
+ return true;
683
+ }
684
+
685
+ if (p.mode === "floating") {
686
+ // Header and body both arm a move; the threshold in
687
+ // `_applyDrag` keeps plain clicks (entry toggles) intact.
688
+ this._drag = {
689
+ kind: "move",
690
+ startMx: mx,
691
+ startMy: my,
692
+ startBox: { ...p.box },
693
+ moved: false,
694
+ snapshot,
695
+ };
696
+ return true;
697
+ }
698
+
699
+ // Sidebar body press: nothing to drag, but consume so no other
700
+ // handler interprets the press.
701
+ return true;
702
+ }
703
+
704
+ private _onPointerMove(
705
+ mx: number,
706
+ my: number,
707
+ ctx: LegendEventCtx,
708
+ ): boolean {
709
+ if (this._drag) {
710
+ this._applyDrag(mx, my, ctx);
711
+ return true;
712
+ }
713
+
714
+ const zone = this.hitTest(mx, my);
715
+ const inside = zone !== "none";
716
+ if (inside && !this._hoverInside) {
717
+ // Entering the legend: clear any plot hover so a stale
718
+ // tooltip doesn't sit frozen under / beside the panel.
719
+ ctx.dispatchLeave();
720
+ }
721
+
722
+ this._hoverInside = inside;
723
+ this._setCursor(LegendController._cursorFor(zone), ctx);
724
+
725
+ // Only the floating panel overlays plot data — consume so the
726
+ // hover dispatch can't tooltip through it. The sidebar gutter
727
+ // is outside the plot rect; hover there is already inert.
728
+ return inside && this._painted!.mode === "floating";
729
+ }
730
+
731
+ private _onPointerUp(ctx: LegendEventCtx): boolean {
732
+ const d = this._drag;
733
+ if (!d) {
734
+ return false;
735
+ }
736
+
737
+ this._drag = null;
738
+ if (d.kind === "scrollbar") {
739
+ this._suppressClick = true;
740
+ return true;
741
+ }
742
+
743
+ if (d.kind === "move" && !d.moved) {
744
+ // A press that never crossed the move threshold: plain
745
+ // click — let the click event through for entry toggles.
746
+ return true;
747
+ }
748
+
749
+ this._suppressClick = true;
750
+ const fields: Partial<PluginConfig> = {};
751
+ const cfg = ctx.cfg;
752
+ if (cfg.legend_size_mode !== d.snapshot.legend_size_mode) {
753
+ fields.legend_size_mode = cfg.legend_size_mode;
754
+ }
755
+
756
+ if (cfg.legend_width_px !== d.snapshot.legend_width_px) {
757
+ fields.legend_width_px = Math.round(cfg.legend_width_px);
758
+ }
759
+
760
+ if (cfg.legend_height_px !== d.snapshot.legend_height_px) {
761
+ fields.legend_height_px = Math.round(cfg.legend_height_px);
762
+ }
763
+
764
+ if (round4(cfg.legend_x) !== round4(d.snapshot.legend_x)) {
765
+ fields.legend_x = round4(cfg.legend_x);
766
+ }
767
+
768
+ if (round4(cfg.legend_y) !== round4(d.snapshot.legend_y)) {
769
+ fields.legend_y = round4(cfg.legend_y);
770
+ }
771
+
772
+ if (Object.keys(fields).length > 0) {
773
+ ctx.postDelta(fields);
774
+ }
775
+
776
+ return true;
777
+ }
778
+
779
+ private _onDblClick(mx: number, my: number, ctx: LegendEventCtx): boolean {
780
+ const zone = this.hitTest(mx, my);
781
+ if (zone === "none") {
782
+ return false;
783
+ }
784
+
785
+ const resetsWidth =
786
+ zone === "resize-w" || zone === "resize-e" || zone === "resize-se";
787
+ const resetsHeight = zone === "resize-s" || zone === "resize-se";
788
+ if (!resetsWidth && !resetsHeight) {
789
+ return this._painted!.mode === "floating";
790
+ }
791
+
792
+ const cfg = ctx.cfg;
793
+ const fields: Partial<PluginConfig> = {};
794
+
795
+ // A floating panel's reset also returns it to content-sizing —
796
+ // the inverse of the freeze a resize drag performs, and the only
797
+ // gesture that restores `"auto"` without the settings form.
798
+ if (
799
+ this._painted!.mode === "floating" &&
800
+ cfg.legend_size_mode !== DEFAULT_PLUGIN_CONFIG.legend_size_mode
801
+ ) {
802
+ cfg.legend_size_mode = DEFAULT_PLUGIN_CONFIG.legend_size_mode;
803
+ fields.legend_size_mode = cfg.legend_size_mode;
804
+ }
805
+
806
+ if (
807
+ resetsWidth &&
808
+ cfg.legend_width_px !== DEFAULT_PLUGIN_CONFIG.legend_width_px
809
+ ) {
810
+ cfg.legend_width_px = DEFAULT_PLUGIN_CONFIG.legend_width_px;
811
+ fields.legend_width_px = cfg.legend_width_px;
812
+ }
813
+
814
+ if (
815
+ resetsHeight &&
816
+ cfg.legend_height_px !== DEFAULT_PLUGIN_CONFIG.legend_height_px
817
+ ) {
818
+ cfg.legend_height_px = DEFAULT_PLUGIN_CONFIG.legend_height_px;
819
+ fields.legend_height_px = cfg.legend_height_px;
820
+ }
821
+
822
+ if (Object.keys(fields).length > 0) {
823
+ // Sidebar width changes the plot rect — full relayout;
824
+ // floating resets are chrome-only. The anchored-coords
825
+ // model keeps the anchor corner glued through the size
826
+ // change, so no position rewrite is needed.
827
+ ctx.repaint(this._painted!.mode === "sidebar");
828
+ ctx.postDelta(fields);
829
+ }
830
+
831
+ return true;
832
+ }
833
+
834
+ private _onClick(mx: number, my: number, ctx: LegendEventCtx): boolean {
835
+ if (this._suppressClick) {
836
+ this._suppressClick = false;
837
+ return true;
838
+ }
839
+
840
+ const zone = this.hitTest(mx, my);
841
+ if (zone === "none") {
842
+ return false;
843
+ }
844
+
845
+ if (zone === "scrollbar") {
846
+ return true;
847
+ }
848
+
849
+ if (this._painted!.mode === "floating") {
850
+ // Pass through only when the click lands on a toggleable
851
+ // entry (series legends populate `legendRects` with the
852
+ // visible window); everything else is consumed so the
853
+ // click can't pin a tooltip on the plot under the panel.
854
+ for (const entry of ctx.legendRects) {
855
+ const r = entry.rect;
856
+ if (
857
+ mx >= r.x &&
858
+ mx <= r.x + r.width &&
859
+ my >= r.y &&
860
+ my <= r.y + r.height
861
+ ) {
862
+ return false;
863
+ }
864
+ }
865
+
866
+ return true;
867
+ }
868
+
869
+ // Sidebar clicks keep their existing path (series toggle via
870
+ // the chart's own click handler; the gutter is outside the
871
+ // plot so nothing else can trigger).
872
+ return false;
873
+ }
874
+
875
+ private _applyDrag(mx: number, my: number, ctx: LegendEventCtx): void {
876
+ const d = this._drag!;
877
+ const cfg = ctx.cfg;
878
+ const { cssWidth, cssHeight } = ctx;
879
+ const maxW = Math.max(LEGEND_MIN_WIDTH, Math.floor(cssWidth / 2));
880
+ switch (d.kind) {
881
+ case "scrollbar": {
882
+ const p = this._painted;
883
+ if (!p || p.content.height <= 0) {
884
+ return;
885
+ }
886
+
887
+ const ratio = p.contentHeight / p.content.height;
888
+ this._setScroll(d.startScroll + (my - d.startMy) * ratio, ctx);
889
+ return;
890
+ }
891
+
892
+ case "move": {
893
+ if (!d.moved) {
894
+ const dist = Math.hypot(mx - d.startMx, my - d.startMy);
895
+ if (dist < MOVE_THRESHOLD) {
896
+ return;
897
+ }
898
+
899
+ d.moved = true;
900
+ this._setCursor("grabbing", ctx);
901
+ }
902
+
903
+ const w = d.startBox.width;
904
+ const h = d.startBox.height;
905
+ const x = clamp(
906
+ d.startBox.x + (mx - d.startMx),
907
+ 0,
908
+ Math.max(0, cssWidth - w),
909
+ );
910
+ const y = clamp(
911
+ d.startBox.y + (my - d.startMy),
912
+ 0,
913
+ Math.max(0, cssHeight - h),
914
+ );
915
+ this._writeAnchoredPos(ctx, x, y, w, h);
916
+ ctx.repaint(false);
917
+ return;
918
+ }
919
+
920
+ case "resize-w": {
921
+ if (this._painted?.mode === "sidebar") {
922
+ cfg.legend_width_px = clamp(
923
+ Math.round(d.startGutter + (d.startMx - mx)),
924
+ LEGEND_MIN_WIDTH,
925
+ Math.min(LEGEND_MAX_WIDTH, maxW),
926
+ );
927
+ ctx.repaint(true);
928
+ return;
929
+ }
930
+
931
+ // Floating: keep the RIGHT edge fixed while the left
932
+ // edge follows the cursor.
933
+ this._freezeAutoSize(ctx, d.startBox);
934
+ const right = d.startBox.x + d.startBox.width;
935
+ const w = clamp(
936
+ Math.round(d.startBox.width + (d.startMx - mx)),
937
+ LEGEND_MIN_WIDTH,
938
+ Math.min(LEGEND_MAX_WIDTH, maxW),
939
+ );
940
+ cfg.legend_width_px = w;
941
+ this._writeAnchoredPos(
942
+ ctx,
943
+ right - w,
944
+ d.startBox.y,
945
+ w,
946
+ d.startBox.height,
947
+ );
948
+ ctx.repaint(false);
949
+ return;
950
+ }
951
+
952
+ case "resize-e":
953
+ case "resize-se": {
954
+ this._freezeAutoSize(ctx, d.startBox);
955
+ const w = clamp(
956
+ Math.round(d.startBox.width + (mx - d.startMx)),
957
+ LEGEND_MIN_WIDTH,
958
+ Math.min(LEGEND_MAX_WIDTH, maxW),
959
+ );
960
+ cfg.legend_width_px = w;
961
+ const h =
962
+ d.kind === "resize-se"
963
+ ? this._applySouthResize(d.startBox, d.startMy, my, ctx)
964
+ : d.startBox.height;
965
+ this._writeAnchoredPos(ctx, d.startBox.x, d.startBox.y, w, h);
966
+ ctx.repaint(false);
967
+ return;
968
+ }
969
+
970
+ case "resize-s": {
971
+ this._freezeAutoSize(ctx, d.startBox);
972
+ const h = this._applySouthResize(
973
+ d.startBox,
974
+ d.startMy,
975
+ my,
976
+ ctx,
977
+ );
978
+ this._writeAnchoredPos(
979
+ ctx,
980
+ d.startBox.x,
981
+ d.startBox.y,
982
+ d.startBox.width,
983
+ h,
984
+ );
985
+ ctx.repaint(false);
986
+ return;
987
+ }
988
+ }
989
+ }
990
+
991
+ /**
992
+ * First write of a resize gesture on an auto-sized floating panel:
993
+ * switch it to `"fixed"`. Without this the gesture would be inert —
994
+ * the next paint recomputes the content size and discards it.
995
+ *
996
+ * BOTH dimensions are seeded from the box on screen, not just the
997
+ * dragged one: the saved `legend_width_px` / `legend_height_px` are
998
+ * whatever the panel had before it went auto, so adopting them
999
+ * wholesale would jump the edge the user is NOT dragging.
1000
+ */
1001
+ private _freezeAutoSize(ctx: LegendEventCtx, startBox: PlotRect): void {
1002
+ if (ctx.cfg.legend_size_mode === "fixed") {
1003
+ return;
1004
+ }
1005
+
1006
+ ctx.cfg.legend_size_mode = "fixed";
1007
+ ctx.cfg.legend_width_px = Math.round(startBox.width);
1008
+ ctx.cfg.legend_height_px = Math.round(startBox.height);
1009
+ }
1010
+
1011
+ /** Bottom-edge resize: new height with the top edge held fixed. */
1012
+ private _applySouthResize(
1013
+ startBox: PlotRect,
1014
+ startMy: number,
1015
+ my: number,
1016
+ ctx: LegendEventCtx,
1017
+ ): number {
1018
+ const h = clamp(
1019
+ Math.round(startBox.height + (my - startMy)),
1020
+ LEGEND_MIN_HEIGHT,
1021
+ Math.max(LEGEND_MIN_HEIGHT, ctx.cssHeight - 8),
1022
+ );
1023
+ ctx.cfg.legend_height_px = h;
1024
+ return h;
1025
+ }
1026
+
1027
+ private _writeAnchoredPos(
1028
+ ctx: LegendEventCtx,
1029
+ xPx: number,
1030
+ yPx: number,
1031
+ w: number,
1032
+ h: number,
1033
+ ): void {
1034
+ const freeW = Math.max(0, ctx.cssWidth - w);
1035
+ const freeH = Math.max(0, ctx.cssHeight - h);
1036
+ const a = ctx.cfg.legend_anchor;
1037
+ ctx.cfg.legend_x = norm(anchorRight(a) ? freeW - xPx : xPx, freeW);
1038
+ ctx.cfg.legend_y = norm(anchorBottom(a) ? freeH - yPx : yPx, freeH);
1039
+ }
1040
+
1041
+ private _setScroll(next: number, ctx: LegendEventCtx): void {
1042
+ const p = this._painted;
1043
+ if (!p) {
1044
+ return;
1045
+ }
1046
+
1047
+ const clamped = clamp(
1048
+ next,
1049
+ 0,
1050
+ Math.max(0, p.contentHeight - p.content.height),
1051
+ );
1052
+ if (clamped !== this._scroll) {
1053
+ this._scroll = clamped;
1054
+ ctx.repaint(false);
1055
+ }
1056
+ }
1057
+
1058
+ private static _cursorFor(zone: LegendHitZone): string {
1059
+ switch (zone) {
1060
+ case "resize-w":
1061
+ case "resize-e":
1062
+ return "ew-resize";
1063
+ case "resize-s":
1064
+ return "ns-resize";
1065
+ case "resize-se":
1066
+ return "nwse-resize";
1067
+ case "move":
1068
+ return "grab";
1069
+ default:
1070
+ return "";
1071
+ }
1072
+ }
1073
+
1074
+ private _setCursor(cursor: string, ctx: LegendEventCtx): void {
1075
+ if (cursor !== this._lastCursor) {
1076
+ this._lastCursor = cursor;
1077
+ ctx.setCursor(cursor);
1078
+ }
1079
+ }
1080
+ }
1081
+
1082
+ function round4(v: number): number {
1083
+ return Math.round(v * 10000) / 10000;
1084
+ }