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,373 @@
1
+ import { createInteractionManager, type InteractionManager } from "insomni";
2
+ import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
3
+
4
+ import type { CompiledHitTest } from "../geoms/types.ts";
5
+ import { createGrammarHitLayer, type GrammarHitLayer } from "./hit-layer.ts";
6
+ import { createGrammarContextMenu } from "./menu.ts";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Fake DOM canvas — mirrors the InteractionManager unit-test scaffolding.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ type Listener = (event: unknown) => void;
13
+
14
+ class FakeEventTarget {
15
+ private readonly listeners = new Map<string, Set<Listener>>();
16
+ addEventListener(type: string, listener: Listener): void {
17
+ let set = this.listeners.get(type);
18
+ if (!set) {
19
+ set = new Set();
20
+ this.listeners.set(type, set);
21
+ }
22
+ set.add(listener);
23
+ }
24
+ removeEventListener(type: string, listener: Listener): void {
25
+ this.listeners.get(type)?.delete(listener);
26
+ }
27
+ dispatch(type: string, event: unknown): void {
28
+ for (const l of Array.from(this.listeners.get(type) ?? [])) l(event);
29
+ }
30
+ }
31
+
32
+ function makeCanvas(width = 400, height = 300) {
33
+ const t = new FakeEventTarget() as FakeEventTarget & Record<string, unknown>;
34
+ t.style = { cursor: "" };
35
+ t.clientWidth = width;
36
+ t.clientHeight = height;
37
+ t.getBoundingClientRect = () => ({ left: 0, top: 0, width, height });
38
+ t.setPointerCapture = () => {};
39
+ t.releasePointerCapture = () => {};
40
+ t.hasPointerCapture = () => false;
41
+ return t as unknown as HTMLElement & FakeEventTarget;
42
+ }
43
+
44
+ function contextMenuEvent(clientX: number, clientY: number) {
45
+ return {
46
+ type: "contextmenu",
47
+ clientX,
48
+ clientY,
49
+ shiftKey: false,
50
+ ctrlKey: false,
51
+ metaKey: false,
52
+ altKey: false,
53
+ preventDefault: vi.fn(),
54
+ };
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Compiled hit-test factories
59
+ // ---------------------------------------------------------------------------
60
+
61
+ interface Row {
62
+ id: string;
63
+ v: number;
64
+ }
65
+
66
+ function pointHit(
67
+ geomKind: "point" | "line",
68
+ positions: number[],
69
+ indices: number[],
70
+ data: readonly Row[],
71
+ pickRadius = 5,
72
+ ): CompiledHitTest<Row> {
73
+ return {
74
+ geomKind,
75
+ positions: Float32Array.from(positions),
76
+ dataIndex: Int32Array.from(indices),
77
+ pickRadius,
78
+ channels: {},
79
+ data,
80
+ };
81
+ }
82
+
83
+ function regionHit(
84
+ positions: number[],
85
+ rects: number[],
86
+ indices: number[],
87
+ data: readonly Row[],
88
+ ): CompiledHitTest<Row> {
89
+ return {
90
+ geomKind: "bar",
91
+ positions: Float32Array.from(positions),
92
+ rects: Float32Array.from(rects),
93
+ dataIndex: Int32Array.from(indices),
94
+ pickRadius: 5,
95
+ channels: {},
96
+ data,
97
+ };
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // pickAt — exhaustive
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe("GrammarHitLayer.pickAt", () => {
105
+ let canvas: HTMLElement;
106
+ let manager: InteractionManager;
107
+ let hitLayer: GrammarHitLayer;
108
+
109
+ beforeEach(() => {
110
+ vi.stubGlobal("window", new FakeEventTarget());
111
+ canvas = makeCanvas();
112
+ manager = createInteractionManager(canvas);
113
+ hitLayer = createGrammarHitLayer({ manager, element: canvas });
114
+ });
115
+
116
+ test("empty slots → null", () => {
117
+ expect(hitLayer.pickAt(10, 10)).toBeNull();
118
+ });
119
+
120
+ test("nearest point within pickRadius wins", () => {
121
+ const data: Row[] = [
122
+ { id: "a", v: 1 },
123
+ { id: "b", v: 2 },
124
+ { id: "c", v: 3 },
125
+ ];
126
+ hitLayer.sync([pointHit("point", [10, 10, 30, 30, 100, 100], [0, 1, 2], data, 6)]);
127
+ const r = hitLayer.pickAt(31, 32);
128
+ expect(r).not.toBeNull();
129
+ expect(r!.hit.dataIndex).toBe(1);
130
+ expect(r!.hit.x).toBe(30);
131
+ expect(r!.hit.y).toBe(30);
132
+ });
133
+
134
+ test("outside all pickRadii → null", () => {
135
+ const data: Row[] = [{ id: "a", v: 1 }];
136
+ hitLayer.sync([pointHit("point", [10, 10], [0], data, 4)]);
137
+ expect(hitLayer.pickAt(100, 100)).toBeNull();
138
+ });
139
+
140
+ test("topmost slot wins ties (later registration = higher zIndex)", () => {
141
+ const a: Row[] = [{ id: "lower", v: 1 }];
142
+ const b: Row[] = [{ id: "upper", v: 2 }];
143
+ hitLayer.sync([
144
+ pointHit("point", [50, 50], [0], a, 10),
145
+ pointHit("line", [50, 50], [0], b, 10),
146
+ ]);
147
+ const r = hitLayer.pickAt(50, 50);
148
+ expect(r).not.toBeNull();
149
+ expect(r!.hit.geomKind).toBe("line");
150
+ expect(r!.hit.data).toBe(b);
151
+ });
152
+
153
+ test("region rect containment", () => {
154
+ const data: Row[] = [
155
+ { id: "bar0", v: 1 },
156
+ { id: "bar1", v: 2 },
157
+ ];
158
+ hitLayer.sync([regionHit([20, 50, 60, 50], [10, 30, 20, 40, 50, 30, 20, 40], [0, 1], data)]);
159
+ const inFirst = hitLayer.pickAt(15, 50);
160
+ expect(inFirst).not.toBeNull();
161
+ expect(inFirst!.hit.dataIndex).toBe(0);
162
+ const inSecond = hitLayer.pickAt(55, 50);
163
+ expect(inSecond!.hit.dataIndex).toBe(1);
164
+ const outside = hitLayer.pickAt(0, 0);
165
+ expect(outside).toBeNull();
166
+ });
167
+
168
+ test("mode: 'point' skips region slots", () => {
169
+ const data: Row[] = [{ id: "bar", v: 1 }];
170
+ hitLayer.sync([regionHit([50, 50], [40, 40, 20, 20], [0], data)]);
171
+ expect(hitLayer.pickAt(50, 50, { mode: "point" })).toBeNull();
172
+ expect(hitLayer.pickAt(50, 50, { mode: "any" })).not.toBeNull();
173
+ });
174
+
175
+ test("mode: 'region' skips point slots", () => {
176
+ const data: Row[] = [{ id: "p", v: 1 }];
177
+ hitLayer.sync([pointHit("point", [50, 50], [0], data, 8)]);
178
+ expect(hitLayer.pickAt(50, 50, { mode: "region" })).toBeNull();
179
+ expect(hitLayer.pickAt(50, 50, { mode: "point" })).not.toBeNull();
180
+ });
181
+
182
+ test("pickAxis: 'x' ignores y distance", () => {
183
+ const data: Row[] = [{ id: "p", v: 1 }];
184
+ const hit: CompiledHitTest<Row> = {
185
+ ...pointHit("line", [50, 50], [0], data, 5),
186
+ pickAxis: "x",
187
+ };
188
+ hitLayer.sync([hit]);
189
+ // Even though y is way off, x is exactly on the point.
190
+ expect(hitLayer.pickAt(50, 200)).not.toBeNull();
191
+ expect(hitLayer.pickAt(60, 50)).toBeNull();
192
+ });
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // createGrammarContextMenu wiring
197
+ // ---------------------------------------------------------------------------
198
+
199
+ describe("createGrammarContextMenu", () => {
200
+ let canvas: HTMLElement;
201
+ let manager: InteractionManager;
202
+ let hitLayer: GrammarHitLayer;
203
+
204
+ beforeEach(() => {
205
+ vi.stubGlobal("window", new FakeEventTarget());
206
+ canvas = makeCanvas();
207
+ manager = createInteractionManager(canvas);
208
+ hitLayer = createGrammarHitLayer({ manager, element: canvas });
209
+ });
210
+
211
+ function fire(x: number, y: number) {
212
+ (canvas as unknown as FakeEventTarget).dispatch("contextmenu", contextMenuEvent(x, y));
213
+ }
214
+
215
+ test("trigger on point resolves datum + mark", () => {
216
+ const data: Row[] = [{ id: "a", v: 7 }];
217
+ hitLayer.sync([pointHit("point", [100, 100], [0], data, 5)]);
218
+ const onTrigger = vi.fn();
219
+ const menu = createGrammarContextMenu({ manager, hitLayer }, { onTrigger });
220
+ fire(102, 101);
221
+ expect(onTrigger).toHaveBeenCalledOnce();
222
+ const info = onTrigger.mock.calls[0][0];
223
+ expect(info.hit).not.toBeNull();
224
+ expect(info.mark).toBe("point");
225
+ expect(info.datum).toEqual({ id: "a", v: 7 });
226
+ expect(info.screenX).toBe(102);
227
+ expect(info.screenY).toBe(101);
228
+ expect(info.source).toBe("mouse");
229
+ menu.dispose();
230
+ });
231
+
232
+ test("nearest-point on empty background suppresses the trigger entirely", () => {
233
+ // Matches hover behavior — no popup unless a mark is hit.
234
+ hitLayer.sync([pointHit("point", [10, 10], [0], [{ id: "x", v: 1 }], 4)]);
235
+ const onTrigger = vi.fn();
236
+ const menu = createGrammarContextMenu({ manager, hitLayer }, { onTrigger });
237
+ fire(200, 200);
238
+ expect(onTrigger).not.toHaveBeenCalled();
239
+ menu.dispose();
240
+ });
241
+
242
+ test("background hitMode fires on empty space with null hit", () => {
243
+ hitLayer.sync([pointHit("point", [10, 10], [0], [{ id: "x", v: 1 }], 4)]);
244
+ const onTrigger = vi.fn();
245
+ const menu = createGrammarContextMenu(
246
+ { manager, hitLayer },
247
+ { hitMode: "background", onTrigger },
248
+ );
249
+ fire(200, 200);
250
+ expect(onTrigger).toHaveBeenCalledOnce();
251
+ const info = onTrigger.mock.calls[0][0];
252
+ expect(info.hit).toBeNull();
253
+ expect(info.datum).toBeNull();
254
+ expect(info.mark).toBeNull();
255
+ menu.dispose();
256
+ });
257
+
258
+ test("multi-mark overlap honors z-order (topmost slot wins)", () => {
259
+ const a = [{ id: "lower", v: 1 }];
260
+ const b = [{ id: "upper", v: 2 }];
261
+ hitLayer.sync([
262
+ pointHit("point", [50, 50], [0], a, 10),
263
+ pointHit("line", [50, 50], [0], b, 10),
264
+ ]);
265
+ const onTrigger = vi.fn();
266
+ const menu = createGrammarContextMenu({ manager, hitLayer }, { onTrigger });
267
+ fire(50, 50);
268
+ expect(onTrigger.mock.calls[0][0].mark).toBe("line");
269
+ expect(onTrigger.mock.calls[0][0].datum).toEqual(b[0]);
270
+ menu.dispose();
271
+ });
272
+
273
+ test("hitMode 'background' never resolves marks", () => {
274
+ hitLayer.sync([pointHit("point", [50, 50], [0], [{ id: "x", v: 1 }], 10)]);
275
+ const onTrigger = vi.fn();
276
+ const menu = createGrammarContextMenu(
277
+ { manager, hitLayer },
278
+ { hitMode: "background", onTrigger },
279
+ );
280
+ fire(50, 50);
281
+ expect(onTrigger).toHaveBeenCalledOnce();
282
+ const info = onTrigger.mock.calls[0][0];
283
+ expect(info.hit).toBeNull();
284
+ expect(info.datum).toBeNull();
285
+ menu.dispose();
286
+ });
287
+
288
+ test("hitMode 'nearest-point' skips region geoms (and suppresses trigger when no point hit)", () => {
289
+ hitLayer.sync([regionHit([50, 50], [40, 40, 20, 20], [0], [{ id: "bar", v: 1 }])]);
290
+ const onTrigger = vi.fn();
291
+ const menu = createGrammarContextMenu(
292
+ { manager, hitLayer },
293
+ { hitMode: "nearest-point", onTrigger },
294
+ );
295
+ fire(50, 50);
296
+ expect(onTrigger).not.toHaveBeenCalled();
297
+ menu.dispose();
298
+ });
299
+
300
+ test("hitMode 'any-mark' includes region geoms", () => {
301
+ hitLayer.sync([regionHit([50, 50], [40, 40, 20, 20], [0], [{ id: "bar", v: 1 }])]);
302
+ const onTrigger = vi.fn();
303
+ const menu = createGrammarContextMenu(
304
+ { manager, hitLayer },
305
+ { hitMode: "any-mark", onTrigger },
306
+ );
307
+ fire(50, 50);
308
+ expect(onTrigger.mock.calls[0][0].mark).toBe("bar");
309
+ menu.dispose();
310
+ });
311
+
312
+ test("isSuppressed gates trigger (mid-pan)", () => {
313
+ hitLayer.sync([pointHit("point", [50, 50], [0], [{ id: "x", v: 1 }], 10)]);
314
+ let suppressed = false;
315
+ const onTrigger = vi.fn();
316
+ const menu = createGrammarContextMenu(
317
+ { manager, hitLayer },
318
+ { onTrigger, isSuppressed: () => suppressed },
319
+ );
320
+ suppressed = true;
321
+ fire(50, 50);
322
+ expect(onTrigger).not.toHaveBeenCalled();
323
+ suppressed = false;
324
+ fire(50, 50);
325
+ expect(onTrigger).toHaveBeenCalledOnce();
326
+ menu.dispose();
327
+ });
328
+
329
+ test("dispose unsubscribes", () => {
330
+ hitLayer.sync([pointHit("point", [50, 50], [0], [{ id: "x", v: 1 }], 10)]);
331
+ const onTrigger = vi.fn();
332
+ const menu = createGrammarContextMenu({ manager, hitLayer }, { onTrigger });
333
+ menu.dispose();
334
+ fire(50, 50);
335
+ expect(onTrigger).not.toHaveBeenCalled();
336
+ });
337
+
338
+ test("items resolver receives the trigger info", () => {
339
+ hitLayer.sync([pointHit("point", [50, 50], [0], [{ id: "x", v: 7 }], 10)]);
340
+ const resolver = vi.fn(() => [
341
+ { id: "details", label: "Show details" },
342
+ { id: "delete", label: "Delete", danger: true },
343
+ ]);
344
+ const fakeLayer = {} as unknown as import("insomni").Layer;
345
+ const menu = createGrammarContextMenu(
346
+ {
347
+ manager,
348
+ hitLayer,
349
+ hudLayer: () => fakeLayer,
350
+ bounds: () => ({ x: 0, y: 0, width: 400, height: 300 }),
351
+ },
352
+ {
353
+ items: resolver,
354
+ onAction: () => {},
355
+ },
356
+ );
357
+ fire(50, 50);
358
+ expect(resolver).toHaveBeenCalledOnce();
359
+ const info = (resolver.mock.calls[0] as unknown[])[0] as { hit: unknown; datum: unknown };
360
+ expect(info.hit).not.toBeNull();
361
+ expect(info.datum).toEqual({ id: "x", v: 7 });
362
+ menu.dispose();
363
+ });
364
+
365
+ test("items throws if hudLayer/bounds deps missing", () => {
366
+ expect(() =>
367
+ createGrammarContextMenu(
368
+ { manager, hitLayer },
369
+ { items: [{ id: "x", label: "x" }], onAction: () => {} },
370
+ ),
371
+ ).toThrow(/hudLayer.*bounds/);
372
+ });
373
+ });