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.
Files changed (242) hide show
  1. package/LICENSE.md +674 -0
  2. package/README.md +81 -0
  3. package/dist/core.d.mts +340 -0
  4. package/dist/core.mjs +1047 -0
  5. package/dist/index.d.mts +3426 -0
  6. package/dist/index.mjs +12762 -0
  7. package/dist/interactions-DEFL_F4E.mjs +5395 -0
  8. package/dist/range-presets-CzECsu3V.d.mts +1523 -0
  9. package/package.json +34 -0
  10. package/src/annotations.d.ts +121 -0
  11. package/src/annotations.ts +438 -0
  12. package/src/axis.d.ts +184 -0
  13. package/src/axis.test.ts +131 -0
  14. package/src/axis.ts +765 -0
  15. package/src/colorbar.d.ts +69 -0
  16. package/src/colorbar.ts +294 -0
  17. package/src/colors.d.ts +57 -0
  18. package/src/colors.test.ts +28 -0
  19. package/src/colors.ts +486 -0
  20. package/src/core.ts +299 -0
  21. package/src/format.d.ts +54 -0
  22. package/src/format.ts +138 -0
  23. package/src/grammar/accessibility.d.ts +147 -0
  24. package/src/grammar/accessibility.test.ts +199 -0
  25. package/src/grammar/accessibility.ts +443 -0
  26. package/src/grammar/aes.d.ts +35 -0
  27. package/src/grammar/aes.test.ts +75 -0
  28. package/src/grammar/aes.ts +120 -0
  29. package/src/grammar/annotations.d.ts +86 -0
  30. package/src/grammar/annotations.test.ts +68 -0
  31. package/src/grammar/annotations.ts +336 -0
  32. package/src/grammar/attach-brush.d.ts +44 -0
  33. package/src/grammar/attach-brush.test.ts +214 -0
  34. package/src/grammar/attach-brush.ts +111 -0
  35. package/src/grammar/attach-presets.d.ts +33 -0
  36. package/src/grammar/attach-presets.test.ts +106 -0
  37. package/src/grammar/attach-presets.ts +215 -0
  38. package/src/grammar/chart.d.ts +952 -0
  39. package/src/grammar/chart.test.ts +118 -0
  40. package/src/grammar/chart.ts +1172 -0
  41. package/src/grammar/color-utils.d.ts +29 -0
  42. package/src/grammar/color-utils.test.ts +53 -0
  43. package/src/grammar/color-utils.ts +66 -0
  44. package/src/grammar/constants.d.ts +45 -0
  45. package/src/grammar/constants.ts +61 -0
  46. package/src/grammar/coord.d.ts +183 -0
  47. package/src/grammar/coord.test.ts +355 -0
  48. package/src/grammar/coord.ts +619 -0
  49. package/src/grammar/data/pivot.d.ts +57 -0
  50. package/src/grammar/data/pivot.ts +107 -0
  51. package/src/grammar/emphasis-driver.d.ts +69 -0
  52. package/src/grammar/emphasis-driver.test.ts +199 -0
  53. package/src/grammar/emphasis-driver.ts +205 -0
  54. package/src/grammar/equality.d.ts +3 -0
  55. package/src/grammar/equality.ts +40 -0
  56. package/src/grammar/facet.d.ts +63 -0
  57. package/src/grammar/facet.test.ts +60 -0
  58. package/src/grammar/facet.ts +175 -0
  59. package/src/grammar/geoms/_categorical.d.ts +94 -0
  60. package/src/grammar/geoms/_categorical.ts +0 -0
  61. package/src/grammar/geoms/_distribution.d.ts +52 -0
  62. package/src/grammar/geoms/_distribution.ts +125 -0
  63. package/src/grammar/geoms/_mark.d.ts +69 -0
  64. package/src/grammar/geoms/_mark.ts +136 -0
  65. package/src/grammar/geoms/_shape.d.ts +41 -0
  66. package/src/grammar/geoms/_shape.ts +74 -0
  67. package/src/grammar/geoms/aggregate.d.ts +95 -0
  68. package/src/grammar/geoms/aggregate.test.ts +554 -0
  69. package/src/grammar/geoms/aggregate.ts +840 -0
  70. package/src/grammar/geoms/area.d.ts +32 -0
  71. package/src/grammar/geoms/area.test.ts +165 -0
  72. package/src/grammar/geoms/area.ts +578 -0
  73. package/src/grammar/geoms/band.d.ts +27 -0
  74. package/src/grammar/geoms/band.test.ts +57 -0
  75. package/src/grammar/geoms/band.ts +126 -0
  76. package/src/grammar/geoms/bar.d.ts +56 -0
  77. package/src/grammar/geoms/bar.test.ts +367 -0
  78. package/src/grammar/geoms/bar.ts +1054 -0
  79. package/src/grammar/geoms/boxplot.d.ts +129 -0
  80. package/src/grammar/geoms/boxplot.test.ts +299 -0
  81. package/src/grammar/geoms/boxplot.ts +834 -0
  82. package/src/grammar/geoms/connected-scatter.d.ts +27 -0
  83. package/src/grammar/geoms/connected-scatter.test.ts +157 -0
  84. package/src/grammar/geoms/connected-scatter.ts +63 -0
  85. package/src/grammar/geoms/emphasis.d.ts +76 -0
  86. package/src/grammar/geoms/emphasis.test.ts +135 -0
  87. package/src/grammar/geoms/emphasis.ts +162 -0
  88. package/src/grammar/geoms/histogram.d.ts +75 -0
  89. package/src/grammar/geoms/histogram.test.ts +262 -0
  90. package/src/grammar/geoms/histogram.ts +740 -0
  91. package/src/grammar/geoms/index.d.ts +20 -0
  92. package/src/grammar/geoms/index.ts +77 -0
  93. package/src/grammar/geoms/interval.d.ts +31 -0
  94. package/src/grammar/geoms/interval.test.ts +154 -0
  95. package/src/grammar/geoms/interval.ts +342 -0
  96. package/src/grammar/geoms/line.d.ts +38 -0
  97. package/src/grammar/geoms/line.test.ts +247 -0
  98. package/src/grammar/geoms/line.ts +659 -0
  99. package/src/grammar/geoms/point.d.ts +57 -0
  100. package/src/grammar/geoms/point.test.ts +163 -0
  101. package/src/grammar/geoms/point.ts +545 -0
  102. package/src/grammar/geoms/polar.test.ts +216 -0
  103. package/src/grammar/geoms/ribbon.d.ts +21 -0
  104. package/src/grammar/geoms/ribbon.test.ts +170 -0
  105. package/src/grammar/geoms/ribbon.ts +87 -0
  106. package/src/grammar/geoms/ridgeline.d.ts +89 -0
  107. package/src/grammar/geoms/ridgeline.test.ts +247 -0
  108. package/src/grammar/geoms/ridgeline.ts +1164 -0
  109. package/src/grammar/geoms/rolling.d.ts +43 -0
  110. package/src/grammar/geoms/rolling.test.ts +217 -0
  111. package/src/grammar/geoms/rolling.ts +387 -0
  112. package/src/grammar/geoms/rug.d.ts +28 -0
  113. package/src/grammar/geoms/rug.test.ts +126 -0
  114. package/src/grammar/geoms/rug.ts +214 -0
  115. package/src/grammar/geoms/rule.d.ts +23 -0
  116. package/src/grammar/geoms/rule.test.ts +69 -0
  117. package/src/grammar/geoms/rule.ts +212 -0
  118. package/src/grammar/geoms/smooth.d.ts +54 -0
  119. package/src/grammar/geoms/smooth.test.ts +78 -0
  120. package/src/grammar/geoms/smooth.ts +337 -0
  121. package/src/grammar/geoms/text.d.ts +29 -0
  122. package/src/grammar/geoms/text.test.ts +64 -0
  123. package/src/grammar/geoms/text.ts +234 -0
  124. package/src/grammar/geoms/tile.d.ts +61 -0
  125. package/src/grammar/geoms/tile.test.ts +157 -0
  126. package/src/grammar/geoms/tile.ts +621 -0
  127. package/src/grammar/geoms/types.d.ts +319 -0
  128. package/src/grammar/geoms/types.ts +362 -0
  129. package/src/grammar/geoms/violin.d.ts +85 -0
  130. package/src/grammar/geoms/violin.test.ts +187 -0
  131. package/src/grammar/geoms/violin.ts +672 -0
  132. package/src/grammar/index.d.ts +22 -0
  133. package/src/grammar/index.ts +269 -0
  134. package/src/grammar/interactions/_disposable.d.ts +5 -0
  135. package/src/grammar/interactions/_disposable.ts +23 -0
  136. package/src/grammar/interactions/_z.d.ts +4 -0
  137. package/src/grammar/interactions/_z.ts +16 -0
  138. package/src/grammar/interactions/brush-selection.test.ts +262 -0
  139. package/src/grammar/interactions/brush.d.ts +63 -0
  140. package/src/grammar/interactions/brush.test.ts +483 -0
  141. package/src/grammar/interactions/brush.ts +452 -0
  142. package/src/grammar/interactions/crosshair.d.ts +19 -0
  143. package/src/grammar/interactions/crosshair.test.ts +127 -0
  144. package/src/grammar/interactions/crosshair.ts +76 -0
  145. package/src/grammar/interactions/hit-layer.d.ts +64 -0
  146. package/src/grammar/interactions/hit-layer.ts +246 -0
  147. package/src/grammar/interactions/legend.d.ts +19 -0
  148. package/src/grammar/interactions/legend.ts +101 -0
  149. package/src/grammar/interactions/menu.d.ts +93 -0
  150. package/src/grammar/interactions/menu.test.ts +373 -0
  151. package/src/grammar/interactions/menu.ts +342 -0
  152. package/src/grammar/interactions/selection.d.ts +25 -0
  153. package/src/grammar/interactions/selection.test.ts +289 -0
  154. package/src/grammar/interactions/selection.ts +142 -0
  155. package/src/grammar/interactions/series-readout.d.ts +91 -0
  156. package/src/grammar/interactions/series-readout.test.ts +668 -0
  157. package/src/grammar/interactions/series-readout.ts +422 -0
  158. package/src/grammar/interactions/series-snap.d.ts +70 -0
  159. package/src/grammar/interactions/series-snap.test.ts +214 -0
  160. package/src/grammar/interactions/series-snap.ts +218 -0
  161. package/src/grammar/interactions/tooltip-axis.test.ts +176 -0
  162. package/src/grammar/interactions/tooltip-touch.browser.test.ts +49 -0
  163. package/src/grammar/interactions/tooltip-touch.test.ts +161 -0
  164. package/src/grammar/interactions/tooltip.d.ts +140 -0
  165. package/src/grammar/interactions/tooltip.test.ts +406 -0
  166. package/src/grammar/interactions/tooltip.ts +622 -0
  167. package/src/grammar/interactions/transitions.d.ts +34 -0
  168. package/src/grammar/interactions/transitions.test.ts +172 -0
  169. package/src/grammar/interactions/transitions.ts +160 -0
  170. package/src/grammar/layout.d.ts +68 -0
  171. package/src/grammar/layout.ts +186 -0
  172. package/src/grammar/legend-merge.test.ts +332 -0
  173. package/src/grammar/mount.d.ts +78 -0
  174. package/src/grammar/mount.test.ts +479 -0
  175. package/src/grammar/mount.ts +2112 -0
  176. package/src/grammar/palettes.d.ts +54 -0
  177. package/src/grammar/palettes.test.ts +80 -0
  178. package/src/grammar/palettes.ts +167 -0
  179. package/src/grammar/pan-zoom.test.ts +398 -0
  180. package/src/grammar/phylo.d.ts +65 -0
  181. package/src/grammar/phylo.test.ts +59 -0
  182. package/src/grammar/phylo.ts +112 -0
  183. package/src/grammar/pipeline.auto-ticks.test.ts +40 -0
  184. package/src/grammar/pipeline.d.ts +158 -0
  185. package/src/grammar/pipeline.test.ts +463 -0
  186. package/src/grammar/pipeline.ts +1233 -0
  187. package/src/grammar/profiling.d.ts +8 -0
  188. package/src/grammar/profiling.ts +24 -0
  189. package/src/grammar/scales.d.ts +188 -0
  190. package/src/grammar/scales.test.ts +181 -0
  191. package/src/grammar/scales.ts +800 -0
  192. package/src/grammar/svg.d.ts +3 -0
  193. package/src/grammar/svg.ts +39 -0
  194. package/src/grammar/theme.d.ts +261 -0
  195. package/src/grammar/theme.test.ts +105 -0
  196. package/src/grammar/theme.ts +490 -0
  197. package/src/heatmap/cpu.ts +109 -0
  198. package/src/heatmap/gpu.ts +565 -0
  199. package/src/heatmap/types.ts +177 -0
  200. package/src/heatmap.browser.test.ts +308 -0
  201. package/src/heatmap.test.ts +320 -0
  202. package/src/heatmap.ts +123 -0
  203. package/src/index.d.ts +1 -0
  204. package/src/index.ts +8 -0
  205. package/src/interactions.d.ts +48 -0
  206. package/src/interactions.test.ts +226 -0
  207. package/src/interactions.ts +394 -0
  208. package/src/layout/box.d.ts +48 -0
  209. package/src/layout/box.test.ts +107 -0
  210. package/src/layout/box.ts +143 -0
  211. package/src/legend.d.ts +115 -0
  212. package/src/legend.ts +422 -0
  213. package/src/marks/curve.d.ts +43 -0
  214. package/src/marks/curve.ts +244 -0
  215. package/src/marks/stack.d.ts +53 -0
  216. package/src/marks/stack.ts +184 -0
  217. package/src/marks.d.ts +273 -0
  218. package/src/marks.test.ts +541 -0
  219. package/src/marks.ts +1292 -0
  220. package/src/navigator.test.ts +174 -0
  221. package/src/navigator.ts +393 -0
  222. package/src/range-presets.d.ts +113 -0
  223. package/src/range-presets.test.ts +345 -0
  224. package/src/range-presets.ts +349 -0
  225. package/src/scales.d.ts +98 -0
  226. package/src/scales.test.ts +103 -0
  227. package/src/scales.ts +695 -0
  228. package/src/stats/index.d.ts +200 -0
  229. package/src/stats/index.test.ts +349 -0
  230. package/src/stats/index.ts +740 -0
  231. package/src/stats/regression.d.ts +38 -0
  232. package/src/stats/regression.test.ts +56 -0
  233. package/src/stats/regression.ts +396 -0
  234. package/src/stats/rolling-window.d.ts +55 -0
  235. package/src/stats/rolling-window.test.ts +237 -0
  236. package/src/stats/rolling-window.ts +256 -0
  237. package/src/test-setup.ts +19 -0
  238. package/src/viewport/axis-state.d.ts +72 -0
  239. package/src/viewport/axis-state.ts +476 -0
  240. package/src/viewport.d.ts +170 -0
  241. package/src/viewport.test.ts +363 -0
  242. 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
+ });