insomni-plot 0.1.0-alpha.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.
- package/LICENSE.md +674 -0
- package/README.md +81 -0
- package/dist/core.d.mts +340 -0
- package/dist/core.mjs +1047 -0
- package/dist/index.d.mts +3426 -0
- package/dist/index.mjs +12762 -0
- package/dist/interactions-DEFL_F4E.mjs +5395 -0
- package/dist/range-presets-CzECsu3V.d.mts +1523 -0
- package/package.json +34 -0
- package/src/annotations.d.ts +121 -0
- package/src/annotations.ts +438 -0
- package/src/axis.d.ts +184 -0
- package/src/axis.test.ts +131 -0
- package/src/axis.ts +765 -0
- package/src/colorbar.d.ts +69 -0
- package/src/colorbar.ts +294 -0
- package/src/colors.d.ts +57 -0
- package/src/colors.test.ts +28 -0
- package/src/colors.ts +486 -0
- package/src/core.ts +299 -0
- package/src/format.d.ts +54 -0
- package/src/format.ts +138 -0
- package/src/grammar/accessibility.d.ts +147 -0
- package/src/grammar/accessibility.test.ts +199 -0
- package/src/grammar/accessibility.ts +443 -0
- package/src/grammar/aes.d.ts +35 -0
- package/src/grammar/aes.test.ts +75 -0
- package/src/grammar/aes.ts +120 -0
- package/src/grammar/annotations.d.ts +86 -0
- package/src/grammar/annotations.test.ts +68 -0
- package/src/grammar/annotations.ts +336 -0
- package/src/grammar/attach-brush.d.ts +44 -0
- package/src/grammar/attach-brush.test.ts +214 -0
- package/src/grammar/attach-brush.ts +111 -0
- package/src/grammar/attach-presets.d.ts +33 -0
- package/src/grammar/attach-presets.test.ts +106 -0
- package/src/grammar/attach-presets.ts +215 -0
- package/src/grammar/chart.d.ts +952 -0
- package/src/grammar/chart.test.ts +118 -0
- package/src/grammar/chart.ts +1172 -0
- package/src/grammar/color-utils.d.ts +29 -0
- package/src/grammar/color-utils.test.ts +53 -0
- package/src/grammar/color-utils.ts +66 -0
- package/src/grammar/constants.d.ts +45 -0
- package/src/grammar/constants.ts +61 -0
- package/src/grammar/coord.d.ts +183 -0
- package/src/grammar/coord.test.ts +355 -0
- package/src/grammar/coord.ts +619 -0
- package/src/grammar/data/pivot.d.ts +57 -0
- package/src/grammar/data/pivot.ts +107 -0
- package/src/grammar/emphasis-driver.d.ts +69 -0
- package/src/grammar/emphasis-driver.test.ts +199 -0
- package/src/grammar/emphasis-driver.ts +205 -0
- package/src/grammar/equality.d.ts +3 -0
- package/src/grammar/equality.ts +40 -0
- package/src/grammar/facet.d.ts +63 -0
- package/src/grammar/facet.test.ts +60 -0
- package/src/grammar/facet.ts +175 -0
- package/src/grammar/geoms/_categorical.d.ts +94 -0
- package/src/grammar/geoms/_categorical.ts +0 -0
- package/src/grammar/geoms/_distribution.d.ts +52 -0
- package/src/grammar/geoms/_distribution.ts +125 -0
- package/src/grammar/geoms/_mark.d.ts +69 -0
- package/src/grammar/geoms/_mark.ts +136 -0
- package/src/grammar/geoms/_shape.d.ts +41 -0
- package/src/grammar/geoms/_shape.ts +74 -0
- package/src/grammar/geoms/aggregate.d.ts +95 -0
- package/src/grammar/geoms/aggregate.test.ts +554 -0
- package/src/grammar/geoms/aggregate.ts +840 -0
- package/src/grammar/geoms/area.d.ts +32 -0
- package/src/grammar/geoms/area.test.ts +165 -0
- package/src/grammar/geoms/area.ts +578 -0
- package/src/grammar/geoms/band.d.ts +27 -0
- package/src/grammar/geoms/band.test.ts +57 -0
- package/src/grammar/geoms/band.ts +126 -0
- package/src/grammar/geoms/bar.d.ts +56 -0
- package/src/grammar/geoms/bar.test.ts +367 -0
- package/src/grammar/geoms/bar.ts +1054 -0
- package/src/grammar/geoms/boxplot.d.ts +129 -0
- package/src/grammar/geoms/boxplot.test.ts +299 -0
- package/src/grammar/geoms/boxplot.ts +834 -0
- package/src/grammar/geoms/connected-scatter.d.ts +27 -0
- package/src/grammar/geoms/connected-scatter.test.ts +157 -0
- package/src/grammar/geoms/connected-scatter.ts +63 -0
- package/src/grammar/geoms/emphasis.d.ts +76 -0
- package/src/grammar/geoms/emphasis.test.ts +135 -0
- package/src/grammar/geoms/emphasis.ts +162 -0
- package/src/grammar/geoms/histogram.d.ts +75 -0
- package/src/grammar/geoms/histogram.test.ts +262 -0
- package/src/grammar/geoms/histogram.ts +740 -0
- package/src/grammar/geoms/index.d.ts +20 -0
- package/src/grammar/geoms/index.ts +77 -0
- package/src/grammar/geoms/interval.d.ts +31 -0
- package/src/grammar/geoms/interval.test.ts +154 -0
- package/src/grammar/geoms/interval.ts +342 -0
- package/src/grammar/geoms/line.d.ts +38 -0
- package/src/grammar/geoms/line.test.ts +247 -0
- package/src/grammar/geoms/line.ts +659 -0
- package/src/grammar/geoms/point.d.ts +57 -0
- package/src/grammar/geoms/point.test.ts +163 -0
- package/src/grammar/geoms/point.ts +545 -0
- package/src/grammar/geoms/polar.test.ts +216 -0
- package/src/grammar/geoms/ribbon.d.ts +21 -0
- package/src/grammar/geoms/ribbon.test.ts +170 -0
- package/src/grammar/geoms/ribbon.ts +87 -0
- package/src/grammar/geoms/ridgeline.d.ts +89 -0
- package/src/grammar/geoms/ridgeline.test.ts +247 -0
- package/src/grammar/geoms/ridgeline.ts +1164 -0
- package/src/grammar/geoms/rolling.d.ts +43 -0
- package/src/grammar/geoms/rolling.test.ts +217 -0
- package/src/grammar/geoms/rolling.ts +387 -0
- package/src/grammar/geoms/rug.d.ts +28 -0
- package/src/grammar/geoms/rug.test.ts +126 -0
- package/src/grammar/geoms/rug.ts +214 -0
- package/src/grammar/geoms/rule.d.ts +23 -0
- package/src/grammar/geoms/rule.test.ts +69 -0
- package/src/grammar/geoms/rule.ts +212 -0
- package/src/grammar/geoms/smooth.d.ts +54 -0
- package/src/grammar/geoms/smooth.test.ts +78 -0
- package/src/grammar/geoms/smooth.ts +337 -0
- package/src/grammar/geoms/text.d.ts +29 -0
- package/src/grammar/geoms/text.test.ts +64 -0
- package/src/grammar/geoms/text.ts +234 -0
- package/src/grammar/geoms/tile.d.ts +61 -0
- package/src/grammar/geoms/tile.test.ts +157 -0
- package/src/grammar/geoms/tile.ts +621 -0
- package/src/grammar/geoms/types.d.ts +319 -0
- package/src/grammar/geoms/types.ts +362 -0
- package/src/grammar/geoms/violin.d.ts +85 -0
- package/src/grammar/geoms/violin.test.ts +187 -0
- package/src/grammar/geoms/violin.ts +672 -0
- package/src/grammar/index.d.ts +22 -0
- package/src/grammar/index.ts +269 -0
- package/src/grammar/interactions/_disposable.d.ts +5 -0
- package/src/grammar/interactions/_disposable.ts +23 -0
- package/src/grammar/interactions/_z.d.ts +4 -0
- package/src/grammar/interactions/_z.ts +16 -0
- package/src/grammar/interactions/brush-selection.test.ts +262 -0
- package/src/grammar/interactions/brush.d.ts +63 -0
- package/src/grammar/interactions/brush.test.ts +483 -0
- package/src/grammar/interactions/brush.ts +452 -0
- package/src/grammar/interactions/crosshair.d.ts +19 -0
- package/src/grammar/interactions/crosshair.test.ts +127 -0
- package/src/grammar/interactions/crosshair.ts +76 -0
- package/src/grammar/interactions/hit-layer.d.ts +64 -0
- package/src/grammar/interactions/hit-layer.ts +246 -0
- package/src/grammar/interactions/legend.d.ts +19 -0
- package/src/grammar/interactions/legend.ts +101 -0
- package/src/grammar/interactions/menu.d.ts +93 -0
- package/src/grammar/interactions/menu.test.ts +373 -0
- package/src/grammar/interactions/menu.ts +342 -0
- package/src/grammar/interactions/selection.d.ts +25 -0
- package/src/grammar/interactions/selection.test.ts +289 -0
- package/src/grammar/interactions/selection.ts +142 -0
- package/src/grammar/interactions/series-readout.d.ts +91 -0
- package/src/grammar/interactions/series-readout.test.ts +668 -0
- package/src/grammar/interactions/series-readout.ts +422 -0
- package/src/grammar/interactions/series-snap.d.ts +70 -0
- package/src/grammar/interactions/series-snap.test.ts +214 -0
- package/src/grammar/interactions/series-snap.ts +218 -0
- package/src/grammar/interactions/tooltip-axis.test.ts +176 -0
- package/src/grammar/interactions/tooltip-touch.browser.test.ts +49 -0
- package/src/grammar/interactions/tooltip-touch.test.ts +161 -0
- package/src/grammar/interactions/tooltip.d.ts +140 -0
- package/src/grammar/interactions/tooltip.test.ts +406 -0
- package/src/grammar/interactions/tooltip.ts +622 -0
- package/src/grammar/interactions/transitions.d.ts +34 -0
- package/src/grammar/interactions/transitions.test.ts +172 -0
- package/src/grammar/interactions/transitions.ts +160 -0
- package/src/grammar/layout.d.ts +68 -0
- package/src/grammar/layout.ts +186 -0
- package/src/grammar/legend-merge.test.ts +332 -0
- package/src/grammar/mount.d.ts +78 -0
- package/src/grammar/mount.test.ts +479 -0
- package/src/grammar/mount.ts +2112 -0
- package/src/grammar/palettes.d.ts +54 -0
- package/src/grammar/palettes.test.ts +80 -0
- package/src/grammar/palettes.ts +167 -0
- package/src/grammar/pan-zoom.test.ts +398 -0
- package/src/grammar/phylo.d.ts +65 -0
- package/src/grammar/phylo.test.ts +59 -0
- package/src/grammar/phylo.ts +112 -0
- package/src/grammar/pipeline.auto-ticks.test.ts +40 -0
- package/src/grammar/pipeline.d.ts +158 -0
- package/src/grammar/pipeline.test.ts +463 -0
- package/src/grammar/pipeline.ts +1233 -0
- package/src/grammar/profiling.d.ts +8 -0
- package/src/grammar/profiling.ts +24 -0
- package/src/grammar/scales.d.ts +188 -0
- package/src/grammar/scales.test.ts +181 -0
- package/src/grammar/scales.ts +800 -0
- package/src/grammar/svg.d.ts +3 -0
- package/src/grammar/svg.ts +39 -0
- package/src/grammar/theme.d.ts +261 -0
- package/src/grammar/theme.test.ts +105 -0
- package/src/grammar/theme.ts +490 -0
- package/src/heatmap/cpu.ts +109 -0
- package/src/heatmap/gpu.ts +565 -0
- package/src/heatmap/types.ts +177 -0
- package/src/heatmap.browser.test.ts +308 -0
- package/src/heatmap.test.ts +320 -0
- package/src/heatmap.ts +123 -0
- package/src/index.d.ts +1 -0
- package/src/index.ts +8 -0
- package/src/interactions.d.ts +48 -0
- package/src/interactions.test.ts +226 -0
- package/src/interactions.ts +394 -0
- package/src/layout/box.d.ts +48 -0
- package/src/layout/box.test.ts +107 -0
- package/src/layout/box.ts +143 -0
- package/src/legend.d.ts +115 -0
- package/src/legend.ts +422 -0
- package/src/marks/curve.d.ts +43 -0
- package/src/marks/curve.ts +244 -0
- package/src/marks/stack.d.ts +53 -0
- package/src/marks/stack.ts +184 -0
- package/src/marks.d.ts +273 -0
- package/src/marks.test.ts +541 -0
- package/src/marks.ts +1292 -0
- package/src/navigator.test.ts +174 -0
- package/src/navigator.ts +393 -0
- package/src/range-presets.d.ts +113 -0
- package/src/range-presets.test.ts +345 -0
- package/src/range-presets.ts +349 -0
- package/src/scales.d.ts +98 -0
- package/src/scales.test.ts +103 -0
- package/src/scales.ts +695 -0
- package/src/stats/index.d.ts +200 -0
- package/src/stats/index.test.ts +349 -0
- package/src/stats/index.ts +740 -0
- package/src/stats/regression.d.ts +38 -0
- package/src/stats/regression.test.ts +56 -0
- package/src/stats/regression.ts +396 -0
- package/src/stats/rolling-window.d.ts +55 -0
- package/src/stats/rolling-window.test.ts +237 -0
- package/src/stats/rolling-window.ts +256 -0
- package/src/test-setup.ts +19 -0
- package/src/viewport/axis-state.d.ts +72 -0
- package/src/viewport/axis-state.ts +476 -0
- package/src/viewport.d.ts +170 -0
- package/src/viewport.test.ts +363 -0
- package/src/viewport.ts +510 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Grammar-level context-menu wiring
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Subscribes to `InteractionManager.onContextMenuRequest` (right-click, touch
|
|
5
|
+
// long-press, keyboard) and resolves each event against the shared hit-layer
|
|
6
|
+
// for a `{ datum, mark, screenX, screenY, source, mods }` payload.
|
|
7
|
+
//
|
|
8
|
+
// Two modes:
|
|
9
|
+
// 1. `items` + `onAction` — the library renders a GPU-drawn `Menu` inside
|
|
10
|
+
// the canvas, registers an InteractionNode for hover/click routing, and
|
|
11
|
+
// closes on outside-press / Escape / pan-zoom / item click. No DOM.
|
|
12
|
+
// 2. `onTrigger` only — emits an event and lets the consumer render. Kept
|
|
13
|
+
// for parity with the old API and for hosts that need full control.
|
|
14
|
+
//
|
|
15
|
+
// In `"nearest-point"` and `"any-mark"` modes, the trigger is suppressed when
|
|
16
|
+
// no mark is hit. Matches hover-tooltip / crosshair behavior — no popup
|
|
17
|
+
// without an anchor. Only `"background"` mode fires unconditionally.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type ContextMenuOpts,
|
|
21
|
+
createMenu,
|
|
22
|
+
type GestureEventInfo,
|
|
23
|
+
type GlyphAtlas,
|
|
24
|
+
type InteractionManager,
|
|
25
|
+
type InteractionNode,
|
|
26
|
+
type Invalidator,
|
|
27
|
+
type Layer,
|
|
28
|
+
type Menu,
|
|
29
|
+
type MenuContent,
|
|
30
|
+
type MenuItem,
|
|
31
|
+
type MenuPlacement,
|
|
32
|
+
type MenuStyle,
|
|
33
|
+
type Mods,
|
|
34
|
+
} from "insomni";
|
|
35
|
+
import type { ContextMenuItem, ContextMenuTriggerPayload } from "../chart.ts";
|
|
36
|
+
import type { GeomKind, HoveredHit } from "../geoms/types.ts";
|
|
37
|
+
import type { Theme } from "../theme.ts";
|
|
38
|
+
import { createDisposable } from "./_disposable.ts";
|
|
39
|
+
import { TEXT_WIDTH_FALLBACK_RATIO } from "../../format.ts";
|
|
40
|
+
import type { GrammarHitLayer } from "./hit-layer.ts";
|
|
41
|
+
|
|
42
|
+
export type ContextMenuHitMode = "nearest-point" | "any-mark" | "background";
|
|
43
|
+
|
|
44
|
+
export interface ContextMenuTriggerInfo {
|
|
45
|
+
/**
|
|
46
|
+
* Resolved hit at the trigger point, or `null` for a background trigger.
|
|
47
|
+
* `nearest-point` resolution uses each compiled hit-test's `pickRadius`;
|
|
48
|
+
* `any-mark` additionally includes region-based geoms (bars, tiles).
|
|
49
|
+
* `background` mode always returns `null`.
|
|
50
|
+
*/
|
|
51
|
+
hit: HoveredHit | null;
|
|
52
|
+
/** Convenience: the resolved data row, or `null` for background. */
|
|
53
|
+
// oxlint-disable-next-line no-redundant-type-constituents -- `| null` is intentional documentation that null means "no hit" vs. an actual data row
|
|
54
|
+
datum: unknown | null;
|
|
55
|
+
/** Convenience: the geom kind that produced the hit (e.g. "point", "bar"). */
|
|
56
|
+
mark: GeomKind | null;
|
|
57
|
+
/** Element-local CSS px where the menu should anchor. */
|
|
58
|
+
screenX: number;
|
|
59
|
+
screenY: number;
|
|
60
|
+
source: "mouse" | "touch" | "pen" | "keyboard";
|
|
61
|
+
mods: Mods;
|
|
62
|
+
originalEvent: Event | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GrammarContextMenuOptions {
|
|
66
|
+
/**
|
|
67
|
+
* Resolution policy for the trigger point. Default `"nearest-point"`.
|
|
68
|
+
*
|
|
69
|
+
* - `"nearest-point"`: walks point-based geoms (scatter, line vertices, ...)
|
|
70
|
+
* only. Honors each compiled hit-test's `pickRadius`. **Suppresses the
|
|
71
|
+
* trigger entirely when no mark is hit.**
|
|
72
|
+
* - `"any-mark"`: includes region-based geoms (bars, histograms, tiles).
|
|
73
|
+
* Same suppression behavior as `"nearest-point"`.
|
|
74
|
+
* - `"background"`: never resolves to a mark; `hit` / `datum` / `mark` are
|
|
75
|
+
* always null. Fires unconditionally — useful for empty-space menus.
|
|
76
|
+
*/
|
|
77
|
+
hitMode?: ContextMenuHitMode;
|
|
78
|
+
/**
|
|
79
|
+
* Optional gate: when this returns true (e.g. an active pan/zoom drag), the
|
|
80
|
+
* context-menu trigger is suppressed. The InteractionManager already cancels
|
|
81
|
+
* touch long-press on drag promotion, but a right-click during a left-button
|
|
82
|
+
* drag still arrives as a native `contextmenu` event — this hook lets the
|
|
83
|
+
* mount tie suppression to its own panning state.
|
|
84
|
+
*/
|
|
85
|
+
isSuppressed?(): boolean;
|
|
86
|
+
/** Override default ContextMenuOpts (holdMs, slopPx) for touch long-press. */
|
|
87
|
+
managerOpts?: ContextMenuOpts;
|
|
88
|
+
/** Low-level trigger emitter (fires alongside the rendered menu, if any). */
|
|
89
|
+
onTrigger?(info: ContextMenuTriggerInfo): void;
|
|
90
|
+
/**
|
|
91
|
+
* Menu items (static or per-trigger). When set, the library renders a
|
|
92
|
+
* canvas menu and routes clicks through `onAction`. When omitted, only
|
|
93
|
+
* `onTrigger` fires.
|
|
94
|
+
*/
|
|
95
|
+
items?:
|
|
96
|
+
| readonly ContextMenuItem[]
|
|
97
|
+
| ((info: ContextMenuTriggerInfo) => readonly ContextMenuItem[]);
|
|
98
|
+
onAction?(itemId: string, info: ContextMenuTriggerInfo): void;
|
|
99
|
+
placement?: MenuPlacement;
|
|
100
|
+
style?: MenuStyle;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface GrammarContextMenuDeps {
|
|
104
|
+
manager: InteractionManager;
|
|
105
|
+
hitLayer: GrammarHitLayer;
|
|
106
|
+
/** Required only when `items` is configured. Where the menu draws. */
|
|
107
|
+
hudLayer?: () => Layer;
|
|
108
|
+
/** Required only when `items` is configured. For text measurement. */
|
|
109
|
+
atlas?: () => GlyphAtlas | undefined;
|
|
110
|
+
/** Required only when `items` is configured. For default styling tokens. */
|
|
111
|
+
theme?: () => Theme;
|
|
112
|
+
/** Required only when `items` is configured. Bounds for menu clamp/flip. */
|
|
113
|
+
bounds?: () => { x: number; y: number; width: number; height: number };
|
|
114
|
+
/** Required only when `items` is configured. Wakes the loop on fade ticks. */
|
|
115
|
+
invalidator?: Invalidator;
|
|
116
|
+
/**
|
|
117
|
+
* Subscribe to pan/zoom changes (so the menu closes when the chart moves
|
|
118
|
+
* under it). Returns an unsubscribe, or null when pan/zoom is disabled.
|
|
119
|
+
*/
|
|
120
|
+
onViewportChange?(cb: () => void): (() => void) | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface GrammarContextMenu {
|
|
124
|
+
/** Tick fade timers. */
|
|
125
|
+
step(dt: number): void;
|
|
126
|
+
/** Draw the menu into the hud layer. */
|
|
127
|
+
draw(): void;
|
|
128
|
+
dispose(): void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function toMenuItem(it: ContextMenuItem): MenuItem {
|
|
132
|
+
return {
|
|
133
|
+
id: it.id,
|
|
134
|
+
label: it.label,
|
|
135
|
+
separator: it.separator,
|
|
136
|
+
disabled: it.disabled,
|
|
137
|
+
danger: it.danger,
|
|
138
|
+
swatch: it.swatch,
|
|
139
|
+
kbd: it.kbd,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createGrammarContextMenu(
|
|
144
|
+
deps: GrammarContextMenuDeps,
|
|
145
|
+
opts: GrammarContextMenuOptions,
|
|
146
|
+
): GrammarContextMenu {
|
|
147
|
+
const hitMode: ContextMenuHitMode = opts.hitMode ?? "nearest-point";
|
|
148
|
+
const isSuppressed = opts.isSuppressed ? () => opts.isSuppressed!() : undefined;
|
|
149
|
+
|
|
150
|
+
// ----- Menu primitive (only when items are configured) ----------------------
|
|
151
|
+
const itemsResolver = opts.items;
|
|
152
|
+
const onAction = opts.onAction
|
|
153
|
+
? (id: string, info: ContextMenuTriggerInfo) => opts.onAction!(id, info)
|
|
154
|
+
: undefined;
|
|
155
|
+
const hasMenu = itemsResolver !== undefined;
|
|
156
|
+
|
|
157
|
+
let menu: Menu | null = null;
|
|
158
|
+
let menuNode: InteractionNode | null = null;
|
|
159
|
+
let lastTriggerInfo: ContextMenuTriggerInfo | null = null;
|
|
160
|
+
let unsubBackground: (() => void) | null = null;
|
|
161
|
+
let unsubViewport: (() => void) | null = null;
|
|
162
|
+
let keydownHandler: ((ev: KeyboardEvent) => void) | null = null;
|
|
163
|
+
let outsideDownHandler: ((ev: PointerEvent) => void) | null = null;
|
|
164
|
+
let drawHudLayer: (() => Layer) | null = null;
|
|
165
|
+
|
|
166
|
+
if (hasMenu) {
|
|
167
|
+
if (!deps.hudLayer || !deps.bounds) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
"createGrammarContextMenu: `items` requires `hudLayer` and `bounds` deps for canvas rendering",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const hudLayer = deps.hudLayer;
|
|
173
|
+
const boundsFn = deps.bounds;
|
|
174
|
+
const atlasFn = deps.atlas;
|
|
175
|
+
const invalidator = deps.invalidator;
|
|
176
|
+
menu = createMenu({
|
|
177
|
+
placement: opts.placement ?? "bottom-right",
|
|
178
|
+
bounds: boundsFn,
|
|
179
|
+
measure: (text, fontSize) => {
|
|
180
|
+
const a = atlasFn?.();
|
|
181
|
+
if (a) return a.measureText(text, { fontSize, simple: true });
|
|
182
|
+
return { width: text.length * fontSize * TEXT_WIDTH_FALLBACK_RATIO, height: fontSize };
|
|
183
|
+
},
|
|
184
|
+
style: opts.style,
|
|
185
|
+
invalidator,
|
|
186
|
+
});
|
|
187
|
+
// Capture hudLayer for the draw() closure below — `deps.hudLayer` is the
|
|
188
|
+
// canonical getter (re-resolves the layer if font-load swapped it).
|
|
189
|
+
drawHudLayer = hudLayer;
|
|
190
|
+
|
|
191
|
+
const closeMenu = () => {
|
|
192
|
+
menu!.hide();
|
|
193
|
+
menuNode?.update({ enabled: false });
|
|
194
|
+
lastTriggerInfo = null;
|
|
195
|
+
invalidator?.invalidate();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Interaction node — bounds track the live menu box, only enabled while
|
|
199
|
+
// visible. zIndex sits above everything else (hit cloud z is in the
|
|
200
|
+
// ~2000 range; we go well above).
|
|
201
|
+
menuNode = deps.manager.add({
|
|
202
|
+
zIndex: 10_000,
|
|
203
|
+
space: "ui",
|
|
204
|
+
enabled: false,
|
|
205
|
+
cursor: "pointer",
|
|
206
|
+
bounds: () => {
|
|
207
|
+
const b = menu!.getBounds();
|
|
208
|
+
return b ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
209
|
+
},
|
|
210
|
+
onHoverMove: (e) => {
|
|
211
|
+
menu!.setHoverPosition({ x: e.x, y: e.y });
|
|
212
|
+
},
|
|
213
|
+
onHoverLeave: () => {
|
|
214
|
+
menu!.setHoverPosition(null);
|
|
215
|
+
},
|
|
216
|
+
onPress: (e) => {
|
|
217
|
+
const id = menu!.pickItemAt(e.x, e.y);
|
|
218
|
+
const info = lastTriggerInfo;
|
|
219
|
+
closeMenu();
|
|
220
|
+
if (id !== null && info && onAction) onAction(id, info);
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Close on background tap (clicks outside any node) — natural "click
|
|
225
|
+
// anywhere outside" behavior.
|
|
226
|
+
unsubBackground = deps.manager.onBackgroundTap(() => {
|
|
227
|
+
if (menu!.visible) closeMenu();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Close on pan/zoom — the menu anchor would drift otherwise.
|
|
231
|
+
if (deps.onViewportChange) {
|
|
232
|
+
unsubViewport =
|
|
233
|
+
deps.onViewportChange(() => {
|
|
234
|
+
if (menu!.visible) closeMenu();
|
|
235
|
+
}) ?? null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Close on Escape. Manager doesn't expose generic keydown; document
|
|
239
|
+
// listener is the standard pattern (matches the previous DOM popover).
|
|
240
|
+
if (typeof document !== "undefined") {
|
|
241
|
+
keydownHandler = (ev: KeyboardEvent) => {
|
|
242
|
+
if (ev.key === "Escape" && menu!.visible) {
|
|
243
|
+
ev.preventDefault();
|
|
244
|
+
closeMenu();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
document.addEventListener("keydown", keydownHandler);
|
|
248
|
+
|
|
249
|
+
// Close on pointerdown outside the canvas. The background-tap above
|
|
250
|
+
// covers clicks on the canvas itself; this catches clicks on host
|
|
251
|
+
// chrome (chips, monitor panel) which would otherwise leave the menu
|
|
252
|
+
// floating.
|
|
253
|
+
outsideDownHandler = (ev: PointerEvent) => {
|
|
254
|
+
if (!menu!.visible) return;
|
|
255
|
+
const elt = deps.manager.element;
|
|
256
|
+
const target = ev.target;
|
|
257
|
+
if (target instanceof Node && elt.contains(target)) return;
|
|
258
|
+
closeMenu();
|
|
259
|
+
};
|
|
260
|
+
document.addEventListener("pointerdown", outsideDownHandler, true);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ----- Native context-menu request handling --------------------------------
|
|
265
|
+
|
|
266
|
+
const unsubscribe = deps.manager.onContextMenuRequest((info: GestureEventInfo) => {
|
|
267
|
+
if (isSuppressed?.()) return;
|
|
268
|
+
let hit: HoveredHit | null = null;
|
|
269
|
+
if (hitMode !== "background") {
|
|
270
|
+
const mode = hitMode === "nearest-point" ? "point" : "any";
|
|
271
|
+
const picked = deps.hitLayer.pickAt(info.x, info.y, { mode });
|
|
272
|
+
hit = picked ? picked.hit : null;
|
|
273
|
+
// Suppress entirely when no mark is hit. Matches hover behavior. Also
|
|
274
|
+
// dismiss any currently-open menu so the user can right-click empty
|
|
275
|
+
// space to close it without first clicking elsewhere.
|
|
276
|
+
if (hit === null) {
|
|
277
|
+
if (menu?.visible) {
|
|
278
|
+
menu.hide();
|
|
279
|
+
menuNode?.update({ enabled: false });
|
|
280
|
+
lastTriggerInfo = null;
|
|
281
|
+
deps.invalidator?.invalidate();
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const trigger: ContextMenuTriggerInfo = {
|
|
287
|
+
hit,
|
|
288
|
+
datum: hit ? (hit.data[hit.dataIndex] ?? null) : null,
|
|
289
|
+
mark: hit ? hit.geomKind : null,
|
|
290
|
+
screenX: info.x,
|
|
291
|
+
screenY: info.y,
|
|
292
|
+
source: info.source,
|
|
293
|
+
mods: info.mods,
|
|
294
|
+
originalEvent: info.originalEvent,
|
|
295
|
+
};
|
|
296
|
+
opts.onTrigger?.(trigger);
|
|
297
|
+
if (menu && itemsResolver) {
|
|
298
|
+
const resolved = typeof itemsResolver === "function" ? itemsResolver(trigger) : itemsResolver;
|
|
299
|
+
const content: MenuContent = { items: resolved.map(toMenuItem) };
|
|
300
|
+
lastTriggerInfo = trigger;
|
|
301
|
+
menu.show({ x: info.x, y: info.y }, content);
|
|
302
|
+
menuNode?.update({ enabled: true });
|
|
303
|
+
deps.invalidator?.invalidate();
|
|
304
|
+
}
|
|
305
|
+
}, opts.managerOpts);
|
|
306
|
+
|
|
307
|
+
const d = createDisposable(() => {
|
|
308
|
+
// Remove document-scoped listeners FIRST: they outlive this component if
|
|
309
|
+
// any later cleanup step throws, and leaving them attached would leak
|
|
310
|
+
// closures over `menu`/`deps` and keep dispatching to a torn-down menu.
|
|
311
|
+
try {
|
|
312
|
+
if (keydownHandler && typeof document !== "undefined") {
|
|
313
|
+
document.removeEventListener("keydown", keydownHandler);
|
|
314
|
+
}
|
|
315
|
+
if (outsideDownHandler && typeof document !== "undefined") {
|
|
316
|
+
document.removeEventListener("pointerdown", outsideDownHandler, true);
|
|
317
|
+
}
|
|
318
|
+
} finally {
|
|
319
|
+
unsubscribe();
|
|
320
|
+
unsubBackground?.();
|
|
321
|
+
unsubViewport?.();
|
|
322
|
+
menuNode?.destroy();
|
|
323
|
+
menu?.dispose();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
return {
|
|
327
|
+
step(dt) {
|
|
328
|
+
if (d.isDisposed) return;
|
|
329
|
+
menu?.step(dt);
|
|
330
|
+
},
|
|
331
|
+
draw() {
|
|
332
|
+
if (d.isDisposed || !menu || !drawHudLayer) return;
|
|
333
|
+
menu.draw(drawHudLayer());
|
|
334
|
+
},
|
|
335
|
+
dispose: () => d.dispose(),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Cross-package type alias — the public `ContextMenuTriggerPayload` exported
|
|
340
|
+
// from chart.ts shares this shape but is referenced through a different
|
|
341
|
+
// import path. Keep the structural type re-exposed here for grammar tests.
|
|
342
|
+
export type { ContextMenuTriggerPayload };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type InteractionManager } from "insomni";
|
|
2
|
+
import type { CompiledHitTest, HoveredHit } from "../geoms/types.ts";
|
|
3
|
+
import type { GrammarHitLayer } from "./hit-layer.ts";
|
|
4
|
+
export interface GrammarSelectionOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Fired whenever the selection set changes. Receives a fresh array of
|
|
7
|
+
* `HoveredHit`s (one per currently-selected row) so callers can mirror it
|
|
8
|
+
* onto a public signal or run side-effects.
|
|
9
|
+
*/
|
|
10
|
+
onChange?(selected: HoveredHit[]): void;
|
|
11
|
+
}
|
|
12
|
+
export interface GrammarSelectionDeps {
|
|
13
|
+
manager: InteractionManager;
|
|
14
|
+
hitLayer: GrammarHitLayer;
|
|
15
|
+
}
|
|
16
|
+
export interface GrammarSelection {
|
|
17
|
+
/** Refresh id→position registry after each pipeline run. */
|
|
18
|
+
sync<T>(hits: readonly CompiledHitTest<T>[]): void;
|
|
19
|
+
/** Read-only snapshot of the current selection. */
|
|
20
|
+
current(): HoveredHit[];
|
|
21
|
+
/** Imperatively clear the selection (e.g., from outside on Escape). */
|
|
22
|
+
clear(): void;
|
|
23
|
+
dispose(): void;
|
|
24
|
+
}
|
|
25
|
+
export declare function createGrammarSelection(deps: GrammarSelectionDeps, opts?: GrammarSelectionOptions): GrammarSelection;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import type { InteractionManager, PointerInfo } from "insomni";
|
|
2
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
3
|
+
|
|
4
|
+
import type { CompiledHitTest, HoveredHit } from "../geoms/types.ts";
|
|
5
|
+
import type { GrammarHitLayer, HitEventContext, HitLayerSubscriber } from "./hit-layer.ts";
|
|
6
|
+
import { createGrammarSelection } from "./selection.ts";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Test stubs — fake manager (only needed for onBackgroundTap) and fake hit
|
|
10
|
+
// layer that captures subscribers and lets tests fire press events directly.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
function fakeManager(): { manager: InteractionManager; fireBackgroundTap: () => void } {
|
|
14
|
+
let bgHandler: (() => void) | null = null;
|
|
15
|
+
const manager = {
|
|
16
|
+
onBackgroundTap(handler: () => void) {
|
|
17
|
+
bgHandler = handler;
|
|
18
|
+
return () => {
|
|
19
|
+
if (bgHandler === handler) bgHandler = null;
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
} as unknown as InteractionManager;
|
|
23
|
+
return { manager, fireBackgroundTap: () => bgHandler?.() };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fakeHitLayer(): {
|
|
27
|
+
layer: GrammarHitLayer;
|
|
28
|
+
subscribers: HitLayerSubscriber[];
|
|
29
|
+
firePress: <T>(compiled: CompiledHitTest<T>, hitIndex: number, pointer: PointerInfo) => void;
|
|
30
|
+
} {
|
|
31
|
+
const subscribers: HitLayerSubscriber[] = [];
|
|
32
|
+
const layer: GrammarHitLayer = {
|
|
33
|
+
sync() {},
|
|
34
|
+
subscribe(sub) {
|
|
35
|
+
subscribers.push(sub);
|
|
36
|
+
return () => {
|
|
37
|
+
const i = subscribers.indexOf(sub);
|
|
38
|
+
if (i >= 0) subscribers.splice(i, 1);
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
state() {
|
|
42
|
+
return { active: null };
|
|
43
|
+
},
|
|
44
|
+
subscribeState() {
|
|
45
|
+
return () => {};
|
|
46
|
+
},
|
|
47
|
+
pickAt() {
|
|
48
|
+
return null;
|
|
49
|
+
},
|
|
50
|
+
dispose() {
|
|
51
|
+
subscribers.length = 0;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
layer,
|
|
56
|
+
subscribers,
|
|
57
|
+
firePress: (compiled, hitIndex, pointer) => {
|
|
58
|
+
const px = compiled.positions[hitIndex * 2]!;
|
|
59
|
+
const py = compiled.positions[hitIndex * 2 + 1]!;
|
|
60
|
+
const hit: HoveredHit = {
|
|
61
|
+
geomKind: compiled.geomKind,
|
|
62
|
+
dataIndex: compiled.dataIndex[hitIndex] ?? hitIndex,
|
|
63
|
+
seriesKey: compiled.seriesKey?.[hitIndex],
|
|
64
|
+
data: compiled.data as readonly unknown[],
|
|
65
|
+
x: px,
|
|
66
|
+
y: py,
|
|
67
|
+
};
|
|
68
|
+
const ctx: HitEventContext = {
|
|
69
|
+
hit,
|
|
70
|
+
compiled: compiled as CompiledHitTest<unknown>,
|
|
71
|
+
hitIndex,
|
|
72
|
+
pointer,
|
|
73
|
+
};
|
|
74
|
+
for (const sub of subscribers) sub.onPress?.(ctx);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const noMods = { shift: false, ctrl: false, meta: false, alt: false };
|
|
80
|
+
function pointerAt(x: number, y: number, mods = noMods): PointerInfo {
|
|
81
|
+
return {
|
|
82
|
+
pointerId: 1,
|
|
83
|
+
type: "mouse",
|
|
84
|
+
x,
|
|
85
|
+
y,
|
|
86
|
+
localX: x,
|
|
87
|
+
localY: y,
|
|
88
|
+
buttons: 1,
|
|
89
|
+
mods,
|
|
90
|
+
stopPropagation: () => {},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Row {
|
|
95
|
+
x: number;
|
|
96
|
+
y: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hitFor(
|
|
100
|
+
positions: number[],
|
|
101
|
+
indices: number[],
|
|
102
|
+
data: readonly Row[],
|
|
103
|
+
seriesKey?: (string | undefined)[],
|
|
104
|
+
): CompiledHitTest<Row> {
|
|
105
|
+
return {
|
|
106
|
+
geomKind: "point",
|
|
107
|
+
positions: Float32Array.from(positions),
|
|
108
|
+
dataIndex: Int32Array.from(indices),
|
|
109
|
+
seriesKey,
|
|
110
|
+
pickRadius: 5,
|
|
111
|
+
channels: {},
|
|
112
|
+
data,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("grammar selection", () => {
|
|
117
|
+
test("plain click selects a single row", () => {
|
|
118
|
+
const { manager } = fakeManager();
|
|
119
|
+
const { layer, firePress } = fakeHitLayer();
|
|
120
|
+
const events: HoveredHit[][] = [];
|
|
121
|
+
const sel = createGrammarSelection(
|
|
122
|
+
{ manager, hitLayer: layer },
|
|
123
|
+
{ onChange: (s) => events.push(s) },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const data: Row[] = [
|
|
127
|
+
{ x: 10, y: 20 },
|
|
128
|
+
{ x: 30, y: 40 },
|
|
129
|
+
];
|
|
130
|
+
const hit = hitFor([10, 20, 30, 40], [0, 1], data);
|
|
131
|
+
sel.sync([hit]);
|
|
132
|
+
firePress(hit as CompiledHitTest<unknown>, 0, pointerAt(10, 20));
|
|
133
|
+
|
|
134
|
+
expect(sel.current()).toHaveLength(1);
|
|
135
|
+
expect(sel.current()[0]!.dataIndex).toBe(0);
|
|
136
|
+
expect(events.at(-1)!).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("shift-click toggles membership", () => {
|
|
140
|
+
const { manager } = fakeManager();
|
|
141
|
+
const { layer, firePress } = fakeHitLayer();
|
|
142
|
+
const sel = createGrammarSelection({ manager, hitLayer: layer });
|
|
143
|
+
const data: Row[] = [
|
|
144
|
+
{ x: 10, y: 20 },
|
|
145
|
+
{ x: 30, y: 40 },
|
|
146
|
+
];
|
|
147
|
+
const hit = hitFor([10, 20, 30, 40], [0, 1], data) as CompiledHitTest<unknown>;
|
|
148
|
+
sel.sync([hit]);
|
|
149
|
+
|
|
150
|
+
firePress(hit, 0, pointerAt(10, 20));
|
|
151
|
+
firePress(hit, 1, pointerAt(30, 40, { ...noMods, shift: true }));
|
|
152
|
+
expect(
|
|
153
|
+
sel
|
|
154
|
+
.current()
|
|
155
|
+
.map((h) => h.dataIndex)
|
|
156
|
+
.sort((a, b) => a - b),
|
|
157
|
+
).toEqual([0, 1]);
|
|
158
|
+
|
|
159
|
+
firePress(hit, 0, pointerAt(10, 20, { ...noMods, shift: true }));
|
|
160
|
+
expect(sel.current().map((h) => h.dataIndex)).toEqual([1]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("meta-click removes a row", () => {
|
|
164
|
+
const { manager } = fakeManager();
|
|
165
|
+
const { layer, firePress } = fakeHitLayer();
|
|
166
|
+
const sel = createGrammarSelection({ manager, hitLayer: layer });
|
|
167
|
+
const data: Row[] = [
|
|
168
|
+
{ x: 10, y: 20 },
|
|
169
|
+
{ x: 30, y: 40 },
|
|
170
|
+
];
|
|
171
|
+
const hit = hitFor([10, 20, 30, 40], [0, 1], data) as CompiledHitTest<unknown>;
|
|
172
|
+
sel.sync([hit]);
|
|
173
|
+
|
|
174
|
+
firePress(hit, 0, pointerAt(10, 20));
|
|
175
|
+
firePress(hit, 1, pointerAt(30, 40, { ...noMods, shift: true }));
|
|
176
|
+
firePress(hit, 0, pointerAt(10, 20, { ...noMods, meta: true }));
|
|
177
|
+
|
|
178
|
+
expect(sel.current().map((h) => h.dataIndex)).toEqual([1]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("plain click on a different row replaces selection", () => {
|
|
182
|
+
const { manager } = fakeManager();
|
|
183
|
+
const { layer, firePress } = fakeHitLayer();
|
|
184
|
+
const sel = createGrammarSelection({ manager, hitLayer: layer });
|
|
185
|
+
const data: Row[] = [
|
|
186
|
+
{ x: 10, y: 20 },
|
|
187
|
+
{ x: 30, y: 40 },
|
|
188
|
+
];
|
|
189
|
+
const hit = hitFor([10, 20, 30, 40], [0, 1], data) as CompiledHitTest<unknown>;
|
|
190
|
+
sel.sync([hit]);
|
|
191
|
+
|
|
192
|
+
firePress(hit, 0, pointerAt(10, 20));
|
|
193
|
+
firePress(hit, 1, pointerAt(30, 40));
|
|
194
|
+
expect(sel.current().map((h) => h.dataIndex)).toEqual([1]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("background tap clears the selection", () => {
|
|
198
|
+
const { manager, fireBackgroundTap } = fakeManager();
|
|
199
|
+
const { layer, firePress } = fakeHitLayer();
|
|
200
|
+
const events: HoveredHit[][] = [];
|
|
201
|
+
const sel = createGrammarSelection(
|
|
202
|
+
{ manager, hitLayer: layer },
|
|
203
|
+
{ onChange: (s) => events.push(s) },
|
|
204
|
+
);
|
|
205
|
+
const data: Row[] = [{ x: 10, y: 20 }];
|
|
206
|
+
const hit = hitFor([10, 20], [0], data) as CompiledHitTest<unknown>;
|
|
207
|
+
sel.sync([hit]);
|
|
208
|
+
firePress(hit, 0, pointerAt(10, 20));
|
|
209
|
+
expect(sel.current()).toHaveLength(1);
|
|
210
|
+
|
|
211
|
+
fireBackgroundTap();
|
|
212
|
+
expect(sel.current()).toHaveLength(0);
|
|
213
|
+
expect(events.at(-1)!).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("sync drops selection rows that vanish from the data", () => {
|
|
217
|
+
const { manager } = fakeManager();
|
|
218
|
+
const { layer, firePress } = fakeHitLayer();
|
|
219
|
+
const sel = createGrammarSelection({ manager, hitLayer: layer });
|
|
220
|
+
const data: Row[] = [
|
|
221
|
+
{ x: 10, y: 20 },
|
|
222
|
+
{ x: 30, y: 40 },
|
|
223
|
+
];
|
|
224
|
+
const hit1 = hitFor([10, 20, 30, 40], [0, 1], data) as CompiledHitTest<unknown>;
|
|
225
|
+
sel.sync([hit1]);
|
|
226
|
+
firePress(hit1, 0, pointerAt(10, 20));
|
|
227
|
+
expect(sel.current()).toHaveLength(1);
|
|
228
|
+
|
|
229
|
+
// New hit-test omits index 0 (e.g., row was filtered out by NaN/null guard).
|
|
230
|
+
sel.sync([hitFor([30, 40], [1], data)]);
|
|
231
|
+
expect(sel.current()).toHaveLength(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("sync refreshes positions on already-selected rows", () => {
|
|
235
|
+
const { manager } = fakeManager();
|
|
236
|
+
const { layer, firePress } = fakeHitLayer();
|
|
237
|
+
const events: HoveredHit[][] = [];
|
|
238
|
+
const sel = createGrammarSelection(
|
|
239
|
+
{ manager, hitLayer: layer },
|
|
240
|
+
{ onChange: (s) => events.push(s) },
|
|
241
|
+
);
|
|
242
|
+
const data: Row[] = [{ x: 10, y: 20 }];
|
|
243
|
+
const hit1 = hitFor([10, 20], [0], data) as CompiledHitTest<unknown>;
|
|
244
|
+
sel.sync([hit1]);
|
|
245
|
+
firePress(hit1, 0, pointerAt(10, 20));
|
|
246
|
+
const before = events.length;
|
|
247
|
+
|
|
248
|
+
sel.sync([hitFor([55, 66], [0], data)]);
|
|
249
|
+
expect(sel.current()[0]).toMatchObject({ x: 55, y: 66 });
|
|
250
|
+
expect(events.length).toBeGreaterThan(before);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("same row with different series keys can be selected independently", () => {
|
|
254
|
+
const { manager } = fakeManager();
|
|
255
|
+
const { layer, firePress } = fakeHitLayer();
|
|
256
|
+
const sel = createGrammarSelection({ manager, hitLayer: layer });
|
|
257
|
+
const data: Row[] = [{ x: 10, y: 20 }];
|
|
258
|
+
const hit = hitFor([10, 20, 30, 40], [0, 0], data, [
|
|
259
|
+
"east",
|
|
260
|
+
"west",
|
|
261
|
+
]) as CompiledHitTest<unknown>;
|
|
262
|
+
sel.sync([hit]);
|
|
263
|
+
|
|
264
|
+
firePress(hit, 0, pointerAt(10, 20));
|
|
265
|
+
firePress(hit, 1, pointerAt(30, 40, { ...noMods, shift: true }));
|
|
266
|
+
|
|
267
|
+
expect(
|
|
268
|
+
sel
|
|
269
|
+
.current()
|
|
270
|
+
.map((h) => `${h.dataIndex}:${h.seriesKey}`)
|
|
271
|
+
.sort((a, b) => a.localeCompare(b)),
|
|
272
|
+
).toEqual(["0:east", "0:west"]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("dispose unsubscribes from hit layer and stops bg tap from clearing", () => {
|
|
276
|
+
const { manager, fireBackgroundTap } = fakeManager();
|
|
277
|
+
const { layer, subscribers } = fakeHitLayer();
|
|
278
|
+
const sel = createGrammarSelection({ manager, hitLayer: layer });
|
|
279
|
+
const data: Row[] = [{ x: 10, y: 20 }];
|
|
280
|
+
sel.sync([hitFor([10, 20], [0], data)]);
|
|
281
|
+
expect(subscribers).toHaveLength(1);
|
|
282
|
+
|
|
283
|
+
sel.dispose();
|
|
284
|
+
expect(subscribers).toHaveLength(0);
|
|
285
|
+
|
|
286
|
+
fireBackgroundTap(); // unsubscribed; should be a no-op
|
|
287
|
+
expect(sel.current()).toHaveLength(0);
|
|
288
|
+
});
|
|
289
|
+
});
|