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,174 @@
1
+ import { describe, expect, test, vi } from "vite-plus/test";
2
+
3
+ import {
4
+ rgba,
5
+ viewportFrame,
6
+ type DragPointerInfo,
7
+ type InteractionManager,
8
+ type InteractionNode,
9
+ type InteractionNodeSpec,
10
+ type PointerInfo,
11
+ } from "insomni";
12
+ import { createDataNavigator } from "./navigator.ts";
13
+ import { createDataViewport } from "./viewport.ts";
14
+
15
+ function fakeManager(): {
16
+ manager: InteractionManager;
17
+ spec(): InteractionNodeSpec | null;
18
+ destroyed: { value: boolean };
19
+ } {
20
+ let captured: InteractionNodeSpec | null = null;
21
+ const destroyed = { value: false };
22
+ const element = {
23
+ style: { cursor: "" },
24
+ clientWidth: 200,
25
+ width: 200,
26
+ } as unknown as HTMLElement;
27
+ const manager = {
28
+ element,
29
+ add(spec: InteractionNodeSpec): InteractionNode {
30
+ captured = spec;
31
+ return {
32
+ id: Symbol("nav"),
33
+ update: vi.fn(),
34
+ destroy: () => {
35
+ destroyed.value = true;
36
+ },
37
+ };
38
+ },
39
+ addPointCloud: vi.fn(),
40
+ onBackgroundTap: vi.fn(() => () => {}),
41
+ onChange: vi.fn(() => () => {}),
42
+ destroy: vi.fn(),
43
+ } as unknown as InteractionManager;
44
+ return { manager, spec: () => captured, destroyed };
45
+ }
46
+
47
+ const RED = rgba(1, 0, 0, 1);
48
+
49
+ function makePair() {
50
+ const source = createDataViewport({
51
+ frame: viewportFrame(400, 300),
52
+ x: { type: "linear", domain: [0, 100] },
53
+ y: { type: "linear", domain: [0, 100] },
54
+ });
55
+ const overview = createDataViewport({
56
+ frame: viewportFrame(400, 300),
57
+ x: { type: "linear", domain: [0, 100] },
58
+ y: { type: "linear", domain: [0, 100] },
59
+ });
60
+ return { source, overview };
61
+ }
62
+
63
+ describe("createDataNavigator", () => {
64
+ test("render callback runs at construction and on overview change", () => {
65
+ const { source, overview } = makePair();
66
+ let calls = 0;
67
+ createDataNavigator({
68
+ source,
69
+ overview,
70
+ render: () => {
71
+ calls++;
72
+ },
73
+ });
74
+ expect(calls).toBe(1);
75
+ overview.panBy(10, 0);
76
+ expect(calls).toBe(2);
77
+ });
78
+
79
+ test("indicator screen rect tracks source's visible domain via overview.dataToScreen", () => {
80
+ const { source, overview } = makePair();
81
+ const nav = createDataNavigator({
82
+ source,
83
+ overview,
84
+ render: (target) => {
85
+ target.pushRect({ x: 0, y: 0, width: 1, height: 1, fill: RED });
86
+ },
87
+ });
88
+
89
+ const indicator = nav.layers[nav.layers.length - 1];
90
+ expect(indicator._pack.commands.length).toBe(1);
91
+
92
+ source.setVisibleDomain({ x: [25, 75] });
93
+ expect(indicator.shapeCount).toBe(1);
94
+ const left = overview.dataToScreen(25, 0).x;
95
+ const right = overview.dataToScreen(75, 0).x;
96
+ expect(right - left).toBeCloseTo(200, 5);
97
+ });
98
+
99
+ test("drag in overview pans source's visible X domain", () => {
100
+ const { source, overview } = makePair();
101
+ source.setVisibleDomain({ x: [40, 60] });
102
+ const fake = fakeManager();
103
+ createDataNavigator({
104
+ source,
105
+ overview,
106
+ render: () => {},
107
+ interaction: { manager: fake.manager },
108
+ });
109
+
110
+ const spec = fake.spec();
111
+ const press = (x: number, y: number): PointerInfo => ({
112
+ pointerId: 1,
113
+ type: "mouse",
114
+ x,
115
+ y,
116
+ localX: x,
117
+ localY: y,
118
+ buttons: 1,
119
+ mods: { shift: false, ctrl: false, meta: false, alt: false },
120
+ stopPropagation: () => {},
121
+ });
122
+ const drag = (x: number, y: number, dx: number, dy: number): DragPointerInfo => ({
123
+ ...press(x, y),
124
+ dx,
125
+ dy,
126
+ });
127
+
128
+ spec?.onDragStart?.(press(200, 150));
129
+ spec?.onDragMove?.(drag(280, 150, 80, 0));
130
+ const x = source.visibleXDomain as readonly [number, number];
131
+ // 80 px in a 400 px / 100-unit overview → +20 units.
132
+ expect(x[0]).toBeCloseTo(60, 4);
133
+ expect(x[1]).toBeCloseTo(80, 4);
134
+ });
135
+
136
+ test("axes:'x' drag on left edge sets source's visible-x lower bound", () => {
137
+ const { source, overview } = makePair();
138
+ source.setVisibleDomain({ x: [40, 60] });
139
+ const fake = fakeManager();
140
+ createDataNavigator({
141
+ source,
142
+ overview,
143
+ axes: "x",
144
+ render: () => {},
145
+ interaction: { manager: fake.manager },
146
+ });
147
+ const spec = fake.spec();
148
+ const press = (x: number, y: number): PointerInfo => ({
149
+ pointerId: 1,
150
+ type: "mouse",
151
+ x,
152
+ y,
153
+ localX: x,
154
+ localY: y,
155
+ buttons: 1,
156
+ mods: { shift: false, ctrl: false, meta: false, alt: false },
157
+ stopPropagation: () => {},
158
+ });
159
+ const drag = (x: number, y: number, dx: number, dy: number): DragPointerInfo => ({
160
+ ...press(x, y),
161
+ dx,
162
+ dy,
163
+ });
164
+
165
+ // Left edge near screen x = 160.
166
+ spec?.onPress?.(press(161, 150));
167
+ spec?.onDragStart?.(press(161, 150));
168
+ spec?.onDragMove?.(drag(80, 150, -81, 0));
169
+
170
+ const x = source.visibleXDomain as readonly [number, number];
171
+ expect(x[0]).toBeCloseTo(20, 5);
172
+ expect(x[1]).toBeCloseTo(60, 5);
173
+ });
174
+ });
@@ -0,0 +1,393 @@
1
+ import {
2
+ createLayer,
3
+ rgba,
4
+ type Color,
5
+ type FrameRect,
6
+ type InteractionManager,
7
+ type InteractionNode,
8
+ type Layer,
9
+ } from "insomni";
10
+ import type { DataViewport, VisibleDomainInput } from "./viewport.ts";
11
+
12
+ const DEFAULT_INDICATOR_FILL = rgba(1, 1, 1, 0.08);
13
+ const DEFAULT_INDICATOR_STROKE = rgba(1, 1, 1, 0.85);
14
+
15
+ /** Which axes the navigator's indicator (and interaction) tracks. */
16
+ export type DataNavigatorAxes = "xy" | "x" | "y";
17
+
18
+ export interface DataNavigatorIndicatorOptions {
19
+ fill?: Color;
20
+ stroke?: Color;
21
+ strokeWidth?: number;
22
+ cornerRadius?: number;
23
+ }
24
+
25
+ export interface DataNavigatorInteractionOptions {
26
+ manager: InteractionManager;
27
+ /** Drag inside the overview to pan/reframe the source. Default: true. */
28
+ drag?: boolean;
29
+ /** Press outside the indicator to recenter. Default: `"center"`. */
30
+ click?: "center" | false;
31
+ /**
32
+ * Drag the indicator's outer edges to reframe the source instead of panning.
33
+ * 1D-only — only meaningful for `axes: "x"` or `"y"`. Default: `true` for 1D,
34
+ * `false` for `"xy"`.
35
+ */
36
+ resizeEdges?: boolean;
37
+ /** Edge hit-zone width in CSS pixels. Default: 6. */
38
+ edgeHitPx?: number;
39
+ /** Override the interaction-node bounds. Default: `overview.absoluteFrame`. */
40
+ bounds?: () => FrameRect;
41
+ /** Interaction node z-index. Default: 50. */
42
+ zIndex?: number;
43
+ }
44
+
45
+ export interface DataNavigatorOptions<X = unknown, Y = unknown> {
46
+ /** Source data viewport the user navigates in the main view. */
47
+ source: DataViewport<X, Y>;
48
+ /** Overview data viewport that hosts the navigator. */
49
+ overview: DataViewport<X, Y>;
50
+ /** Required: callback that populates the navigator-owned content layer. */
51
+ render: (target: Layer) => void;
52
+ /** Which axes the indicator tracks. Default: `"xy"`. */
53
+ axes?: DataNavigatorAxes;
54
+ indicator?: DataNavigatorIndicatorOptions;
55
+ interaction?: DataNavigatorInteractionOptions;
56
+ /** Bake the render-callback target to an offscreen texture. */
57
+ cache?: {
58
+ renderer: { cacheLayer(layer: Layer): void };
59
+ };
60
+ }
61
+
62
+ export interface DataNavigator {
63
+ readonly layers: readonly Layer[];
64
+ readonly interacting: boolean;
65
+ refresh(): void;
66
+ dispose(): void;
67
+ }
68
+
69
+ /**
70
+ * Build a Navigator for a data viewport: a secondary view of the same data
71
+ * with a rect that tracks the source's visible domain. Drag inside the
72
+ * overview pans/reframes the source.
73
+ */
74
+ export function createDataNavigator<X = unknown, Y = unknown>(
75
+ opts: DataNavigatorOptions<X, Y>,
76
+ ): DataNavigator {
77
+ const source = opts.source as DataViewport;
78
+ const overview = opts.overview as DataViewport;
79
+ const axes: DataNavigatorAxes = opts.axes ?? "xy";
80
+ const renderFn = opts.render;
81
+ const cache = opts.cache;
82
+ const interactionState = { active: false };
83
+
84
+ // v3 `LayerOptions` no longer accepts `viewport`. A `DataViewport` uses an
85
+ // identity camera (data-mode pan/zoom flows through the axis scales, not the
86
+ // camera), so binding the overview camera here was a no-op transform — the
87
+ // navigator content is already emitted in overview pixel space by `renderFn`.
88
+ //
89
+ // v3 cache: the old v1 `{ cache: "texture" }` option became the `cache` HINT
90
+ // ("auto"|"always"|"never"). The navigator's overview content is static
91
+ // between source-domain changes, so an opted-in cache maps to "always" (force
92
+ // a retained RTT bake). The old v1 `cacheSize` had no v3 equivalent — the core
93
+ // bakes at canvas resolution — so it was dropped.
94
+ const target = createLayer({
95
+ space: "ui",
96
+ ...(cache ? { cache: "always" as const } : {}),
97
+ });
98
+ const indicator = createLayer({ space: "ui" });
99
+ const style = resolveIndicatorStyle(opts.indicator);
100
+
101
+ const refreshContent = () => {
102
+ target.clear();
103
+ renderFn(target);
104
+ if (cache) cache.renderer.cacheLayer(target);
105
+ };
106
+
107
+ const refreshIndicator = () => {
108
+ indicator.clear();
109
+ const rect = computeDataIndicatorRect(source, overview, axes);
110
+ if (!rect) return;
111
+ indicator.pushRect({
112
+ x: rect.x,
113
+ y: rect.y,
114
+ width: rect.width,
115
+ height: rect.height,
116
+ fill: style.fill,
117
+ stroke: style.stroke,
118
+ strokeWidth: style.strokeWidth,
119
+ cornerRadius: style.cornerRadius,
120
+ });
121
+ };
122
+
123
+ const refresh = () => {
124
+ refreshContent();
125
+ refreshIndicator();
126
+ };
127
+
128
+ refresh();
129
+
130
+ // Indicator depends on both source's visible domain and overview's domain
131
+ // (dataToScreen). Content depends on overview's domain.
132
+ const unsubSource = source.onChange(refreshIndicator);
133
+ const unsubOverview = overview.onChange(refresh);
134
+
135
+ const node = opts.interaction
136
+ ? bindDataInteraction(opts.interaction, source, overview, axes, interactionState)
137
+ : null;
138
+
139
+ let disposed = false;
140
+
141
+ return {
142
+ layers: [target, indicator],
143
+ get interacting() {
144
+ return interactionState.active;
145
+ },
146
+ refresh,
147
+ dispose() {
148
+ if (disposed) return;
149
+ disposed = true;
150
+ unsubSource();
151
+ unsubOverview();
152
+ node?.destroy();
153
+ target.destroy();
154
+ indicator.destroy();
155
+ },
156
+ };
157
+ }
158
+
159
+ function bindDataInteraction(
160
+ opts: DataNavigatorInteractionOptions,
161
+ source: DataViewport,
162
+ overview: DataViewport,
163
+ axes: DataNavigatorAxes,
164
+ interactionState: { active: boolean },
165
+ ): InteractionNode {
166
+ const dragEnabled = opts.drag ?? true;
167
+ const clickMode = opts.click ?? "center";
168
+ const useX = axes === "xy" || axes === "x";
169
+ const useY = axes === "xy" || axes === "y";
170
+ const resizeEnabled = opts.resizeEdges ?? axes !== "xy";
171
+ const edgeHitPx = opts.edgeHitPx ?? 6;
172
+
173
+ type DragMode = "pan" | "resize-x0" | "resize-x1" | "resize-y0" | "resize-y1";
174
+ let mode: DragMode = "pan";
175
+ let startDataX = 0;
176
+ let startDataY = 0;
177
+ let startSrcX0 = 0;
178
+ let startSrcX1 = 0;
179
+ let startSrcY0 = 0;
180
+ let startSrcY1 = 0;
181
+ let dragHasX = false;
182
+ let dragHasY = false;
183
+ let dragging = false;
184
+
185
+ const detectEdge = (evt: { x: number; y: number }): DragMode | null => {
186
+ if (!resizeEnabled) return null;
187
+ const r = computeDataIndicatorRect(source, overview, axes);
188
+ if (!r) return null;
189
+ if (axes === "x") {
190
+ if (Math.abs(evt.x - r.x) <= edgeHitPx) return "resize-x0";
191
+ if (Math.abs(evt.x - (r.x + r.width)) <= edgeHitPx) return "resize-x1";
192
+ return null;
193
+ }
194
+ if (axes === "y") {
195
+ if (Math.abs(evt.y - r.y) <= edgeHitPx) return "resize-y0";
196
+ if (Math.abs(evt.y - (r.y + r.height)) <= edgeHitPx) return "resize-y1";
197
+ return null;
198
+ }
199
+ return null;
200
+ };
201
+
202
+ return opts.manager.add({
203
+ zIndex: opts.zIndex ?? 50,
204
+ space: "ui",
205
+ bounds: opts.bounds ?? (() => overview.absoluteFrame),
206
+ cursor: dragEnabled ? "grab" : "pointer",
207
+ dragCursor: "grabbing",
208
+
209
+ onPress: (evt) => {
210
+ const edge = detectEdge(evt);
211
+ if (edge) {
212
+ mode = edge;
213
+ return;
214
+ }
215
+ const indRect = computeDataIndicatorRect(source, overview, axes);
216
+ if (indRect) {
217
+ const inside =
218
+ evt.x >= indRect.x &&
219
+ evt.x <= indRect.x + indRect.width &&
220
+ evt.y >= indRect.y &&
221
+ evt.y <= indRect.y + indRect.height;
222
+ if (inside) {
223
+ mode = "pan";
224
+ return;
225
+ }
226
+ }
227
+ mode = "pan";
228
+ if (clickMode !== "center") return;
229
+ const data = overview.screenToData(evt.x, evt.y);
230
+ if (!data) return;
231
+ const update: { x?: VisibleDomainInput; y?: VisibleDomainInput } = {};
232
+ if (useX) {
233
+ const span = visibleNumericSpan(source, "x");
234
+ if (span !== null) {
235
+ const c = toNumber(data.x);
236
+ update.x = [c - span / 2, c + span / 2];
237
+ }
238
+ }
239
+ if (useY) {
240
+ const span = visibleNumericSpan(source, "y");
241
+ if (span !== null) {
242
+ const c = toNumber(data.y);
243
+ update.y = [c - span / 2, c + span / 2];
244
+ }
245
+ }
246
+ source.setVisibleDomain(update);
247
+ },
248
+
249
+ onDragStart: dragEnabled
250
+ ? (evt) => {
251
+ const data = overview.screenToData(evt.x, evt.y);
252
+ if (!data) return;
253
+ startDataX = toNumber(data.x);
254
+ startDataY = toNumber(data.y);
255
+ dragHasX = false;
256
+ dragHasY = false;
257
+ if (useX) {
258
+ const dom = source.visibleXDomain;
259
+ if (Array.isArray(dom) && dom.length === 2) {
260
+ startSrcX0 = toNumber(dom[0]);
261
+ startSrcX1 = toNumber(dom[1]);
262
+ dragHasX = Number.isFinite(startSrcX0) && Number.isFinite(startSrcX1);
263
+ }
264
+ }
265
+ if (useY) {
266
+ const dom = source.visibleYDomain;
267
+ if (Array.isArray(dom) && dom.length === 2) {
268
+ startSrcY0 = toNumber(dom[0]);
269
+ startSrcY1 = toNumber(dom[1]);
270
+ dragHasY = Number.isFinite(startSrcY0) && Number.isFinite(startSrcY1);
271
+ }
272
+ }
273
+ dragging = dragHasX || dragHasY;
274
+ interactionState.active = dragging;
275
+ }
276
+ : undefined,
277
+ onDragMove: dragEnabled
278
+ ? (evt) => {
279
+ if (!dragging) return;
280
+ const data = overview.screenToData(evt.x, evt.y);
281
+ if (!data) return;
282
+ if (mode === "resize-x0" && dragHasX) {
283
+ source.setVisibleDomain({ x: [toNumber(data.x), startSrcX1] });
284
+ return;
285
+ }
286
+ if (mode === "resize-x1" && dragHasX) {
287
+ source.setVisibleDomain({ x: [startSrcX0, toNumber(data.x)] });
288
+ return;
289
+ }
290
+ if (mode === "resize-y0" && dragHasY) {
291
+ source.setVisibleDomain({ y: [toNumber(data.y), startSrcY1] });
292
+ return;
293
+ }
294
+ if (mode === "resize-y1" && dragHasY) {
295
+ source.setVisibleDomain({ y: [startSrcY0, toNumber(data.y)] });
296
+ return;
297
+ }
298
+ const update: { x?: VisibleDomainInput; y?: VisibleDomainInput } = {};
299
+ if (dragHasX) {
300
+ const dx = toNumber(data.x) - startDataX;
301
+ update.x = [startSrcX0 + dx, startSrcX1 + dx];
302
+ }
303
+ if (dragHasY) {
304
+ const dy = toNumber(data.y) - startDataY;
305
+ update.y = [startSrcY0 + dy, startSrcY1 + dy];
306
+ }
307
+ source.setVisibleDomain(update);
308
+ }
309
+ : undefined,
310
+ onDragEnd: dragEnabled
311
+ ? () => {
312
+ dragging = false;
313
+ interactionState.active = false;
314
+ mode = "pan";
315
+ }
316
+ : undefined,
317
+ });
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Helpers
322
+ // ---------------------------------------------------------------------------
323
+
324
+ interface ResolvedStyle {
325
+ fill: Color;
326
+ stroke: Color;
327
+ strokeWidth: number;
328
+ cornerRadius: number;
329
+ }
330
+
331
+ function resolveIndicatorStyle(opts: DataNavigatorIndicatorOptions | undefined): ResolvedStyle {
332
+ return {
333
+ fill: opts?.fill ?? DEFAULT_INDICATOR_FILL,
334
+ stroke: opts?.stroke ?? DEFAULT_INDICATOR_STROKE,
335
+ strokeWidth: opts?.strokeWidth ?? 1,
336
+ cornerRadius: opts?.cornerRadius ?? 0,
337
+ };
338
+ }
339
+
340
+ function toNumber(v: unknown): number {
341
+ if (typeof v === "number") return v;
342
+ if (v instanceof Date) return v.getTime();
343
+ return Number(v);
344
+ }
345
+
346
+ function visibleNumericSpan(vp: DataViewport, axis: "x" | "y"): number | null {
347
+ const dom = axis === "x" ? vp.visibleXDomain : vp.visibleYDomain;
348
+ if (!Array.isArray(dom) || dom.length !== 2) return null;
349
+ const a = toNumber(dom[0]);
350
+ const b = toNumber(dom[1]);
351
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
352
+ return b - a;
353
+ }
354
+
355
+ function computeDataIndicatorRect(
356
+ source: DataViewport,
357
+ overview: DataViewport,
358
+ axes: DataNavigatorAxes,
359
+ ): FrameRect | null {
360
+ const useX = axes === "xy" || axes === "x";
361
+ const useY = axes === "xy" || axes === "y";
362
+ const f = overview.absoluteFrame;
363
+
364
+ let x = f.x;
365
+ let width = f.width;
366
+ let y = f.y;
367
+ let height = f.height;
368
+
369
+ const srcX = source.visibleXDomain;
370
+ const srcY = source.visibleYDomain;
371
+
372
+ // Pick a Y anchor for X-axis pixel mapping (and vice versa). Any value works
373
+ // — dataToScreen treats axes independently for continuous/time inputs.
374
+ const yAnchor = Array.isArray(srcY) && srcY.length > 0 ? srcY[0] : null;
375
+ const xAnchor = Array.isArray(srcX) && srcX.length > 0 ? srcX[0] : null;
376
+
377
+ if (useX) {
378
+ if (!Array.isArray(srcX) || srcX.length !== 2 || yAnchor === null) return null;
379
+ const left = overview.dataToScreen(srcX[0] as never, yAnchor as never);
380
+ const right = overview.dataToScreen(srcX[1] as never, yAnchor as never);
381
+ x = Math.min(left.x, right.x);
382
+ width = Math.abs(right.x - left.x);
383
+ }
384
+ if (useY) {
385
+ if (!Array.isArray(srcY) || srcY.length !== 2 || xAnchor === null) return null;
386
+ const top = overview.dataToScreen(xAnchor as never, srcY[0] as never);
387
+ const bottom = overview.dataToScreen(xAnchor as never, srcY[1] as never);
388
+ y = Math.min(top.y, bottom.y);
389
+ height = Math.abs(bottom.y - top.y);
390
+ }
391
+
392
+ return { x, y, width, height };
393
+ }
@@ -0,0 +1,113 @@
1
+ import type { DateDomain, NumericDomain } from "./scales.ts";
2
+ import type { DataViewport } from "./viewport.ts";
3
+ /** Axis the controller drives. */
4
+ export type RangePresetAxis = "x" | "y";
5
+ /**
6
+ * Resolved domain a preset applies to the viewport. Numeric pair for linear /
7
+ * log / score axes; Date pair for time axes.
8
+ */
9
+ export type RangePresetDomain = NumericDomain | DateDomain;
10
+ /** Optional getter form for the data-extent fallback. */
11
+ export type RangePresetDataDomain = RangePresetDomain | (() => RangePresetDomain);
12
+ /**
13
+ * Context passed to a preset's `resolve` function. `dataDomain` is the full
14
+ * extent of the underlying data (either provided by the consumer at
15
+ * construction time or, if omitted, the viewport's current visible domain
16
+ * sampled at construction). `now` is the current time (epoch ms) — relevant
17
+ * for time presets; helpers like `linearPreset` ignore it.
18
+ */
19
+ export interface RangePresetContext {
20
+ dataDomain: RangePresetDomain;
21
+ now: number;
22
+ }
23
+ /**
24
+ * A single preset entry. `resolve` returns the domain the viewport should be
25
+ * set to when the user activates this key. Return `null` to express "this
26
+ * preset has no valid domain given the current data" — the controller will
27
+ * leave the viewport untouched and not mark itself active.
28
+ */
29
+ export interface RangePreset {
30
+ key: string;
31
+ label: string;
32
+ resolve: (ctx: RangePresetContext) => RangePresetDomain | null;
33
+ }
34
+ export interface RangePresetsOptions {
35
+ /**
36
+ * Any `DataViewport` regardless of axis-value type — accepts the
37
+ * `DataViewport<number, number>` returned by `MountedPlot.viewport` as
38
+ * well as the unparameterized form. The controller only reads visible
39
+ * domains and applies new ones through the viewport's continuous-axis
40
+ * API, so X/Y value types are not constrained at this layer.
41
+ */
42
+ viewport: DataViewport<any, any>;
43
+ axis: RangePresetAxis;
44
+ presets: readonly RangePreset[];
45
+ /**
46
+ * Full data extent. Either a fixed `[min, max]` (numbers or Dates) or a
47
+ * getter that re-reads on each preset evaluation. If omitted, the
48
+ * controller snapshots the viewport's current visible domain at
49
+ * construction time and reuses it as the data domain.
50
+ */
51
+ dataDomain?: RangePresetDataDomain;
52
+ /** Defaults to `Date.now`. Re-evaluated on every `setActive`. */
53
+ now?: () => number;
54
+ /**
55
+ * Relative tolerance for the diff-against-snapshot custom check. A change
56
+ * smaller than `epsilon * max(|a|, |b|, 1)` on either endpoint is ignored.
57
+ * Defaults to `1e-6` — tight enough to detect a one-pixel pan, loose
58
+ * enough to absorb float round-trips through the axis scale.
59
+ */
60
+ epsilon?: number;
61
+ }
62
+ /** Subscriber receives the new active key, or `null` for "custom". */
63
+ export type RangePresetsSubscriber = (active: string | null) => void;
64
+ export interface RangePresetsController {
65
+ readonly presets: readonly RangePreset[];
66
+ /**
67
+ * Activate a preset by key. Re-resolves the preset, applies the resulting
68
+ * domain to the viewport, and remembers the resolved snapshot so future
69
+ * `onChange` events can detect manual changes. Passing `null` clears the
70
+ * active state without touching the viewport.
71
+ */
72
+ setActive(key: string | null): void;
73
+ /** Currently active preset key, or `null` if user has panned/zoomed manually. */
74
+ getActive(): string | null;
75
+ /** Subscribe to active-state changes. Returns an unsubscribe fn. */
76
+ subscribe(fn: RangePresetsSubscriber): () => void;
77
+ dispose(): void;
78
+ }
79
+ export declare function createRangePresets(opts: RangePresetsOptions): RangePresetsController;
80
+ /**
81
+ * Recognized time-preset keys. Each maps to a window relative to `now`
82
+ * (except `MAX`, which uses the full data extent, and `YTD` which goes
83
+ * from January 1 of the current year to `now`).
84
+ */
85
+ export type TimePresetKey = "24H" | "7D" | "1M" | "3M" | "6M" | "1Y" | "YTD" | "MAX";
86
+ /**
87
+ * Time-axis preset. Resolves to a `DateDomain` ending at `now` (or the data
88
+ * domain's end, for `MAX`). Built-in months use calendar-approximate
89
+ * constants (30/90/182/365 days) — close enough for chart UI.
90
+ */
91
+ export declare function timePreset(key: TimePresetKey, label?: string): RangePreset;
92
+ /**
93
+ * Linear / numeric preset showing the last `span` units of `dataDomain` —
94
+ * i.e. `[max - span, max]`. Useful for "last 100m", "last 50 errors", etc.
95
+ * `MAX`-style behavior comes from passing `Infinity` (will clamp to the
96
+ * full data domain).
97
+ */
98
+ export declare function linearPreset(opts: {
99
+ key: string;
100
+ label: string;
101
+ span: number;
102
+ }): RangePreset;
103
+ /**
104
+ * Log-axis preset showing the last `decades` decades of the data — i.e.
105
+ * `[max / 10^decades, max]`, clamped to the data domain's lower bound.
106
+ * The viewport itself stays linear-or-log depending on its scale config;
107
+ * this helper just picks the domain endpoints.
108
+ */
109
+ export declare function logPreset(opts: {
110
+ key: string;
111
+ label: string;
112
+ decades: number;
113
+ }): RangePreset;