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,363 @@
1
+ import { describe, expect, test } from "vite-plus/test";
2
+
3
+ import { viewportFrame } from "insomni";
4
+ import { createDataViewport, linkViewports } from "./viewport.ts";
5
+ import type { ContinuousScale, TimeScale } from "./scales.ts";
6
+
7
+ const asLinear = (s: unknown) => s as ContinuousScale;
8
+ const asTime = (s: unknown) => s as TimeScale;
9
+ const asBand = (s: unknown) => s as (v: string) => number;
10
+
11
+ describe("data viewport", () => {
12
+ test("exposes scales matching the frame range", () => {
13
+ const vp = createDataViewport({
14
+ frame: viewportFrame(400, 300),
15
+ x: { type: "linear", domain: [0, 100] },
16
+ y: { type: "linear", domain: [0, 100] },
17
+ });
18
+
19
+ expect(asLinear(vp.x)(0)).toBeCloseTo(0, 6);
20
+ expect(asLinear(vp.x)(100)).toBeCloseTo(400, 6);
21
+ // Y is inverted so high values render higher on screen (smaller pixel y).
22
+ expect(asLinear(vp.y)(0)).toBeCloseTo(300, 6);
23
+ expect(asLinear(vp.y)(100)).toBeCloseTo(0, 6);
24
+ });
25
+
26
+ test("panBy shifts visible domain so data stays under pointer", () => {
27
+ const vp = createDataViewport({
28
+ frame: viewportFrame(400, 300),
29
+ x: { type: "linear", domain: [0, 100] },
30
+ y: { type: "linear", domain: [0, 100] },
31
+ });
32
+ expect(asLinear(vp.x).invert(200)).toBeCloseTo(50, 6);
33
+ vp.panBy(40, 0);
34
+ expect(asLinear(vp.x).invert(240)).toBeCloseTo(50, 6);
35
+ });
36
+
37
+ test("zoomAt keeps the anchor data value pinned to the anchor pixel", () => {
38
+ const vp = createDataViewport({
39
+ frame: viewportFrame(400, 300),
40
+ x: { type: "linear", domain: [0, 100] },
41
+ y: { type: "linear", domain: [0, 100] },
42
+ });
43
+ const anchorSx = 100;
44
+ const before = asLinear(vp.x).invert(anchorSx);
45
+ vp.zoomAt(anchorSx, 0, { x: 2 });
46
+ const after = asLinear(vp.x).invert(anchorSx);
47
+ expect(after).toBeCloseTo(before, 6);
48
+ });
49
+
50
+ test("reset returns to the initial domain", () => {
51
+ const vp = createDataViewport({
52
+ frame: viewportFrame(400, 300),
53
+ x: { type: "linear", domain: [0, 100] },
54
+ y: { type: "linear", domain: [0, 100] },
55
+ });
56
+ vp.panBy(40, 0);
57
+ vp.zoomAt(200, 150, { x: 2 });
58
+ vp.reset();
59
+ expect(asLinear(vp.x).invert(0)).toBeCloseTo(0, 6);
60
+ expect(asLinear(vp.x).invert(400)).toBeCloseTo(100, 6);
61
+ });
62
+
63
+ test("onChange fires on mutation and not on no-op", () => {
64
+ const vp = createDataViewport({
65
+ frame: viewportFrame(400, 300),
66
+ x: { type: "linear", domain: [0, 100] },
67
+ y: { type: "linear", domain: [0, 100] },
68
+ });
69
+ let calls = 0;
70
+ vp.onChange(() => calls++);
71
+ vp.panBy(10, 0);
72
+ expect(calls).toBe(1);
73
+ vp.panBy(0, 0);
74
+ expect(calls).toBe(1);
75
+ });
76
+
77
+ test("screenToData and dataToScreen round-trip", () => {
78
+ const vp = createDataViewport({
79
+ frame: viewportFrame(400, 300),
80
+ x: { type: "linear", domain: [0, 100] },
81
+ y: { type: "linear", domain: [0, 100] },
82
+ });
83
+ for (const p of [
84
+ { x: 25, y: 25 },
85
+ { x: 50, y: 50 },
86
+ { x: 75, y: 75 },
87
+ ]) {
88
+ const s = vp.dataToScreen(p.x, p.y);
89
+ const d = vp.screenToData(s.x, s.y);
90
+ expect(d?.x).toBeCloseTo(p.x, 6);
91
+ expect(d?.y).toBeCloseTo(p.y, 6);
92
+ }
93
+ });
94
+
95
+ test("band axis is stable under pan/zoom", () => {
96
+ const vp = createDataViewport<number, string>({
97
+ frame: viewportFrame(400, 300),
98
+ x: { type: "linear", domain: [0, 100] },
99
+ y: { type: "band", domain: ["a", "b", "c"] },
100
+ });
101
+ const before = asBand(vp.y)("b");
102
+ vp.panBy(0, 50);
103
+ expect(asBand(vp.y)("b")).toBeCloseTo(before, 6);
104
+ });
105
+
106
+ test("time axis pans in milliseconds", () => {
107
+ const t0 = new Date(2020, 0, 1).getTime();
108
+ const t1 = new Date(2020, 0, 31).getTime();
109
+ const vp = createDataViewport({
110
+ frame: viewportFrame(400, 300),
111
+ x: { type: "time", domain: [new Date(t0), new Date(t1)] },
112
+ y: { type: "linear", domain: [0, 100] },
113
+ });
114
+ expect(asTime(vp.x).invert(200)).toBeInstanceOf(Date);
115
+ });
116
+
117
+ test("setVisibleDomain assigns absolute endpoints and notifies", () => {
118
+ const vp = createDataViewport({
119
+ frame: viewportFrame(400, 300),
120
+ x: { type: "linear", domain: [0, 100] },
121
+ y: { type: "linear", domain: [0, 100] },
122
+ });
123
+ let calls = 0;
124
+ vp.onChange(() => calls++);
125
+ vp.setVisibleDomain({ x: [10, 20] });
126
+ const [x0, x1] = vp.visibleXDomain as readonly [number, number];
127
+ expect(x0).toBeCloseTo(10, 6);
128
+ expect(x1).toBeCloseTo(20, 6);
129
+ expect(calls).toBe(1);
130
+ });
131
+
132
+ test("setVisibleDomain rejects invalid log domains", () => {
133
+ const vp = createDataViewport({
134
+ frame: viewportFrame(400, 300),
135
+ x: { type: "log", domain: [1, 100] },
136
+ y: { type: "linear", domain: [0, 100] },
137
+ });
138
+ expect(() => vp.setVisibleDomain({ x: [-1, 10] })).toThrow();
139
+ });
140
+
141
+ test("setVisibleDomain silently skips band axes", () => {
142
+ const vp = createDataViewport<string, number>({
143
+ frame: viewportFrame(400, 300),
144
+ x: { type: "band", domain: ["a", "b", "c"] },
145
+ y: { type: "linear", domain: [0, 100] },
146
+ });
147
+ let calls = 0;
148
+ vp.onChange(() => calls++);
149
+ vp.setVisibleDomain({ x: [10, 20] as never });
150
+ expect(calls).toBe(0);
151
+ });
152
+
153
+ test("linkViewports propagates pan on linked axis only", () => {
154
+ const a = createDataViewport({
155
+ frame: viewportFrame(400, 300),
156
+ x: { type: "linear", domain: [0, 100] },
157
+ y: { type: "linear", domain: [0, 100] },
158
+ });
159
+ const b = createDataViewport({
160
+ frame: viewportFrame(400, 300),
161
+ x: { type: "linear", domain: [0, 100] },
162
+ y: { type: "linear", domain: [0, 100] },
163
+ });
164
+ const unlink = linkViewports([a, b], { x: true, y: false });
165
+
166
+ a.panBy(40, 0);
167
+ expect(asLinear(b.x).invert(240)).toBeCloseTo(50, 6);
168
+ unlink();
169
+ });
170
+
171
+ test("panBounds: zoomed in, panBy stops at content edges with margin=0", () => {
172
+ const vp = createDataViewport({
173
+ frame: viewportFrame(400, 300),
174
+ x: { type: "linear", domain: [0, 100] },
175
+ y: { type: "linear", domain: [0, 100] },
176
+ panBounds: {},
177
+ });
178
+ vp.zoomAt(200, 150, { x: 2 });
179
+ vp.panBy(-1000, 0);
180
+ const [x0, x1] = vp.visibleXDomain as readonly [number, number];
181
+ expect(x0).toBeCloseTo(50, 6);
182
+ expect(x1).toBeCloseTo(100, 6);
183
+ });
184
+
185
+ test("panBounds: zoomed out, content stays fully visible", () => {
186
+ const vp = createDataViewport({
187
+ frame: viewportFrame(400, 300),
188
+ x: { type: "linear", domain: [0, 100] },
189
+ y: { type: "linear", domain: [0, 100] },
190
+ panBounds: {},
191
+ });
192
+ vp.zoomAt(200, 150, { x: 0.5 });
193
+ vp.panBy(400, 0);
194
+ const [x0, x1] = vp.visibleXDomain as readonly [number, number];
195
+ expect(x0).toBeLessThanOrEqual(0 + 1e-6);
196
+ expect(x1).toBeGreaterThanOrEqual(100 - 1e-6);
197
+ });
198
+
199
+ test("panBounds: overshoot allows pan-past content edges when zoomed in", () => {
200
+ const vp = createDataViewport({
201
+ frame: viewportFrame(400, 300),
202
+ x: { type: "linear", domain: [0, 100] },
203
+ y: { type: "linear", domain: [0, 100] },
204
+ panBounds: { overshoot: { x: 0.1, y: 0 } },
205
+ });
206
+ vp.zoomAt(200, 150, { x: 2 });
207
+ vp.panBy(-1000, 0);
208
+ const [x0, x1] = vp.visibleXDomain as readonly [number, number];
209
+ expect(x0).toBeCloseTo(55, 6);
210
+ expect(x1).toBeCloseTo(105, 6);
211
+ });
212
+
213
+ test("panBounds: setVisibleDomain is also clamped", () => {
214
+ const vp = createDataViewport({
215
+ frame: viewportFrame(400, 300),
216
+ x: { type: "linear", domain: [0, 100] },
217
+ y: { type: "linear", domain: [0, 100] },
218
+ panBounds: {},
219
+ });
220
+ vp.setVisibleDomain({ x: [200, 250] });
221
+ const [x0, x1] = vp.visibleXDomain as readonly [number, number];
222
+ expect(x0).toBeCloseTo(50, 6);
223
+ expect(x1).toBeCloseTo(100, 6);
224
+ });
225
+
226
+ test("panBounds: undefined leaves pan unconstrained", () => {
227
+ const vp = createDataViewport({
228
+ frame: viewportFrame(400, 300),
229
+ x: { type: "linear", domain: [0, 100] },
230
+ y: { type: "linear", domain: [0, 100] },
231
+ });
232
+ vp.setVisibleDomain({ x: [500, 600] });
233
+ const [x0, x1] = vp.visibleXDomain as readonly [number, number];
234
+ expect(x0).toBeCloseTo(500, 6);
235
+ expect(x1).toBeCloseTo(600, 6);
236
+ });
237
+ });
238
+
239
+ describe("getLinearProjector", () => {
240
+ test("projector math matches dataToScreen for linear/linear viewport", () => {
241
+ const vp = createDataViewport({
242
+ frame: viewportFrame(400, 300),
243
+ x: { type: "linear", domain: [0, 100] },
244
+ y: { type: "linear", domain: [0, 100] },
245
+ });
246
+ const proj = vp.getLinearProjector();
247
+ expect(proj).not.toBeNull();
248
+ for (const [dx, dy] of [
249
+ [0, 0],
250
+ [25, 75],
251
+ [50, 50],
252
+ [100, 0],
253
+ ] as [number, number][]) {
254
+ const screen = vp.dataToScreen(dx, dy);
255
+ expect(dx * proj!.txSlope + proj!.txConst).toBeCloseTo(screen.x, 6);
256
+ expect(dy * proj!.tySlope + proj!.tyConst).toBeCloseTo(screen.y, 6);
257
+ }
258
+ });
259
+
260
+ test("projector remains correct after panBy", () => {
261
+ const vp = createDataViewport({
262
+ frame: viewportFrame(400, 300),
263
+ x: { type: "linear", domain: [0, 100] },
264
+ y: { type: "linear", domain: [0, 100] },
265
+ });
266
+ vp.panBy(40, 0);
267
+ const proj = vp.getLinearProjector();
268
+ expect(proj).not.toBeNull();
269
+ const screen = vp.dataToScreen(50, 50);
270
+ expect(50 * proj!.txSlope + proj!.txConst).toBeCloseTo(screen.x, 6);
271
+ expect(50 * proj!.tySlope + proj!.tyConst).toBeCloseTo(screen.y, 6);
272
+ });
273
+
274
+ test("returns null for log x axis", () => {
275
+ const vp = createDataViewport({
276
+ frame: viewportFrame(400, 300),
277
+ x: { type: "log", domain: [1, 100] },
278
+ y: { type: "linear", domain: [0, 100] },
279
+ });
280
+ expect(vp.getLinearProjector()).toBeNull();
281
+ });
282
+
283
+ test("returns null for time x axis", () => {
284
+ const vp = createDataViewport({
285
+ frame: viewportFrame(400, 300),
286
+ x: { type: "time", domain: [new Date(2020, 0, 1), new Date(2020, 11, 31)] },
287
+ y: { type: "linear", domain: [0, 100] },
288
+ });
289
+ expect(vp.getLinearProjector()).toBeNull();
290
+ });
291
+
292
+ test("returns null for band y axis", () => {
293
+ const vp = createDataViewport<number, string>({
294
+ frame: viewportFrame(400, 300),
295
+ x: { type: "linear", domain: [0, 100] },
296
+ y: { type: "band", domain: ["a", "b", "c"] },
297
+ });
298
+ expect(vp.getLinearProjector()).toBeNull();
299
+ });
300
+ });
301
+
302
+ describe("axis scale cache", () => {
303
+ // Each mutator must invalidate the cache; reads must reuse it.
304
+ // The scale getter returns the cached instance on hot paths like
305
+ // `dataToScreen` (called per-entry from anchored-labels at 184k+ entries).
306
+ function makeVp() {
307
+ return createDataViewport({
308
+ frame: viewportFrame(400, 300),
309
+ x: { type: "linear", domain: [0, 100] },
310
+ y: { type: "linear", domain: [0, 100] },
311
+ });
312
+ }
313
+
314
+ test("consecutive scale reads return the cached instance", () => {
315
+ const vp = makeVp();
316
+ const a = vp.x;
317
+ const b = vp.x;
318
+ expect(a).toBe(b);
319
+ });
320
+
321
+ test("panBy invalidates the cache", () => {
322
+ const vp = makeVp();
323
+ const before = vp.x;
324
+ vp.panBy(10, 0);
325
+ expect(vp.x).not.toBe(before);
326
+ });
327
+
328
+ test("zoomAt invalidates the cache", () => {
329
+ const vp = makeVp();
330
+ const before = vp.x;
331
+ vp.zoomAt(200, 150, 2);
332
+ expect(vp.x).not.toBe(before);
333
+ });
334
+
335
+ test("setVisibleDomain invalidates the cache", () => {
336
+ const vp = makeVp();
337
+ const before = vp.x;
338
+ vp.setVisibleDomain({ x: [10, 90] });
339
+ expect(vp.x).not.toBe(before);
340
+ });
341
+
342
+ test("setFrame invalidates the cache when the range changes", () => {
343
+ const vp = makeVp();
344
+ const before = vp.x;
345
+ vp.setFrame(viewportFrame(800, 600));
346
+ expect(vp.x).not.toBe(before);
347
+ });
348
+
349
+ test("reset invalidates the cache", () => {
350
+ const vp = makeVp();
351
+ vp.panBy(10, 0); // move off baseline so reset actually mutates
352
+ const before = vp.x;
353
+ vp.reset();
354
+ expect(vp.x).not.toBe(before);
355
+ });
356
+
357
+ test("no-op mutators (zero pan) keep the cached instance", () => {
358
+ const vp = makeVp();
359
+ const before = vp.x;
360
+ vp.panBy(0, 0);
361
+ expect(vp.x).toBe(before);
362
+ });
363
+ });