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,510 @@
1
+ import {
2
+ createFrame,
3
+ type Frame,
4
+ type FrameRect,
5
+ type ResolvedCameraState,
6
+ type Vec2,
7
+ } from "insomni";
8
+ import type { CameraViewport, ViewportLike } from "insomni/viewport";
9
+ import type { AxisScale, DateDomain, NumericDomain } from "./scales.ts";
10
+ import {
11
+ applyAxis,
12
+ asLinearAxisFn,
13
+ buildScale,
14
+ clampAxisToBase,
15
+ copyContinuous,
16
+ createAxisState,
17
+ invertAxis,
18
+ panAxis,
19
+ readVisibleDomain,
20
+ resetAxis,
21
+ setAxisRange,
22
+ setAxisVisibleDomain,
23
+ zoomAxis,
24
+ type AxisState,
25
+ type ViewportAxisConfig,
26
+ type VisibleDomainInput,
27
+ } from "./viewport/axis-state.ts";
28
+
29
+ export type {
30
+ BandAxisConfig,
31
+ ContinuousAxisConfig,
32
+ TimeAxisConfig,
33
+ ViewportAxisConfig,
34
+ VisibleDomainInput,
35
+ } from "./viewport/axis-state.ts";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Pan-bound margin types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Per-axis margin spec. A bare number applies to both axes. Values are
43
+ * fractions of the *visible* span and apply symmetrically to both edges.
44
+ */
45
+ export type PanBoundsMargin = number | { x?: number; y?: number };
46
+
47
+ export interface PanBoundsMargins {
48
+ /** Pan-past-content allowance when content is larger than the viewport. */
49
+ overshoot?: PanBoundsMargin;
50
+ /** Content drift allowance when content fits inside the viewport. */
51
+ drift?: PanBoundsMargin;
52
+ }
53
+
54
+ export interface DataPanBoundsOptions extends PanBoundsMargins {}
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Viewport options
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export interface DataViewportOptions<X = unknown, Y = unknown> {
61
+ /** Frame in pixel space (relative to parent if `parent` is supplied). */
62
+ frame: Frame;
63
+ /** Optional parent viewport — `frame` is interpreted in the parent's space. */
64
+ parent?: ViewportLike;
65
+ x: ViewportAxisConfig<X>;
66
+ y: ViewportAxisConfig<Y>;
67
+ /** Minimum domain-span multiplier (relative to base). Default: 0.001 */
68
+ minZoom?: number;
69
+ /** Maximum domain-span multiplier (relative to base). Default: 1000 */
70
+ maxZoom?: number;
71
+ /**
72
+ * Pan-bound the visible domain to the axis base domain (the initial domain
73
+ * passed in `x` / `y`). When zoomed in, panning stops at content edges
74
+ * (plus margin); when zoomed out far enough that content fits in view,
75
+ * the viewport cannot pan content off-screen. Zoom itself remains
76
+ * unrestricted past content — this only governs pan position.
77
+ */
78
+ panBounds?: DataPanBoundsOptions;
79
+ }
80
+
81
+ export type ZoomFactor = number | { x?: number; y?: number };
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Linear projector
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export interface LinearProjector {
88
+ /** sx = worldX * txSlope + txConst */
89
+ readonly txSlope: number;
90
+ readonly txConst: number;
91
+ /** sy = worldY * tySlope + tyConst */
92
+ readonly tySlope: number;
93
+ readonly tyConst: number;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // DataViewport public type
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export interface DataViewport<X = unknown, Y = unknown> {
101
+ readonly mode: "data";
102
+ /** Frame relative to `parent` (or absolute if no parent). */
103
+ readonly frame: Frame;
104
+ /** Frame in root / canvas space — walks the parent chain on each read. */
105
+ readonly absoluteFrame: Frame;
106
+ /** Parent viewport, if this is a nested viewport. */
107
+ readonly parent: ViewportLike | null;
108
+ /** Mutable rect equal to `absoluteFrame`, refreshed in place on each access. */
109
+ readonly clipRect: FrameRect;
110
+ /** Identity camera (data-mode pan/zoom flows through axis scales). */
111
+ readonly camera: ResolvedCameraState;
112
+
113
+ readonly x: AxisScale<X>;
114
+ readonly y: AxisScale<Y>;
115
+ readonly visibleXDomain: NumericDomain | DateDomain | readonly X[];
116
+ readonly visibleYDomain: NumericDomain | DateDomain | readonly Y[];
117
+
118
+ /** Pan by pixel deltas in this viewport's frame space. */
119
+ panBy(dxPx: number, dyPx: number): void;
120
+ /** Zoom around a screen-pixel anchor in the viewport's absolute frame space. */
121
+ zoomAt(anchorSx: number, anchorSy: number, factor: ZoomFactor): void;
122
+ /** Reset to initial domain. */
123
+ reset(): void;
124
+ /**
125
+ * Replace the frame (in the same space as the original frame).
126
+ *
127
+ * `reserve` (CSS-px insets) shrinks the axis pixel range without changing
128
+ * the visible frame extent or `clipRect`. This is how the chart pipeline
129
+ * tells the viewport about position-scale range reservations
130
+ * (`framePadding` + per-geom `prepareRange`) so `dataToScreen` matches
131
+ * where marks actually render. Defaults to zero insets — callers that
132
+ * don't reserve any space (test viewports, manual mounts) can omit it.
133
+ */
134
+ setFrame(frame: Frame, reserve?: AxisRangeReserve): void;
135
+
136
+ /** Convert a screen pixel to data coords. Returns null if outside the absolute frame. */
137
+ screenToData(sx: number, sy: number): { x: X; y: Y } | null;
138
+ /** Convert data coords to screen pixels (absolute). */
139
+ dataToScreen(dx: X, dy: Y): Vec2;
140
+ /**
141
+ * If both axes are linear continuous (not log, not time, not band), return a
142
+ * pre-computed slope+intercept projector for use in hot loops. Returns null
143
+ * for mixed or non-linear axis configurations. Recompute once per project
144
+ * call; do not call per-entry.
145
+ */
146
+ getLinearProjector(): LinearProjector | null;
147
+ /** Set the visible domain absolutely on one or both continuous axes. */
148
+ setVisibleDomain(domains: { x?: VisibleDomainInput; y?: VisibleDomainInput }): void;
149
+ /** Replace the pan-bound config (or clear it with `null`). */
150
+ setPanBounds(options: DataPanBoundsOptions | null): void;
151
+
152
+ /** Subscribe to any view-state change. */
153
+ onChange(cb: () => void): () => void;
154
+
155
+ /** @internal */
156
+ _state: DataInternal;
157
+ }
158
+
159
+ /** Anything that can be linked together — `linkViewports` ignores camera viewports. */
160
+ export type Viewport<X = unknown, Y = unknown> = DataViewport<X, Y> | CameraViewport;
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Internal representation
164
+ // ---------------------------------------------------------------------------
165
+
166
+ interface MutableFrameRect {
167
+ x: number;
168
+ y: number;
169
+ width: number;
170
+ height: number;
171
+ }
172
+
173
+ interface ResolvedAxisMargin {
174
+ x: number;
175
+ y: number;
176
+ }
177
+
178
+ interface ResolvedMargins {
179
+ overshoot: ResolvedAxisMargin;
180
+ drift: ResolvedAxisMargin;
181
+ }
182
+
183
+ export interface DataInternal {
184
+ mode: "data";
185
+ frame: Frame;
186
+ parent: ViewportLike | null;
187
+ listeners: Set<() => void>;
188
+ notifying: boolean;
189
+ clipRect: MutableFrameRect;
190
+ xAxis: AxisState<unknown>;
191
+ yAxis: AxisState<unknown>;
192
+ minZoom: number;
193
+ maxZoom: number;
194
+ panBoundsMargins: ResolvedMargins | null;
195
+ rangeReserve: AxisRangeReserve;
196
+ }
197
+
198
+ /**
199
+ * CSS-px insets that shrink the axis pixel range without changing the frame
200
+ * extent. Mirrors the chart pipeline's `framePadding` + per-geom
201
+ * `prepareRange` reservations so `dataToScreen` lines up with where marks
202
+ * actually render.
203
+ */
204
+ export interface AxisRangeReserve {
205
+ readonly left: number;
206
+ readonly right: number;
207
+ readonly top: number;
208
+ readonly bottom: number;
209
+ }
210
+
211
+ const ZERO_RESERVE: AxisRangeReserve = Object.freeze({ left: 0, right: 0, top: 0, bottom: 0 });
212
+
213
+ function sameReserve(a: AxisRangeReserve, b: AxisRangeReserve): boolean {
214
+ return a.left === b.left && a.right === b.right && a.top === b.top && a.bottom === b.bottom;
215
+ }
216
+
217
+ const IDENTITY_CAMERA: ResolvedCameraState = Object.freeze({ x: 0, y: 0, zoom: 1, rotation: 0 });
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Pan-bound margin resolution
221
+ // ---------------------------------------------------------------------------
222
+
223
+ function resolveAxisMargin(spec: PanBoundsMargin | undefined): ResolvedAxisMargin {
224
+ if (spec === undefined) return { x: 0, y: 0 };
225
+ if (typeof spec === "number") return { x: spec, y: spec };
226
+ return { x: spec.x ?? 0, y: spec.y ?? 0 };
227
+ }
228
+
229
+ function resolveMargins(opts: PanBoundsMargins): ResolvedMargins {
230
+ return {
231
+ overshoot: resolveAxisMargin(opts.overshoot),
232
+ drift: resolveAxisMargin(opts.drift),
233
+ };
234
+ }
235
+
236
+ function clampDataState(state: DataInternal): boolean {
237
+ const m = state.panBoundsMargins;
238
+ if (!m) return false;
239
+ let changed = false;
240
+ if (clampAxisToBase(state.xAxis, m.overshoot.x, m.drift.x)) changed = true;
241
+ if (clampAxisToBase(state.yAxis, m.overshoot.y, m.drift.y)) changed = true;
242
+ return changed;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Factory
247
+ // ---------------------------------------------------------------------------
248
+
249
+ export function createDataViewport<X = unknown, Y = unknown>(
250
+ options: DataViewportOptions<X, Y>,
251
+ ): DataViewport<X, Y> {
252
+ const frame = options.frame;
253
+ const parent = options.parent ?? null;
254
+
255
+ const state: DataInternal = {
256
+ mode: "data",
257
+ frame,
258
+ parent,
259
+ xAxis: createAxisState(options.x, 0, frame.width) as AxisState<unknown>,
260
+ yAxis: createAxisState(options.y, frame.height, 0) as AxisState<unknown>,
261
+ minZoom: options.minZoom ?? 0.001,
262
+ maxZoom: options.maxZoom ?? 1000,
263
+ listeners: new Set(),
264
+ notifying: false,
265
+ clipRect: { x: frame.x, y: frame.y, width: frame.width, height: frame.height },
266
+ panBoundsMargins: options.panBounds ? resolveMargins(options.panBounds) : null,
267
+ rangeReserve: ZERO_RESERVE,
268
+ };
269
+
270
+ const notify = () => notifyListeners(state);
271
+
272
+ const viewport: DataViewport<X, Y> = {
273
+ mode: "data",
274
+ get frame() {
275
+ return state.frame;
276
+ },
277
+ get absoluteFrame() {
278
+ return absoluteFrame(state);
279
+ },
280
+ get parent() {
281
+ return state.parent;
282
+ },
283
+ get clipRect() {
284
+ return refreshClipRect(state);
285
+ },
286
+ get camera() {
287
+ return IDENTITY_CAMERA;
288
+ },
289
+ get x() {
290
+ return buildScale(state.xAxis) as AxisScale<X>;
291
+ },
292
+ get y() {
293
+ return buildScale(state.yAxis) as AxisScale<Y>;
294
+ },
295
+ get visibleXDomain() {
296
+ return readVisibleDomain(state.xAxis) as NumericDomain | DateDomain | readonly X[];
297
+ },
298
+ get visibleYDomain() {
299
+ return readVisibleDomain(state.yAxis) as NumericDomain | DateDomain | readonly Y[];
300
+ },
301
+
302
+ panBy(dxPx, dyPx) {
303
+ let changed = false;
304
+ if (dxPx !== 0) changed = panAxis(state.xAxis, dxPx) || changed;
305
+ if (dyPx !== 0) changed = panAxis(state.yAxis, dyPx) || changed;
306
+ if (clampDataState(state)) changed = true;
307
+ if (changed) notify();
308
+ },
309
+
310
+ zoomAt(anchorSx, anchorSy, factor) {
311
+ const fx = typeof factor === "number" ? factor : (factor.x ?? 1);
312
+ const fy = typeof factor === "number" ? factor : (factor.y ?? 1);
313
+ const abs = absoluteFrame(state);
314
+ const localX = anchorSx - abs.x;
315
+ const localY = anchorSy - abs.y;
316
+ let changed = false;
317
+ if (fx !== 1) {
318
+ changed = zoomAxis(state.xAxis, localX, fx, state.minZoom, state.maxZoom) || changed;
319
+ }
320
+ if (fy !== 1) {
321
+ changed = zoomAxis(state.yAxis, localY, fy, state.minZoom, state.maxZoom) || changed;
322
+ }
323
+ if (clampDataState(state)) changed = true;
324
+ if (changed) notify();
325
+ },
326
+
327
+ reset() {
328
+ let changed = false;
329
+ if (resetAxis(state.xAxis)) changed = true;
330
+ if (resetAxis(state.yAxis)) changed = true;
331
+ if (changed) notify();
332
+ },
333
+
334
+ setFrame(nextFrame, reserve) {
335
+ const r = reserve ?? ZERO_RESERVE;
336
+ const frameUnchanged = sameFrame(state.frame, nextFrame);
337
+ const reserveUnchanged = sameReserve(state.rangeReserve, r);
338
+ if (frameUnchanged && reserveUnchanged) {
339
+ state.frame = nextFrame;
340
+ return;
341
+ }
342
+ state.frame = nextFrame;
343
+ state.rangeReserve = r;
344
+ // X axis: pixel range [reserve.left, width - reserve.right] (low→high).
345
+ // Y axis: pixel range [height - reserve.bottom, reserve.top] (high→low,
346
+ // since data ascends upward but pixel y descends).
347
+ setAxisRange(state.xAxis, r.left, nextFrame.width - r.right);
348
+ setAxisRange(state.yAxis, nextFrame.height - r.bottom, r.top);
349
+ refreshClipRect(state);
350
+ notify();
351
+ },
352
+
353
+ screenToData(sx, sy) {
354
+ const f = absoluteFrame(state);
355
+ if (sx < f.x || sx > f.x + f.width || sy < f.y || sy > f.y + f.height) return null;
356
+ const lx = sx - f.x;
357
+ const ly = sy - f.y;
358
+ const xd = invertAxis(state.xAxis, lx);
359
+ const yd = invertAxis(state.yAxis, ly);
360
+ if (xd === undefined || yd === undefined) return null;
361
+ return { x: xd as X, y: yd as Y };
362
+ },
363
+
364
+ dataToScreen(dx, dy) {
365
+ const f = absoluteFrame(state);
366
+ const px = applyAxis(state.xAxis, dx);
367
+ const py = applyAxis(state.yAxis, dy);
368
+ return { x: px + f.x, y: py + f.y };
369
+ },
370
+
371
+ getLinearProjector() {
372
+ const fx = asLinearAxisFn(state.xAxis);
373
+ const fy = asLinearAxisFn(state.yAxis);
374
+ if (!fx || !fy) return null;
375
+ const f = absoluteFrame(state);
376
+ const txSlope = (fx.r1 - fx.r0) / (fx.t1 - fx.t0);
377
+ const txConst = fx.r0 - fx.t0 * txSlope + f.x;
378
+ const tySlope = (fy.r1 - fy.r0) / (fy.t1 - fy.t0);
379
+ const tyConst = fy.r0 - fy.t0 * tySlope + f.y;
380
+ return { txSlope, txConst, tySlope, tyConst };
381
+ },
382
+
383
+ setVisibleDomain(domains) {
384
+ let changed = false;
385
+ if (domains.x !== undefined) {
386
+ changed =
387
+ setAxisVisibleDomain(state.xAxis, domains.x, state.minZoom, state.maxZoom) || changed;
388
+ }
389
+ if (domains.y !== undefined) {
390
+ changed =
391
+ setAxisVisibleDomain(state.yAxis, domains.y, state.minZoom, state.maxZoom) || changed;
392
+ }
393
+ if (clampDataState(state)) changed = true;
394
+ if (changed) notify();
395
+ },
396
+
397
+ setPanBounds(panBounds) {
398
+ state.panBoundsMargins = panBounds ? resolveMargins(panBounds) : null;
399
+ if (clampDataState(state)) notify();
400
+ },
401
+
402
+ onChange(cb) {
403
+ state.listeners.add(cb);
404
+ return () => {
405
+ state.listeners.delete(cb);
406
+ };
407
+ },
408
+
409
+ _state: state,
410
+ };
411
+
412
+ return viewport;
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Parent-chain helpers
417
+ // ---------------------------------------------------------------------------
418
+
419
+ function absoluteFrame(state: DataInternal): Frame {
420
+ if (!state.parent) return state.frame;
421
+ const parentAbs = state.parent.absoluteFrame;
422
+ if (parentAbs.x === 0 && parentAbs.y === 0) return state.frame;
423
+ return createFrame({
424
+ x: state.frame.x + parentAbs.x,
425
+ y: state.frame.y + parentAbs.y,
426
+ width: state.frame.width,
427
+ height: state.frame.height,
428
+ });
429
+ }
430
+
431
+ function refreshClipRect(state: DataInternal): FrameRect {
432
+ const abs = absoluteFrame(state);
433
+ const rect = state.clipRect;
434
+ rect.x = abs.x;
435
+ rect.y = abs.y;
436
+ rect.width = abs.width;
437
+ rect.height = abs.height;
438
+ return rect;
439
+ }
440
+
441
+ function notifyListeners(state: DataInternal): void {
442
+ if (state.notifying) return;
443
+ state.notifying = true;
444
+ try {
445
+ for (const cb of state.listeners) cb();
446
+ } finally {
447
+ state.notifying = false;
448
+ }
449
+ }
450
+
451
+ function sameFrame(a: Frame, b: Frame): boolean {
452
+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Link helper
457
+ // ---------------------------------------------------------------------------
458
+
459
+ export interface LinkViewportsOptions {
460
+ /** Sync X axis state. Default: true. */
461
+ x?: boolean;
462
+ /** Sync Y axis state. Default: false. */
463
+ y?: boolean;
464
+ }
465
+
466
+ /**
467
+ * Keep multiple data viewports in sync on selected axes. Camera viewports in
468
+ * the array are skipped (link only makes sense between like-typed data axes).
469
+ *
470
+ * Any pan/zoom/reset on one data viewport propagates the current transformed-
471
+ * domain endpoints to the others on the linked axes. Requires linked axes to
472
+ * have matching `type` (e.g. both `linear`, or both `time`).
473
+ */
474
+ export function linkViewports(
475
+ viewports: readonly Viewport<any, any>[],
476
+ options: LinkViewportsOptions = {},
477
+ ): () => void {
478
+ const linkX = options.x ?? true;
479
+ const linkY = options.y ?? false;
480
+ if (viewports.length < 2 || (!linkX && !linkY)) return () => {};
481
+
482
+ let propagating = false;
483
+ const unsubs: Array<() => void> = [];
484
+
485
+ for (const source of viewports) {
486
+ if (source.mode !== "data") continue;
487
+ const sourceState = source._state;
488
+ const unsub = source.onChange(() => {
489
+ if (propagating) return;
490
+ propagating = true;
491
+ try {
492
+ for (const target of viewports) {
493
+ if (target === source) continue;
494
+ if (target.mode !== "data") continue;
495
+ const targetState = target._state;
496
+ if (linkX) copyContinuous(sourceState.xAxis, targetState.xAxis);
497
+ if (linkY) copyContinuous(sourceState.yAxis, targetState.yAxis);
498
+ for (const cb of targetState.listeners) cb();
499
+ }
500
+ } finally {
501
+ propagating = false;
502
+ }
503
+ });
504
+ unsubs.push(unsub);
505
+ }
506
+
507
+ return () => {
508
+ for (const unsub of unsubs) unsub();
509
+ };
510
+ }