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,345 @@
1
+ import { describe, expect, test } from "vite-plus/test";
2
+
3
+ import { viewportFrame } from "insomni";
4
+ import {
5
+ createRangePresets,
6
+ linearPreset,
7
+ logPreset,
8
+ timePreset,
9
+ type RangePreset,
10
+ } from "./range-presets.ts";
11
+ import { createDataViewport } from "./viewport.ts";
12
+
13
+ const FROZEN_NOW = new Date("2026-05-20T12:00:00Z").getTime();
14
+ const now = () => FROZEN_NOW;
15
+
16
+ const linearViewport = (domain: [number, number] = [0, 100]) =>
17
+ createDataViewport({
18
+ frame: viewportFrame(400, 300),
19
+ x: { type: "linear", domain },
20
+ y: { type: "linear", domain: [0, 100] },
21
+ });
22
+
23
+ const timeViewport = (domain: [Date, Date]) =>
24
+ createDataViewport({
25
+ frame: viewportFrame(400, 300),
26
+ x: { type: "time", domain },
27
+ y: { type: "linear", domain: [0, 100] },
28
+ });
29
+
30
+ describe("createRangePresets — preset resolution", () => {
31
+ test("time presets resolve against a frozen `now`", () => {
32
+ const dataDomain: [Date, Date] = [new Date("2024-01-01T00:00:00Z"), new Date(FROZEN_NOW)];
33
+ const vp = timeViewport(dataDomain);
34
+ const ctl = createRangePresets({
35
+ viewport: vp,
36
+ axis: "x",
37
+ presets: [timePreset("24H"), timePreset("7D"), timePreset("1M"), timePreset("MAX")],
38
+ dataDomain,
39
+ now,
40
+ });
41
+
42
+ ctl.setActive("24H");
43
+ const [s24, e24] = vp.visibleXDomain as readonly [Date, Date];
44
+ expect(e24.getTime()).toBe(FROZEN_NOW);
45
+ expect(s24.getTime()).toBe(FROZEN_NOW - 24 * 3600_000);
46
+
47
+ ctl.setActive("7D");
48
+ const [s7] = vp.visibleXDomain as readonly [Date, Date];
49
+ expect(s7.getTime()).toBe(FROZEN_NOW - 7 * 24 * 3600_000);
50
+
51
+ ctl.setActive("1M");
52
+ const [s1m] = vp.visibleXDomain as readonly [Date, Date];
53
+ expect(s1m.getTime()).toBe(FROZEN_NOW - 30 * 24 * 3600_000);
54
+ });
55
+
56
+ test("YTD spans January 1 to now", () => {
57
+ const dataDomain: [Date, Date] = [new Date("2020-01-01T00:00:00Z"), new Date(FROZEN_NOW)];
58
+ const vp = timeViewport(dataDomain);
59
+ const ctl = createRangePresets({
60
+ viewport: vp,
61
+ axis: "x",
62
+ presets: [timePreset("YTD")],
63
+ dataDomain,
64
+ now,
65
+ });
66
+ ctl.setActive("YTD");
67
+ const [start, end] = vp.visibleXDomain as readonly [Date, Date];
68
+ expect(end.getTime()).toBe(FROZEN_NOW);
69
+ expect(start.getUTCFullYear()).toBe(2026);
70
+ expect(start.getUTCMonth()).toBe(0);
71
+ expect(start.getUTCDate()).toBe(1);
72
+ });
73
+
74
+ test("MAX resets to the full data extent", () => {
75
+ const dataDomain: [Date, Date] = [new Date("2024-01-01T00:00:00Z"), new Date(FROZEN_NOW)];
76
+ const vp = timeViewport(dataDomain);
77
+ vp.setVisibleDomain({
78
+ x: [new Date("2025-06-01T00:00:00Z"), new Date("2025-09-01T00:00:00Z")] as const,
79
+ });
80
+
81
+ const ctl = createRangePresets({
82
+ viewport: vp,
83
+ axis: "x",
84
+ presets: [timePreset("MAX")],
85
+ dataDomain,
86
+ now,
87
+ });
88
+ ctl.setActive("MAX");
89
+ const [start, end] = vp.visibleXDomain as readonly [Date, Date];
90
+ expect(start.getTime()).toBe(dataDomain[0].getTime());
91
+ expect(end.getTime()).toBe(dataDomain[1].getTime());
92
+ });
93
+
94
+ test("linearPreset shows the last `span` units", () => {
95
+ const vp = linearViewport([0, 100]);
96
+ const ctl = createRangePresets({
97
+ viewport: vp,
98
+ axis: "x",
99
+ presets: [linearPreset({ key: "L20", label: "Last 20", span: 20 })],
100
+ dataDomain: [0, 100],
101
+ });
102
+ ctl.setActive("L20");
103
+ expect(vp.visibleXDomain).toEqual([80, 100]);
104
+ });
105
+
106
+ test("linearPreset with Infinity clamps to data extent", () => {
107
+ const vp = linearViewport([0, 100]);
108
+ vp.setVisibleDomain({ x: [25, 50] });
109
+ const ctl = createRangePresets({
110
+ viewport: vp,
111
+ axis: "x",
112
+ presets: [linearPreset({ key: "max", label: "MAX", span: Infinity })],
113
+ dataDomain: [0, 100],
114
+ });
115
+ ctl.setActive("max");
116
+ expect(vp.visibleXDomain).toEqual([0, 100]);
117
+ });
118
+
119
+ test("logPreset shows the last N decades, clamped to data lower bound", () => {
120
+ const vp = linearViewport([1, 1000]);
121
+ const ctl = createRangePresets({
122
+ viewport: vp,
123
+ axis: "x",
124
+ presets: [
125
+ logPreset({ key: "1d", label: "1 decade", decades: 1 }),
126
+ logPreset({ key: "5d", label: "5 decades", decades: 5 }),
127
+ ],
128
+ dataDomain: [1, 1000],
129
+ });
130
+ ctl.setActive("1d");
131
+ expect(vp.visibleXDomain).toEqual([100, 1000]);
132
+
133
+ ctl.setActive("5d");
134
+ // 1000 / 10^5 = 0.01, clamped to data lower bound = 1
135
+ expect(vp.visibleXDomain).toEqual([1, 1000]);
136
+ });
137
+
138
+ test("custom resolver preset receives ctx and applies returned domain", () => {
139
+ const vp = linearViewport([0, 100]);
140
+ const seen: Array<{ dataDomain: unknown; now: number }> = [];
141
+ const custom: RangePreset = {
142
+ key: "half",
143
+ label: "Half",
144
+ resolve: (ctx) => {
145
+ seen.push({ dataDomain: ctx.dataDomain, now: ctx.now });
146
+ const [lo, hi] = ctx.dataDomain as readonly [number, number];
147
+ return [lo + (hi - lo) / 2, hi] as const;
148
+ },
149
+ };
150
+ const ctl = createRangePresets({
151
+ viewport: vp,
152
+ axis: "x",
153
+ presets: [custom],
154
+ dataDomain: [0, 100],
155
+ now,
156
+ });
157
+ ctl.setActive("half");
158
+ expect(vp.visibleXDomain).toEqual([50, 100]);
159
+ expect(seen).toEqual([{ dataDomain: [0, 100], now: FROZEN_NOW }]);
160
+ });
161
+
162
+ test("preset returning null leaves the viewport untouched and clears active", () => {
163
+ const vp = linearViewport([0, 100]);
164
+ vp.setVisibleDomain({ x: [10, 20] });
165
+ const declining: RangePreset = {
166
+ key: "nope",
167
+ label: "Nope",
168
+ resolve: () => null,
169
+ };
170
+ const ctl = createRangePresets({
171
+ viewport: vp,
172
+ axis: "x",
173
+ presets: [declining],
174
+ });
175
+ ctl.setActive("nope");
176
+ expect(vp.visibleXDomain).toEqual([10, 20]);
177
+ expect(ctl.getActive()).toBeNull();
178
+ });
179
+ });
180
+
181
+ describe("createRangePresets — active state + manual-change detection", () => {
182
+ test("setActive(null) clears active without touching the viewport", () => {
183
+ const vp = linearViewport([0, 100]);
184
+ vp.setVisibleDomain({ x: [30, 70] });
185
+ const ctl = createRangePresets({
186
+ viewport: vp,
187
+ axis: "x",
188
+ presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
189
+ dataDomain: [0, 100],
190
+ });
191
+ ctl.setActive("L20");
192
+ expect(ctl.getActive()).toBe("L20");
193
+ expect(vp.visibleXDomain).toEqual([80, 100]);
194
+
195
+ ctl.setActive(null);
196
+ expect(ctl.getActive()).toBeNull();
197
+ // Viewport stays where the preset left it.
198
+ expect(vp.visibleXDomain).toEqual([80, 100]);
199
+ });
200
+
201
+ test("manual panBy clears active to null", () => {
202
+ const vp = linearViewport([0, 100]);
203
+ const ctl = createRangePresets({
204
+ viewport: vp,
205
+ axis: "x",
206
+ presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
207
+ dataDomain: [0, 100],
208
+ });
209
+ ctl.setActive("L20");
210
+ expect(ctl.getActive()).toBe("L20");
211
+
212
+ vp.panBy(10, 0);
213
+ expect(ctl.getActive()).toBeNull();
214
+ });
215
+
216
+ test("manual setVisibleDomain by consumer clears active", () => {
217
+ const vp = linearViewport([0, 100]);
218
+ const ctl = createRangePresets({
219
+ viewport: vp,
220
+ axis: "x",
221
+ presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
222
+ dataDomain: [0, 100],
223
+ });
224
+ ctl.setActive("L20");
225
+ expect(ctl.getActive()).toBe("L20");
226
+
227
+ vp.setVisibleDomain({ x: [40, 60] });
228
+ expect(ctl.getActive()).toBeNull();
229
+ });
230
+
231
+ test("y-axis change does not clear x-axis preset", () => {
232
+ const vp = linearViewport([0, 100]);
233
+ const ctl = createRangePresets({
234
+ viewport: vp,
235
+ axis: "x",
236
+ presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
237
+ dataDomain: [0, 100],
238
+ });
239
+ ctl.setActive("L20");
240
+ expect(ctl.getActive()).toBe("L20");
241
+
242
+ vp.setVisibleDomain({ y: [10, 50] });
243
+ expect(ctl.getActive()).toBe("L20");
244
+ });
245
+
246
+ test("re-activating the same preset is idempotent (no subscriber call)", () => {
247
+ const vp = linearViewport([0, 100]);
248
+ const ctl = createRangePresets({
249
+ viewport: vp,
250
+ axis: "x",
251
+ presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
252
+ dataDomain: [0, 100],
253
+ });
254
+ const calls: Array<string | null> = [];
255
+ ctl.subscribe((k) => calls.push(k));
256
+ ctl.setActive("L20");
257
+ ctl.setActive("L20");
258
+ expect(calls).toEqual(["L20"]);
259
+ });
260
+
261
+ test("subscribe fires on activate and on manual-change reset", () => {
262
+ const vp = linearViewport([0, 100]);
263
+ const ctl = createRangePresets({
264
+ viewport: vp,
265
+ axis: "x",
266
+ presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
267
+ dataDomain: [0, 100],
268
+ });
269
+ const calls: Array<string | null> = [];
270
+ const unsub = ctl.subscribe((k) => calls.push(k));
271
+
272
+ ctl.setActive("L20");
273
+ vp.panBy(5, 0);
274
+ expect(calls).toEqual(["L20", null]);
275
+
276
+ unsub();
277
+ ctl.setActive("L20");
278
+ expect(calls).toEqual(["L20", null]);
279
+ });
280
+
281
+ test("dispose unsubscribes from viewport and drops subscribers", () => {
282
+ const vp = linearViewport([0, 100]);
283
+ const ctl = createRangePresets({
284
+ viewport: vp,
285
+ axis: "x",
286
+ presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
287
+ dataDomain: [0, 100],
288
+ });
289
+ const calls: Array<string | null> = [];
290
+ ctl.subscribe((k) => calls.push(k));
291
+ ctl.setActive("L20");
292
+
293
+ ctl.dispose();
294
+ // After dispose, a manual pan should not produce any more subscriber
295
+ // calls (active-state diff is also disabled because we unsubscribed).
296
+ vp.panBy(10, 0);
297
+ expect(calls).toEqual(["L20"]);
298
+ });
299
+ });
300
+
301
+ describe("createRangePresets — dataDomain fallback", () => {
302
+ test("snapshots viewport's domain when dataDomain is omitted", () => {
303
+ const vp = linearViewport([0, 100]);
304
+ const ctl = createRangePresets({
305
+ viewport: vp,
306
+ axis: "x",
307
+ presets: [linearPreset({ key: "max", label: "MAX", span: Infinity })],
308
+ // dataDomain omitted — should snapshot [0, 100] at construction.
309
+ });
310
+ vp.setVisibleDomain({ x: [25, 50] });
311
+ ctl.setActive("max");
312
+ expect(vp.visibleXDomain).toEqual([0, 100]);
313
+ });
314
+
315
+ test("dataDomain getter is re-read on each setActive", () => {
316
+ const vp = linearViewport([0, 100]);
317
+ let domain: [number, number] = [0, 100];
318
+ const ctl = createRangePresets({
319
+ viewport: vp,
320
+ axis: "x",
321
+ presets: [linearPreset({ key: "max", label: "MAX", span: Infinity })],
322
+ dataDomain: () => domain,
323
+ });
324
+ ctl.setActive("max");
325
+ expect(vp.visibleXDomain).toEqual([0, 100]);
326
+
327
+ // Simulate data appended — extent grows; next activation should reflect it.
328
+ domain = [0, 200];
329
+ ctl.setActive("max");
330
+ expect(vp.visibleXDomain).toEqual([0, 200]);
331
+ });
332
+ });
333
+
334
+ describe("createRangePresets — error handling", () => {
335
+ test("unknown key throws", () => {
336
+ const vp = linearViewport([0, 100]);
337
+ const ctl = createRangePresets({
338
+ viewport: vp,
339
+ axis: "x",
340
+ presets: [linearPreset({ key: "L20", label: "20", span: 20 })],
341
+ dataDomain: [0, 100],
342
+ });
343
+ expect(() => ctl.setActive("missing")).toThrow(/unknown preset key/);
344
+ });
345
+ });
@@ -0,0 +1,349 @@
1
+ // ---------------------------------------------------------------------------
2
+ // createRangePresets — headless axis-range preset controller
3
+ // ---------------------------------------------------------------------------
4
+ // Generic across any continuous axis: time, linear, log, score thresholds,
5
+ // spatial extents. Given a `DataViewport` and a list of `RangePreset`s, the
6
+ // controller exposes `{ presets, setActive, getActive, subscribe, dispose }`.
7
+ //
8
+ // "Headless" means the library never touches the DOM — consumers render their
9
+ // own chips/buttons/menu and call `setActive(key)` on click. The library owns
10
+ // preset resolution + active-state diffing.
11
+ //
12
+ // Active-state goes to `null` ("custom") whenever the viewport's visible
13
+ // domain on the controlled axis no longer matches the resolved domain the
14
+ // last `setActive` applied. This makes manual pan / zoom / programmatic
15
+ // `setVisibleDomain` calls correctly clear the active chip without the
16
+ // consumer wiring anything else.
17
+
18
+ import type { DateDomain, NumericDomain } from "./scales.ts";
19
+ import type { DataViewport } from "./viewport.ts";
20
+ import type { VisibleDomainInput } from "./viewport/axis-state.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Axis the controller drives. */
27
+ export type RangePresetAxis = "x" | "y";
28
+
29
+ /**
30
+ * Resolved domain a preset applies to the viewport. Numeric pair for linear /
31
+ * log / score axes; Date pair for time axes.
32
+ */
33
+ export type RangePresetDomain = NumericDomain | DateDomain;
34
+
35
+ /** Optional getter form for the data-extent fallback. */
36
+ export type RangePresetDataDomain = RangePresetDomain | (() => RangePresetDomain);
37
+
38
+ /**
39
+ * Context passed to a preset's `resolve` function. `dataDomain` is the full
40
+ * extent of the underlying data (either provided by the consumer at
41
+ * construction time or, if omitted, the viewport's current visible domain
42
+ * sampled at construction). `now` is the current time (epoch ms) — relevant
43
+ * for time presets; helpers like `linearPreset` ignore it.
44
+ */
45
+ export interface RangePresetContext {
46
+ dataDomain: RangePresetDomain;
47
+ now: number;
48
+ }
49
+
50
+ /**
51
+ * A single preset entry. `resolve` returns the domain the viewport should be
52
+ * set to when the user activates this key. Return `null` to express "this
53
+ * preset has no valid domain given the current data" — the controller will
54
+ * leave the viewport untouched and not mark itself active.
55
+ */
56
+ export interface RangePreset {
57
+ key: string;
58
+ label: string;
59
+ resolve: (ctx: RangePresetContext) => RangePresetDomain | null;
60
+ }
61
+
62
+ export interface RangePresetsOptions {
63
+ /**
64
+ * Any `DataViewport` regardless of axis-value type — accepts the
65
+ * `DataViewport<number, number>` returned by `MountedPlot.viewport` as
66
+ * well as the unparameterized form. The controller only reads visible
67
+ * domains and applies new ones through the viewport's continuous-axis
68
+ * API, so X/Y value types are not constrained at this layer.
69
+ */
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ viewport: DataViewport<any, any>;
72
+ axis: RangePresetAxis;
73
+ presets: readonly RangePreset[];
74
+ /**
75
+ * Full data extent. Either a fixed `[min, max]` (numbers or Dates) or a
76
+ * getter that re-reads on each preset evaluation. If omitted, the
77
+ * controller snapshots the viewport's current visible domain at
78
+ * construction time and reuses it as the data domain.
79
+ */
80
+ dataDomain?: RangePresetDataDomain;
81
+ /** Defaults to `Date.now`. Re-evaluated on every `setActive`. */
82
+ now?: () => number;
83
+ /**
84
+ * Relative tolerance for the diff-against-snapshot custom check. A change
85
+ * smaller than `epsilon * max(|a|, |b|, 1)` on either endpoint is ignored.
86
+ * Defaults to `1e-6` — tight enough to detect a one-pixel pan, loose
87
+ * enough to absorb float round-trips through the axis scale.
88
+ */
89
+ epsilon?: number;
90
+ }
91
+
92
+ /** Subscriber receives the new active key, or `null` for "custom". */
93
+ export type RangePresetsSubscriber = (active: string | null) => void;
94
+
95
+ export interface RangePresetsController {
96
+ readonly presets: readonly RangePreset[];
97
+ /**
98
+ * Activate a preset by key. Re-resolves the preset, applies the resulting
99
+ * domain to the viewport, and remembers the resolved snapshot so future
100
+ * `onChange` events can detect manual changes. Passing `null` clears the
101
+ * active state without touching the viewport.
102
+ */
103
+ setActive(key: string | null): void;
104
+ /** Currently active preset key, or `null` if user has panned/zoomed manually. */
105
+ getActive(): string | null;
106
+ /** Subscribe to active-state changes. Returns an unsubscribe fn. */
107
+ subscribe(fn: RangePresetsSubscriber): () => void;
108
+ dispose(): void;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // createRangePresets
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export function createRangePresets(opts: RangePresetsOptions): RangePresetsController {
116
+ const viewport = opts.viewport;
117
+ const axis = opts.axis;
118
+ const presets = opts.presets;
119
+ const nowFn = opts.now ?? Date.now;
120
+ const epsilon = opts.epsilon ?? 1e-6;
121
+
122
+ const presetByKey = new Map(presets.map((p) => [p.key, p] as const));
123
+ const resolveDataDomain = (): RangePresetDomain => {
124
+ if (typeof opts.dataDomain === "function")
125
+ return (opts.dataDomain as () => RangePresetDomain)();
126
+ if (opts.dataDomain) return opts.dataDomain;
127
+ return readAxisDomain(viewport, axis);
128
+ };
129
+
130
+ // Snapshot the viewport's domain at construction to use as a fallback
131
+ // dataDomain, so a caller that omits `dataDomain` still gets a stable
132
+ // extent (rather than re-reading the current — possibly already panned —
133
+ // visible domain on every preset evaluation).
134
+ const snapshotDataDomain = opts.dataDomain === undefined ? readAxisDomain(viewport, axis) : null;
135
+ const getDataDomain = (): RangePresetDomain => snapshotDataDomain ?? resolveDataDomain();
136
+
137
+ let activeKey: string | null = null;
138
+ // Last domain the controller applied via setActive — used to detect when a
139
+ // subsequent viewport change came from outside (pan/zoom/setVisibleDomain
140
+ // by the consumer) and flip active back to `null`.
141
+ let lastAppliedDomain: RangePresetDomain | null = null;
142
+ let applying = 0;
143
+ const subscribers = new Set<RangePresetsSubscriber>();
144
+
145
+ const setActiveInternal = (next: string | null): void => {
146
+ if (next === activeKey) return;
147
+ activeKey = next;
148
+ for (const fn of subscribers) fn(next);
149
+ };
150
+
151
+ const unsubViewport = viewport.onChange(() => {
152
+ if (applying > 0) return;
153
+ if (activeKey === null || lastAppliedDomain === null) return;
154
+ const current = readAxisDomain(viewport, axis);
155
+ if (!domainsEqual(current, lastAppliedDomain, epsilon)) {
156
+ lastAppliedDomain = null;
157
+ setActiveInternal(null);
158
+ }
159
+ });
160
+
161
+ return {
162
+ presets,
163
+ setActive(key) {
164
+ if (key === null) {
165
+ lastAppliedDomain = null;
166
+ setActiveInternal(null);
167
+ return;
168
+ }
169
+ const preset = presetByKey.get(key);
170
+ if (!preset) {
171
+ throw new Error(`createRangePresets: unknown preset key "${key}"`);
172
+ }
173
+ const domain = preset.resolve({ dataDomain: getDataDomain(), now: nowFn() });
174
+ if (domain === null) {
175
+ // Preset declined to produce a domain — clear active without touching
176
+ // the viewport so the consumer's chip UI reflects the no-op.
177
+ lastAppliedDomain = null;
178
+ setActiveInternal(null);
179
+ return;
180
+ }
181
+ applying++;
182
+ try {
183
+ viewport.setVisibleDomain({ [axis]: domain as VisibleDomainInput });
184
+ } finally {
185
+ applying--;
186
+ }
187
+ // Re-read the viewport's domain post-apply. If clamping (panBounds,
188
+ // minZoom, maxZoom) altered the request, treat the *clamped* domain as
189
+ // the snapshot — that's what subsequent pans will be diffed against.
190
+ lastAppliedDomain = readAxisDomain(viewport, axis);
191
+ setActiveInternal(key);
192
+ },
193
+ getActive() {
194
+ return activeKey;
195
+ },
196
+ subscribe(fn) {
197
+ subscribers.add(fn);
198
+ return () => {
199
+ subscribers.delete(fn);
200
+ };
201
+ },
202
+ dispose() {
203
+ unsubViewport();
204
+ subscribers.clear();
205
+ },
206
+ };
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Built-in preset helpers
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Recognized time-preset keys. Each maps to a window relative to `now`
215
+ * (except `MAX`, which uses the full data extent, and `YTD` which goes
216
+ * from January 1 of the current year to `now`).
217
+ */
218
+ export type TimePresetKey = "24H" | "7D" | "1M" | "3M" | "6M" | "1Y" | "YTD" | "MAX";
219
+
220
+ const TIME_PRESET_MS: Record<Exclude<TimePresetKey, "YTD" | "MAX">, number> = {
221
+ "24H": 24 * 3600_000,
222
+ "7D": 7 * 24 * 3600_000,
223
+ "1M": 30 * 24 * 3600_000,
224
+ "3M": 90 * 24 * 3600_000,
225
+ "6M": 182 * 24 * 3600_000,
226
+ "1Y": 365 * 24 * 3600_000,
227
+ };
228
+
229
+ const TIME_PRESET_LABEL: Record<TimePresetKey, string> = {
230
+ "24H": "24H",
231
+ "7D": "7D",
232
+ "1M": "1M",
233
+ "3M": "3M",
234
+ "6M": "6M",
235
+ "1Y": "1Y",
236
+ YTD: "YTD",
237
+ MAX: "MAX",
238
+ };
239
+
240
+ /**
241
+ * Time-axis preset. Resolves to a `DateDomain` ending at `now` (or the data
242
+ * domain's end, for `MAX`). Built-in months use calendar-approximate
243
+ * constants (30/90/182/365 days) — close enough for chart UI.
244
+ */
245
+ export function timePreset(key: TimePresetKey, label?: string): RangePreset {
246
+ const resolveLabel = label ?? TIME_PRESET_LABEL[key];
247
+ return {
248
+ key,
249
+ label: resolveLabel,
250
+ resolve: ({ dataDomain, now }) => {
251
+ if (key === "MAX") {
252
+ return toDateDomain(dataDomain);
253
+ }
254
+ if (key === "YTD") {
255
+ const start = new Date(Date.UTC(new Date(now).getUTCFullYear(), 0, 1));
256
+ return [start, new Date(now)] as DateDomain;
257
+ }
258
+ const span = TIME_PRESET_MS[key];
259
+ return [new Date(now - span), new Date(now)] as DateDomain;
260
+ },
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Linear / numeric preset showing the last `span` units of `dataDomain` —
266
+ * i.e. `[max - span, max]`. Useful for "last 100m", "last 50 errors", etc.
267
+ * `MAX`-style behavior comes from passing `Infinity` (will clamp to the
268
+ * full data domain).
269
+ */
270
+ export function linearPreset(opts: { key: string; label: string; span: number }): RangePreset {
271
+ return {
272
+ key: opts.key,
273
+ label: opts.label,
274
+ resolve: ({ dataDomain }) => {
275
+ const [lo, hi] = toNumericDomain(dataDomain);
276
+ if (!Number.isFinite(opts.span)) return [lo, hi] as NumericDomain;
277
+ const start = Math.max(lo, hi - opts.span);
278
+ return [start, hi] as NumericDomain;
279
+ },
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Log-axis preset showing the last `decades` decades of the data — i.e.
285
+ * `[max / 10^decades, max]`, clamped to the data domain's lower bound.
286
+ * The viewport itself stays linear-or-log depending on its scale config;
287
+ * this helper just picks the domain endpoints.
288
+ */
289
+ export function logPreset(opts: { key: string; label: string; decades: number }): RangePreset {
290
+ return {
291
+ key: opts.key,
292
+ label: opts.label,
293
+ resolve: ({ dataDomain }) => {
294
+ const [lo, hi] = toNumericDomain(dataDomain);
295
+ if (hi <= 0) return [lo, hi] as NumericDomain;
296
+ const candidate = hi / Math.pow(10, opts.decades);
297
+ const start = Math.max(lo, candidate);
298
+ return [start, hi] as NumericDomain;
299
+ },
300
+ };
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Helpers
305
+ // ---------------------------------------------------------------------------
306
+
307
+ function readAxisDomain(viewport: DataViewport, axis: RangePresetAxis): RangePresetDomain {
308
+ const domain = axis === "x" ? viewport.visibleXDomain : viewport.visibleYDomain;
309
+ if (!Array.isArray(domain) || domain.length !== 2) {
310
+ throw new Error(`createRangePresets: ${axis}-axis must be a continuous (numeric or date) axis`);
311
+ }
312
+ // `Array.isArray` widens to `any[]`; the length check above guarantees this is
313
+ // a 2-tuple of numbers or Dates (band domains have length !== 2 only by luck,
314
+ // but a continuous axis is required by contract — hence the throw above).
315
+ return domain as unknown as RangePresetDomain;
316
+ }
317
+
318
+ function toNumericDomain(domain: RangePresetDomain): NumericDomain {
319
+ const [a, b] = domain;
320
+ return [domainEndAsNumber(a), domainEndAsNumber(b)] as NumericDomain;
321
+ }
322
+
323
+ function toDateDomain(domain: RangePresetDomain): DateDomain {
324
+ const [a, b] = domain;
325
+ return [domainEndAsDate(a), domainEndAsDate(b)] as DateDomain;
326
+ }
327
+
328
+ function domainEndAsNumber(value: number | Date): number {
329
+ return typeof value === "number" ? value : value.getTime();
330
+ }
331
+
332
+ function domainEndAsDate(value: number | Date): Date {
333
+ return typeof value === "number" ? new Date(value) : value;
334
+ }
335
+
336
+ function domainsEqual(a: RangePresetDomain, b: RangePresetDomain, epsilon: number): boolean {
337
+ const a0 = domainEndAsNumber(a[0]);
338
+ const a1 = domainEndAsNumber(a[1]);
339
+ const b0 = domainEndAsNumber(b[0]);
340
+ const b1 = domainEndAsNumber(b[1]);
341
+ return numberClose(a0, b0, epsilon) && numberClose(a1, b1, epsilon);
342
+ }
343
+
344
+ function numberClose(a: number, b: number, epsilon: number): boolean {
345
+ if (a === b) return true;
346
+ const diff = Math.abs(a - b);
347
+ const scale = Math.max(Math.abs(a), Math.abs(b), 1);
348
+ return diff <= epsilon * scale;
349
+ }