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,226 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
2
+
3
+ import { viewportFrame } from "insomni";
4
+ import { bindDataViewport } from "./interactions.ts";
5
+ import { createDataViewport } from "./viewport.ts";
6
+
7
+ // ============ Fixtures ============
8
+
9
+ type Listener = (event: unknown) => void;
10
+
11
+ class FakeEventTarget {
12
+ private readonly listeners = new Map<string, Set<Listener>>();
13
+
14
+ addEventListener(type: string, listener: Listener): void {
15
+ let set = this.listeners.get(type);
16
+ if (!set) {
17
+ set = new Set();
18
+ this.listeners.set(type, set);
19
+ }
20
+ set.add(listener);
21
+ }
22
+
23
+ removeEventListener(type: string, listener: Listener): void {
24
+ this.listeners.get(type)?.delete(listener);
25
+ }
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
+ interface FakeCanvas extends FakeEventTarget {
33
+ style: { cursor: string; touchAction?: string };
34
+ clientWidth: number;
35
+ clientHeight: number;
36
+ width: number;
37
+ height: number;
38
+ getBoundingClientRect(): { left: number; top: number; width: number; height: number };
39
+ setPointerCapture(_: number): void;
40
+ releasePointerCapture(_: number): void;
41
+ hasPointerCapture(_: number): boolean;
42
+ }
43
+
44
+ const fixture = {
45
+ canvas: (rect = { left: 0, top: 0, width: 800, height: 600 }): FakeCanvas => {
46
+ const target = new FakeEventTarget() as FakeEventTarget & Partial<FakeCanvas>;
47
+ target.style = { cursor: "" };
48
+ target.clientWidth = rect.width;
49
+ target.clientHeight = rect.height;
50
+ target.width = rect.width;
51
+ target.height = rect.height;
52
+ target.getBoundingClientRect = () => rect;
53
+ target.setPointerCapture = () => {};
54
+ target.releasePointerCapture = () => {};
55
+ target.hasPointerCapture = () => false;
56
+ return target as FakeCanvas;
57
+ },
58
+ wheelEvent: (clientX: number, clientY: number, deltaY = 100) => ({
59
+ clientX,
60
+ clientY,
61
+ deltaX: 0,
62
+ deltaY,
63
+ shiftKey: false,
64
+ metaKey: false,
65
+ ctrlKey: false,
66
+ altKey: false,
67
+ preventDefault: vi.fn(),
68
+ }),
69
+ pointerEvent: (
70
+ clientX: number,
71
+ clientY: number,
72
+ opts: { pointerId?: number; buttons?: number; pointerType?: string } = {},
73
+ ) => ({
74
+ pointerId: opts.pointerId ?? 1,
75
+ pointerType: opts.pointerType ?? "mouse",
76
+ clientX,
77
+ clientY,
78
+ buttons: opts.buttons ?? 1,
79
+ shiftKey: false,
80
+ metaKey: false,
81
+ ctrlKey: false,
82
+ altKey: false,
83
+ preventDefault: vi.fn(),
84
+ }),
85
+ };
86
+
87
+ describe("bindDataViewport", () => {
88
+ beforeEach(() => {
89
+ vi.stubGlobal("window", new FakeEventTarget());
90
+ });
91
+
92
+ test("wheel inside the viewport frame calls preventDefault and zooms", () => {
93
+ const canvas = fixture.canvas({ left: 0, top: 0, width: 400, height: 300 });
94
+ const vp = createDataViewport({
95
+ frame: viewportFrame(400, 300).padded({ top: 20, right: 20, bottom: 20, left: 20 }),
96
+ x: { type: "linear", domain: [0, 100] },
97
+ y: { type: "linear", domain: [0, 100] },
98
+ });
99
+ const zoomAt = vi.spyOn(vp, "zoomAt");
100
+
101
+ const binding = bindDataViewport(vp, canvas as unknown as HTMLElement);
102
+ expect(binding.mode).toBe("data");
103
+
104
+ const ev = fixture.wheelEvent(200, 150);
105
+ (canvas as unknown as FakeEventTarget).dispatch("wheel", ev);
106
+
107
+ expect(ev.preventDefault).toHaveBeenCalledOnce();
108
+ expect(zoomAt).toHaveBeenCalledOnce();
109
+ });
110
+
111
+ test("wheel outside the viewport frame does not preventDefault or zoom", () => {
112
+ const canvas = fixture.canvas({ left: 0, top: 0, width: 400, height: 300 });
113
+ const vp = createDataViewport({
114
+ frame: viewportFrame(400, 300).padded({ top: 20, right: 20, bottom: 20, left: 20 }),
115
+ x: { type: "linear", domain: [0, 100] },
116
+ y: { type: "linear", domain: [0, 100] },
117
+ });
118
+ const zoomAt = vi.spyOn(vp, "zoomAt");
119
+
120
+ bindDataViewport(vp, canvas as unknown as HTMLElement);
121
+
122
+ const ev = fixture.wheelEvent(5, 5);
123
+ (canvas as unknown as FakeEventTarget).dispatch("wheel", ev);
124
+
125
+ expect(ev.preventDefault).not.toHaveBeenCalled();
126
+ expect(zoomAt).not.toHaveBeenCalled();
127
+ });
128
+
129
+ test("pointerdown outside the frame does not start a drag", () => {
130
+ const canvas = fixture.canvas({ left: 0, top: 0, width: 400, height: 300 });
131
+ const vp = createDataViewport({
132
+ frame: viewportFrame(400, 300).padded({ top: 20, right: 20, bottom: 20, left: 20 }),
133
+ x: { type: "linear", domain: [0, 100] },
134
+ y: { type: "linear", domain: [0, 100] },
135
+ });
136
+ const panBy = vi.spyOn(vp, "panBy");
137
+
138
+ const binding = bindDataViewport(vp, canvas as unknown as HTMLElement);
139
+
140
+ (canvas as unknown as FakeEventTarget).dispatch("pointerdown", fixture.pointerEvent(5, 5));
141
+ (canvas as unknown as FakeEventTarget).dispatch("pointermove", fixture.pointerEvent(50, 50));
142
+ expect(panBy).not.toHaveBeenCalled();
143
+ expect(binding.interacting).toBe(false);
144
+ });
145
+
146
+ test("drag started inside the frame keeps panning after the pointer leaves", () => {
147
+ const canvas = fixture.canvas({ left: 0, top: 0, width: 400, height: 300 });
148
+ const vp = createDataViewport({
149
+ frame: viewportFrame(400, 300).padded({ top: 20, right: 20, bottom: 20, left: 20 }),
150
+ x: { type: "linear", domain: [0, 100] },
151
+ y: { type: "linear", domain: [0, 100] },
152
+ });
153
+ const panBy = vi.spyOn(vp, "panBy");
154
+
155
+ const binding = bindDataViewport(vp, canvas as unknown as HTMLElement);
156
+
157
+ (canvas as unknown as FakeEventTarget).dispatch("pointerdown", fixture.pointerEvent(200, 150));
158
+ (canvas as unknown as FakeEventTarget).dispatch(
159
+ "pointermove",
160
+ fixture.pointerEvent(2000, 2000),
161
+ );
162
+ expect(binding.interacting).toBe(true);
163
+ expect(panBy).toHaveBeenCalled();
164
+
165
+ (canvas as unknown as FakeEventTarget).dispatch("pointerup", fixture.pointerEvent(2000, 2000));
166
+ expect(binding.interacting).toBe(false);
167
+ });
168
+
169
+ test("smart defaults exclude band axes from pan/zoom", () => {
170
+ const canvas = fixture.canvas({ left: 0, top: 0, width: 400, height: 300 });
171
+ const vp = createDataViewport({
172
+ frame: viewportFrame(400, 300),
173
+ x: { type: "band", domain: ["a", "b", "c"] as string[] },
174
+ y: { type: "linear", domain: [0, 100] },
175
+ });
176
+ const zoomAt = vi.spyOn(vp, "zoomAt");
177
+
178
+ bindDataViewport(vp, canvas as unknown as HTMLElement);
179
+
180
+ const ev = fixture.wheelEvent(200, 150);
181
+ (canvas as unknown as FakeEventTarget).dispatch("wheel", ev);
182
+
183
+ expect(zoomAt).toHaveBeenCalledOnce();
184
+ const call = zoomAt.mock.calls[0]!;
185
+ const factor = call[2] as { x: number; y: number };
186
+ expect(factor.x).toBe(1);
187
+ expect(factor.y).not.toBe(1);
188
+ });
189
+
190
+ test("disabled flag short-circuits wheel handling", () => {
191
+ const canvas = fixture.canvas({ left: 0, top: 0, width: 400, height: 300 });
192
+ const vp = createDataViewport({
193
+ frame: viewportFrame(400, 300),
194
+ x: { type: "linear", domain: [0, 100] },
195
+ y: { type: "linear", domain: [0, 100] },
196
+ });
197
+ const zoomAt = vi.spyOn(vp, "zoomAt");
198
+
199
+ const binding = bindDataViewport(vp, canvas as unknown as HTMLElement);
200
+ binding.enabled = false;
201
+
202
+ const ev = fixture.wheelEvent(200, 150);
203
+ (canvas as unknown as FakeEventTarget).dispatch("wheel", ev);
204
+
205
+ expect(ev.preventDefault).not.toHaveBeenCalled();
206
+ expect(zoomAt).not.toHaveBeenCalled();
207
+ });
208
+
209
+ test("destroy releases the node", () => {
210
+ const canvas = fixture.canvas();
211
+ const vp = createDataViewport({
212
+ frame: viewportFrame(400, 300),
213
+ x: { type: "linear", domain: [0, 100] },
214
+ y: { type: "linear", domain: [0, 100] },
215
+ });
216
+ const zoomAt = vi.spyOn(vp, "zoomAt");
217
+
218
+ const binding = bindDataViewport(vp, canvas as unknown as HTMLElement);
219
+ binding.destroy();
220
+
221
+ const ev = fixture.wheelEvent(200, 150);
222
+ (canvas as unknown as FakeEventTarget).dispatch("wheel", ev);
223
+ expect(zoomAt).not.toHaveBeenCalled();
224
+ expect(ev.preventDefault).not.toHaveBeenCalled();
225
+ });
226
+ });
@@ -0,0 +1,394 @@
1
+ import {
2
+ createInteractionManager,
3
+ createVelocityTracker,
4
+ smoothDamp,
5
+ VIEWPORT_Z,
6
+ type FlingOptions,
7
+ type InteractionNode,
8
+ type SmoothDamp,
9
+ type VelocityTracker,
10
+ } from "insomni";
11
+ import type { DataViewport } from "./viewport.ts";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Public types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export type AxisSelection = "x" | "y" | "xy" | "none";
18
+
19
+ export interface BindDataViewportOptions {
20
+ /** Enable pointer drag pan. Default: true. */
21
+ drag?: boolean;
22
+ /** Enable mouse-wheel zoom. Default: true. */
23
+ wheel?: boolean;
24
+ /** Enable two-finger pinch (touch). Default: true. */
25
+ pinch?: boolean;
26
+ /** SmoothDamp time constant (seconds). `0` = instant (no smoothing). Default: `0`. */
27
+ smoothTime?: number;
28
+ /**
29
+ * Drag-release fling. `true` enables with defaults, `false` disables, or
30
+ * pass an object to tune. Default: off (fling on a chart feels disorienting).
31
+ */
32
+ fling?: boolean | FlingOptions;
33
+
34
+ /**
35
+ * Axes that respond to drag / pinch pan. Default: derived from axis types
36
+ * (continuous/time axes pan; band axes don't).
37
+ */
38
+ pan?: AxisSelection;
39
+ /** Axes that respond to wheel / pinch zoom. Default: derived from axis types. */
40
+ zoom?: AxisSelection;
41
+ /** Multiplier applied to wheel deltaY. Default: 0.001. */
42
+ wheelSensitivity?: number;
43
+ /**
44
+ * When true, Shift while wheeling zooms Y only; Meta / Ctrl zoom X only.
45
+ * Default: true.
46
+ */
47
+ axisModifiers?: boolean;
48
+ }
49
+
50
+ export interface DataViewportBinding {
51
+ readonly mode: "data";
52
+ /** True while the user is actively dragging or pinching. */
53
+ readonly interacting: boolean;
54
+ /** True while a drag-release fling is still decaying. */
55
+ readonly flinging: boolean;
56
+ /** Hard disable. */
57
+ enabled: boolean;
58
+ /** Advance smoothing / fling by `dt` seconds and apply pending pan. */
59
+ update(dt: number): void;
60
+ /** Immediately cancel any pending smoothing and fling. */
61
+ stopAnimation(): void;
62
+ /** Remove all listeners and release any pending state. */
63
+ destroy(): void;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Helpers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function nowSeconds(): number {
71
+ return (typeof performance !== "undefined" ? performance.now() : Date.now()) / 1000;
72
+ }
73
+
74
+ interface ResolvedFling {
75
+ enabled: boolean;
76
+ friction: number;
77
+ minVelocity: number;
78
+ windowSeconds: number;
79
+ }
80
+
81
+ function resolveFling(
82
+ opts: BindDataViewportOptions["fling"],
83
+ defaultEnabled: boolean,
84
+ ): ResolvedFling {
85
+ if (opts === false) {
86
+ return { enabled: false, friction: 4, minVelocity: 50, windowSeconds: 0.08 };
87
+ }
88
+ const cfg: FlingOptions = opts === undefined || opts === true ? {} : opts;
89
+ return {
90
+ enabled: opts === undefined ? defaultEnabled : true,
91
+ friction: cfg.friction ?? 4,
92
+ minVelocity: cfg.minVelocity ?? 50,
93
+ windowSeconds: cfg.windowSeconds ?? 0.08,
94
+ };
95
+ }
96
+
97
+ function defaultAxes(viewport: DataViewport<unknown, unknown>): AxisSelection {
98
+ const state = viewport._state;
99
+ const xCont = state.xAxis.kind === "continuous";
100
+ const yCont = state.yAxis.kind === "continuous";
101
+ if (xCont && yCont) return "xy";
102
+ if (xCont) return "x";
103
+ if (yCont) return "y";
104
+ return "none";
105
+ }
106
+
107
+ function axisHas(axes: AxisSelection, axis: "x" | "y"): boolean {
108
+ return axes === "xy" || axes === axis;
109
+ }
110
+
111
+ // Returns the largest fraction of `factor` that BOTH continuous axes can apply
112
+ // without hitting their per-axis caps. zoomAxis caps newSpan (= curSpan/f) to
113
+ // [baseSpan/maxZoom, baseSpan/minZoom], so f ∈ [curSpan*minZoom/baseSpan,
114
+ // curSpan*maxZoom/baseSpan]; intersect across axes, clamp `factor` into it.
115
+ function clampJointFactor<X, Y>(viewport: DataViewport<X, Y>, factor: number): number {
116
+ const s = viewport._state;
117
+ const { minZoom, maxZoom } = s;
118
+ let lo = -Infinity;
119
+ let hi = Infinity;
120
+ for (const axis of [s.xAxis, s.yAxis]) {
121
+ if (axis.kind !== "continuous") continue;
122
+ const baseAbs = Math.abs(axis.baseT1 - axis.baseT0);
123
+ const curAbs = Math.abs(axis.t1 - axis.t0);
124
+ if (baseAbs === 0 || curAbs === 0) continue;
125
+ lo = Math.max(lo, (curAbs * minZoom) / baseAbs);
126
+ hi = Math.min(hi, (curAbs * maxZoom) / baseAbs);
127
+ }
128
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return factor;
129
+ if (lo > hi) return 1;
130
+ if (factor < lo) return lo;
131
+ if (factor > hi) return hi;
132
+ return factor;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // bindDataViewport
137
+ // ---------------------------------------------------------------------------
138
+
139
+ export function bindDataViewport<X, Y>(
140
+ viewport: DataViewport<X, Y>,
141
+ element: HTMLElement,
142
+ options: BindDataViewportOptions = {},
143
+ ): DataViewportBinding {
144
+ const auto = defaultAxes(viewport as DataViewport<unknown, unknown>);
145
+ const pan = options.pan ?? auto;
146
+ const zoom = options.zoom ?? auto;
147
+ const wheelEnabled = (options.wheel ?? true) && zoom !== "none";
148
+ const dragEnabled = (options.drag ?? true) && pan !== "none";
149
+ const pinchEnabled = (options.pinch ?? true) && (pan !== "none" || zoom !== "none");
150
+ const wheelSensitivity = options.wheelSensitivity ?? 0.001;
151
+ const axisModifiers = options.axisModifiers ?? true;
152
+ const smoothTime = options.smoothTime ?? 0;
153
+ const fling = resolveFling(options.fling, false);
154
+ // Only bother with the damper + update(dt) loop when the user has opted
155
+ // into smoothing or fling. Simple data viewports stay synchronous.
156
+ const animated = smoothTime > 0 || fling.enabled;
157
+
158
+ const sdX: SmoothDamp = smoothDamp({ smoothTime });
159
+ const sdY: SmoothDamp = smoothDamp({ smoothTime });
160
+ let lastAppliedX = 0;
161
+ let lastAppliedY = 0;
162
+
163
+ const tracker: VelocityTracker = createVelocityTracker({ windowSeconds: fling.windowSeconds });
164
+ let flingVx = 0;
165
+ let flingVy = 0;
166
+ let flinging = false;
167
+
168
+ let enabled = true;
169
+ let interacting = false;
170
+
171
+ const stopAnimation = () => {
172
+ sdX.setTarget(sdX.value);
173
+ sdX.setVelocity(0);
174
+ sdY.setTarget(sdY.value);
175
+ sdY.setVelocity(0);
176
+ lastAppliedX = sdX.value;
177
+ lastAppliedY = sdY.value;
178
+ flingVx = 0;
179
+ flingVy = 0;
180
+ flinging = false;
181
+ };
182
+
183
+ const resetPanTargets = () => {
184
+ sdX.setTarget(0);
185
+ sdX.setValue(0);
186
+ sdX.setVelocity(0);
187
+ sdY.setTarget(0);
188
+ sdY.setValue(0);
189
+ sdY.setVelocity(0);
190
+ lastAppliedX = 0;
191
+ lastAppliedY = 0;
192
+ };
193
+
194
+ const zoomAt = (x: number, y: number, factor: number, axes: AxisSelection) => {
195
+ const fx = axes !== "none" && axisHas(axes, "x") && axisHas(zoom, "x") ? factor : 1;
196
+ const fy = axes !== "none" && axisHas(axes, "y") && axisHas(zoom, "y") ? factor : 1;
197
+ if (fx === 1 && fy === 1) return;
198
+ // Lock both axes to the more-constrained one so e.g. an axis whose
199
+ // visible span already sits at its cap doesn't silently stop while the
200
+ // other keeps going (which stretches the chart out of proportion).
201
+ if (fx !== 1 && fy !== 1 && fx === fy) {
202
+ const locked = clampJointFactor(viewport, factor);
203
+ if (locked === 1) return;
204
+ viewport.zoomAt(x, y, { x: locked, y: locked });
205
+ return;
206
+ }
207
+ viewport.zoomAt(x, y, { x: fx, y: fy });
208
+ };
209
+
210
+ const panByImmediate = (dx: number, dy: number) => {
211
+ const ux = axisHas(pan, "x") ? dx : 0;
212
+ const uy = axisHas(pan, "y") ? dy : 0;
213
+ if (ux || uy) viewport.panBy(ux, uy);
214
+ };
215
+
216
+ const manager = createInteractionManager(element);
217
+ const node: InteractionNode = manager.add({
218
+ zIndex: VIEWPORT_Z,
219
+ enabled,
220
+ space: "ui",
221
+ bounds: () => viewport.absoluteFrame,
222
+ cursor: dragEnabled ? "grab" : "default",
223
+ dragCursor: "grabbing",
224
+
225
+ onScroll: wheelEnabled
226
+ ? (evt) => {
227
+ const raw = 1 - evt.dy * wheelSensitivity;
228
+ const factor = raw <= 0 ? 0.001 : raw;
229
+ if (axisModifiers && (evt.mods.shift || evt.mods.meta || evt.mods.ctrl)) {
230
+ const onlyY = evt.mods.shift && !(evt.mods.meta || evt.mods.ctrl);
231
+ zoomAt(evt.x, evt.y, factor, onlyY ? "y" : "x");
232
+ } else {
233
+ zoomAt(evt.x, evt.y, factor, "xy");
234
+ }
235
+ }
236
+ : undefined,
237
+
238
+ onDragStart: dragEnabled
239
+ ? (evt) => {
240
+ interacting = true;
241
+ flinging = false;
242
+ flingVx = 0;
243
+ flingVy = 0;
244
+ if (animated) resetPanTargets();
245
+ if (fling.enabled) {
246
+ tracker.reset();
247
+ tracker.sample(nowSeconds(), evt.x, evt.y);
248
+ }
249
+ }
250
+ : undefined,
251
+ onDragMove: dragEnabled
252
+ ? (evt) => {
253
+ panByImmediate(evt.dx, evt.dy);
254
+ if (fling.enabled) tracker.sample(nowSeconds(), evt.x, evt.y);
255
+ }
256
+ : undefined,
257
+ onDragEnd: dragEnabled
258
+ ? () => {
259
+ interacting = false;
260
+ if (!fling.enabled) return;
261
+ const v = tracker.velocity();
262
+ const vx = axisHas(pan, "x") ? v.x : 0;
263
+ const vy = axisHas(pan, "y") ? v.y : 0;
264
+ const mag = Math.hypot(vx, vy);
265
+ if (mag < fling.minVelocity) return;
266
+ resetPanTargets();
267
+ flingVx = vx;
268
+ flingVy = vy;
269
+ flinging = true;
270
+ }
271
+ : undefined,
272
+
273
+ onPinch: pinchEnabled
274
+ ? (evt) => {
275
+ if (evt.phase === "start") {
276
+ interacting = true;
277
+ flinging = false;
278
+ flingVx = 0;
279
+ flingVy = 0;
280
+ if (animated) resetPanTargets();
281
+ return;
282
+ }
283
+ if (evt.phase === "end") {
284
+ interacting = false;
285
+ return;
286
+ }
287
+ if (pan !== "none") {
288
+ panByImmediate(evt.deltaX, evt.deltaY);
289
+ }
290
+ if (
291
+ zoom !== "none" &&
292
+ evt.deltaScale !== 1 &&
293
+ Number.isFinite(evt.deltaScale) &&
294
+ evt.deltaScale > 0
295
+ ) {
296
+ zoomAt(evt.focalX, evt.focalY, evt.deltaScale, "xy");
297
+ }
298
+ }
299
+ : undefined,
300
+ });
301
+
302
+ const continuousDomainValue = (domain: unknown): [number, number] | null => {
303
+ if (!Array.isArray(domain) || domain.length !== 2) return null;
304
+ const a = domain[0];
305
+ const b = domain[1];
306
+ const av = a instanceof Date ? a.getTime() : typeof a === "number" ? a : NaN;
307
+ const bv = b instanceof Date ? b.getTime() : typeof b === "number" ? b : NaN;
308
+ if (!Number.isFinite(av) || !Number.isFinite(bv)) return null;
309
+ return [av, bv];
310
+ };
311
+
312
+ const domainsEqual = (a: [number, number] | null, b: [number, number] | null): boolean => {
313
+ if (a === null || b === null) return a === b;
314
+ return a[0] === b[0] && a[1] === b[1];
315
+ };
316
+
317
+ const update = (dt: number) => {
318
+ if (dt <= 0) return;
319
+ if (!animated) return;
320
+
321
+ if (flinging) {
322
+ if (axisHas(pan, "x")) sdX.setTarget(sdX.target + flingVx * dt);
323
+ if (axisHas(pan, "y")) sdY.setTarget(sdY.target + flingVy * dt);
324
+ const decay = Math.exp(-fling.friction * dt);
325
+ flingVx *= decay;
326
+ flingVy *= decay;
327
+ if (Math.hypot(flingVx, flingVy) < fling.minVelocity * 0.1) {
328
+ flingVx = 0;
329
+ flingVy = 0;
330
+ flinging = false;
331
+ }
332
+ }
333
+
334
+ const stepX = axisHas(pan, "x");
335
+ const stepY = axisHas(pan, "y");
336
+ if (stepX) sdX.step(dt);
337
+ if (stepY) sdY.step(dt);
338
+ const dx = stepX ? sdX.value - lastAppliedX : 0;
339
+ const dy = stepY ? sdY.value - lastAppliedY : 0;
340
+ if (dx === 0 && dy === 0) return;
341
+
342
+ const beforeX = stepX ? continuousDomainValue(viewport.visibleXDomain) : null;
343
+ const beforeY = stepY ? continuousDomainValue(viewport.visibleYDomain) : null;
344
+ viewport.panBy(dx, dy);
345
+ lastAppliedX += dx;
346
+ lastAppliedY += dy;
347
+
348
+ // Clamp detection: if the viewport rejected the pan (pan-bounds or a band
349
+ // axis), snap the damper to the current value so the user isn't fighting a
350
+ // growing residual target on the next drag. Fling velocity dies at the wall.
351
+ if (stepX && dx !== 0) {
352
+ const afterX = continuousDomainValue(viewport.visibleXDomain);
353
+ const clamped = !beforeX || !afterX || domainsEqual(beforeX, afterX);
354
+ if (clamped) {
355
+ sdX.setTarget(sdX.value);
356
+ sdX.setVelocity(0);
357
+ flingVx = 0;
358
+ }
359
+ }
360
+ if (stepY && dy !== 0) {
361
+ const afterY = continuousDomainValue(viewport.visibleYDomain);
362
+ const clamped = !beforeY || !afterY || domainsEqual(beforeY, afterY);
363
+ if (clamped) {
364
+ sdY.setTarget(sdY.value);
365
+ sdY.setVelocity(0);
366
+ flingVy = 0;
367
+ }
368
+ }
369
+ if (flingVx === 0 && flingVy === 0) flinging = false;
370
+ };
371
+
372
+ return {
373
+ mode: "data",
374
+ get interacting() {
375
+ return interacting;
376
+ },
377
+ get flinging() {
378
+ return flinging;
379
+ },
380
+ get enabled() {
381
+ return enabled;
382
+ },
383
+ set enabled(v: boolean) {
384
+ enabled = v;
385
+ node.update({ enabled: v });
386
+ },
387
+ update,
388
+ stopAnimation,
389
+ destroy() {
390
+ node.destroy();
391
+ manager.destroy();
392
+ },
393
+ };
394
+ }
@@ -0,0 +1,48 @@
1
+ import type { Layer } from "insomni";
2
+ export interface MeasuredBox {
3
+ width: number;
4
+ height: number;
5
+ }
6
+ export interface BoxOrigin {
7
+ x: number;
8
+ y: number;
9
+ }
10
+ /**
11
+ * A measure-then-place rectangle. Implementations must return a stable
12
+ * `measure()` (called by parents to size themselves) and an `addTo` that
13
+ * draws into a layer at the given top-left.
14
+ */
15
+ export interface Placeable {
16
+ measure(): MeasuredBox;
17
+ addTo(layer: Layer, origin: BoxOrigin): void;
18
+ }
19
+ export type StackDirection = "horizontal" | "vertical";
20
+ export type StackAlign = "start" | "center" | "end";
21
+ export interface StackOptions {
22
+ direction: StackDirection;
23
+ /** Cross-axis alignment. Default `"start"`. */
24
+ align?: StackAlign;
25
+ /** Pixel gap between items. Default `0`. */
26
+ gap?: number;
27
+ }
28
+ /**
29
+ * Lay out `items` along one axis. The stack's bbox is the sum of children
30
+ * along the main axis (plus gaps) and the max along the cross axis.
31
+ *
32
+ * Children are measured **once** at construction; `measure()` and `addTo()`
33
+ * are O(items) thereafter. If you need to refresh measurements, build a new
34
+ * stack.
35
+ */
36
+ export declare function stack(items: readonly Placeable[], opts: StackOptions): Placeable;
37
+ export interface Padding {
38
+ top?: number;
39
+ right?: number;
40
+ bottom?: number;
41
+ left?: number;
42
+ }
43
+ /** Wrap `item` so its bbox includes uniform or per-side margin. */
44
+ export declare function pad(item: Placeable, p: Padding): Placeable;
45
+ /** A degenerate Placeable that occupies space but draws nothing. */
46
+ export declare function spacer(width: number, height: number): Placeable;
47
+ /** Lift a measure + draw pair into a Placeable. */
48
+ export declare function placeable(measure: MeasuredBox, draw: (layer: Layer, origin: BoxOrigin) => void): Placeable;