@perspective-dev/viewer-charts 5.2.0 → 5.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdn/perspective-viewer-charts.js +1 -2
- package/dist/cdn/perspective-viewer-charts.js.map +4 -4
- package/dist/esm/axis/legend.d.ts +53 -5
- package/dist/esm/axis/map-ticks.d.ts +15 -0
- package/dist/esm/charts/candlestick/candlestick-interact.d.ts +2 -6
- package/dist/esm/charts/candlestick/candlestick-render.d.ts +3 -0
- package/dist/esm/charts/cartesian/cartesian.d.ts +7 -0
- package/dist/esm/charts/cartesian/glyph.d.ts +2 -1
- package/dist/esm/charts/cartesian/glyphs/density.d.ts +2 -1
- package/dist/esm/charts/cartesian/glyphs/lines.d.ts +2 -1
- package/dist/esm/charts/cartesian/glyphs/points.d.ts +2 -1
- package/dist/esm/charts/cartesian/tooltip-lines.d.ts +1 -1
- package/dist/esm/charts/chart-base.d.ts +24 -9
- package/dist/esm/charts/chart.d.ts +81 -6
- package/dist/esm/charts/common/category-axis-resolver.d.ts +1 -1
- package/dist/esm/charts/common/draw-tooltip-box.d.ts +2 -1
- package/dist/esm/charts/common/tree-chart.d.ts +1 -0
- package/dist/esm/charts/common/tree-interact.d.ts +1 -1
- package/dist/esm/charts/heatmap/heatmap.d.ts +1 -0
- package/dist/esm/charts/series/series-build.d.ts +7 -0
- package/dist/esm/charts/series/series-interact.d.ts +1 -1
- package/dist/esm/charts/series/series-type.d.ts +18 -0
- package/dist/esm/charts/series/series.d.ts +10 -1
- package/dist/esm/charts/sunburst/sunburst-render.d.ts +4 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/interaction/axis-indicators.d.ts +11 -0
- package/dist/esm/interaction/host-sink-dom.d.ts +3 -2
- package/dist/esm/interaction/host-sink-message.d.ts +3 -2
- package/dist/esm/interaction/lazy-tooltip.d.ts +2 -2
- package/dist/esm/interaction/legend-controller.d.ts +246 -0
- package/dist/esm/interaction/tooltip-controller.d.ts +20 -95
- package/dist/esm/interaction/tooltip-grid.d.ts +33 -0
- package/dist/esm/layout/facet-grid.d.ts +6 -0
- package/dist/esm/layout/ticks.d.ts +1 -0
- package/dist/esm/map/tile-source.d.ts +77 -8
- package/dist/esm/perspective-viewer-charts.js +1 -2
- package/dist/esm/perspective-viewer-charts.js.map +4 -4
- package/dist/esm/plugin/charts.d.ts +2 -0
- package/dist/esm/plugin/format-defaults.d.ts +9 -0
- package/dist/esm/plugin/plugin.d.ts +28 -1
- package/dist/esm/theme/gradient.d.ts +32 -0
- package/dist/esm/theme/overrides.d.ts +19 -0
- package/dist/esm/transport/protocol.d.ts +58 -5
- package/dist/esm/transport/renderer-transport.d.ts +3 -0
- package/dist/esm/utils/css.d.ts +4 -0
- package/dist/esm/worker/renderer.worker.d.ts +46 -1
- package/package.json +1 -1
- package/src/css/perspective-viewer-charts.css +0 -1
- package/src/ts/axis/bar-axis.ts +2 -7
- package/src/ts/axis/legend.ts +351 -24
- package/src/ts/axis/map-ticks.ts +94 -0
- package/src/ts/axis/numeric-axis.ts +2 -9
- package/src/ts/charts/candlestick/candlestick-build.ts +1 -0
- package/src/ts/charts/candlestick/candlestick-interact.ts +46 -40
- package/src/ts/charts/candlestick/candlestick-render.ts +81 -10
- package/src/ts/charts/candlestick/candlestick.ts +4 -3
- package/src/ts/charts/cartesian/cartesian-interact.ts +9 -3
- package/src/ts/charts/cartesian/cartesian-render.ts +374 -75
- package/src/ts/charts/cartesian/cartesian.ts +13 -0
- package/src/ts/charts/cartesian/glyph.ts +6 -2
- package/src/ts/charts/cartesian/glyphs/density.ts +2 -2
- package/src/ts/charts/cartesian/glyphs/lines.ts +15 -11
- package/src/ts/charts/cartesian/glyphs/points.ts +9 -5
- package/src/ts/charts/cartesian/tooltip-lines.ts +8 -8
- package/src/ts/charts/chart-base.ts +76 -42
- package/src/ts/charts/chart.ts +111 -7
- package/src/ts/charts/common/category-axis-resolver.ts +44 -5
- package/src/ts/charts/common/draw-tooltip-box.ts +13 -30
- package/src/ts/charts/common/tree-chart.ts +4 -0
- package/src/ts/charts/common/tree-chrome.ts +102 -23
- package/src/ts/charts/common/tree-interact.ts +18 -17
- package/src/ts/charts/heatmap/heatmap-build.ts +7 -1
- package/src/ts/charts/heatmap/heatmap-interact.ts +75 -8
- package/src/ts/charts/heatmap/heatmap-render.ts +105 -24
- package/src/ts/charts/heatmap/heatmap.ts +8 -3
- package/src/ts/charts/map/map.ts +3 -5
- package/src/ts/charts/series/series-build.ts +15 -2
- package/src/ts/charts/series/series-interact.ts +14 -24
- package/src/ts/charts/series/series-render.ts +310 -132
- package/src/ts/charts/series/series-type.ts +65 -0
- package/src/ts/charts/series/series.ts +31 -8
- package/src/ts/charts/sunburst/sunburst-render.ts +16 -1
- package/src/ts/charts/treemap/treemap-render.ts +12 -1
- package/src/ts/data/view-reader.ts +1 -4
- package/src/ts/index.ts +2 -0
- package/src/ts/interaction/axis-indicators.ts +137 -0
- package/src/ts/interaction/host-sink-dom.ts +66 -2
- package/src/ts/interaction/host-sink-message.ts +8 -2
- package/src/ts/interaction/lazy-tooltip.ts +5 -5
- package/src/ts/interaction/legend-controller.ts +1084 -0
- package/src/ts/interaction/tooltip-controller.ts +43 -131
- package/src/ts/interaction/tooltip-grid.ts +187 -0
- package/src/ts/layout/facet-grid.ts +8 -1
- package/src/ts/layout/ticks.ts +48 -12
- package/src/ts/map/tile-loader.ts +16 -1
- package/src/ts/map/tile-source.ts +199 -53
- package/src/ts/map/tile-sources.json +18 -0
- package/src/ts/plugin/charts.ts +40 -6
- package/src/ts/plugin/format-defaults.ts +37 -0
- package/src/ts/plugin/plugin.ts +330 -76
- package/src/ts/shaders/line.vert.glsl +5 -2
- package/src/ts/shaders/scatter.vert.glsl +0 -7
- package/src/ts/theme/gradient.ts +265 -1
- package/src/ts/theme/overrides.ts +76 -0
- package/src/ts/transport/protocol.ts +60 -4
- package/src/ts/transport/renderer-transport.ts +107 -14
- package/src/ts/utils/css.ts +12 -0
- package/src/ts/worker/dispatch.ts +6 -2
- package/src/ts/worker/renderer.worker.ts +191 -7
|
@@ -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
|
+
}
|