@pluot/core 0.1.0 → 0.1.1

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 (34) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +1109 -4101
  3. package/dist-tsc/functional-3d-view-controls.d.ts +11 -0
  4. package/dist-tsc/functional-3d-view-controls.d.ts.map +1 -0
  5. package/dist-tsc/functional-3d-view-controls.js +517 -0
  6. package/dist-tsc/functional-dom-2d-camera.d.ts +9 -0
  7. package/dist-tsc/functional-dom-2d-camera.d.ts.map +1 -0
  8. package/dist-tsc/functional-dom-2d-camera.js +178 -0
  9. package/dist-tsc/index.d.ts +2 -2
  10. package/dist-tsc/index.d.ts.map +1 -1
  11. package/dist-tsc/index.js +2 -2
  12. package/dist-tsc/lru-store.d.ts.map +1 -1
  13. package/dist-tsc/lru-store.js +3 -0
  14. package/dist-tsc/unrolled-3d-view-controls.d.ts +2 -0
  15. package/dist-tsc/unrolled-3d-view-controls.d.ts.map +1 -0
  16. package/dist-tsc/unrolled-3d-view-controls.js +637 -0
  17. package/dist-tsc/unrolled-dom-2d-camera.d.ts +2 -0
  18. package/dist-tsc/unrolled-dom-2d-camera.d.ts.map +1 -0
  19. package/dist-tsc/unrolled-dom-2d-camera.js +193 -0
  20. package/dist-tsc/viewport.d.ts +91 -0
  21. package/dist-tsc/viewport.d.ts.map +1 -1
  22. package/dist-tsc/viewport.js +91 -3
  23. package/dist-tsc/viewport.test.d.ts +2 -0
  24. package/dist-tsc/viewport.test.d.ts.map +1 -0
  25. package/dist-tsc/viewport.test.js +184 -0
  26. package/package.json +8 -2
  27. package/src/functional-3d-view-controls.ts +585 -0
  28. package/src/functional-dom-2d-camera.ts +214 -0
  29. package/src/index.ts +2 -2
  30. package/src/lru-store.ts +3 -0
  31. package/src/viewport.test.ts +262 -0
  32. package/src/viewport.ts +91 -3
  33. package/src/3d-view-controls.js +0 -271
  34. package/src/dom-2d-camera.js +0 -441
@@ -0,0 +1,214 @@
1
+ // Functional/stateless adaptation of dom-2d-camera.
2
+ //
3
+ // License copied from https://github.com/flekschas/dom-2d-camera/blob/master/LICENSE.md
4
+ //
5
+ // This software is released under the MIT license:
6
+ //
7
+ // Permission is hereby granted, free of charge, to any person obtaining a copy of
8
+ // this software and associated documentation files (the "Software"), to deal in
9
+ // the Software without restriction, including without limitation the rights to
10
+ // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
11
+ // the Software, and to permit persons to whom the Software is furnished to do so,
12
+ // subject to the following conditions:
13
+ //
14
+ // The above copyright notice and this permission notice shall be included in all
15
+ // copies or substantial portions of the Software.
16
+ //
17
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
19
+ // FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
20
+ // COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
21
+ // IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
22
+ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+
25
+ import { mat4, vec2 } from 'gl-matrix';
26
+ import { ViewportParams } from './viewport.js';
27
+
28
+
29
+ export type CameraMatrix = Float32Array;
30
+
31
+ // Camera configuration constants (matching dom-2d-camera defaults)
32
+ const isFixed = false;
33
+ const isNdc = true;
34
+ const isPan = true;
35
+ const isPanInverted = [false, true];
36
+ const panSpeed = 1;
37
+ const isRotate = true;
38
+ const rotateSpeed = 1;
39
+ const defaultMouseDownMoveAction = "pan";
40
+ const mouseDownMoveModKey = "alt";
41
+ const isZoom = true;
42
+ const zoomSpeed = 1;
43
+
44
+ // Derived settings
45
+ const isPanX = Array.isArray(isPan) ? Boolean(isPan[0]) : Boolean(isPan);
46
+ const isPanY = Array.isArray(isPan) ? Boolean(isPan[1]) : Boolean(isPan);
47
+ const isPanXInverted = Array.isArray(isPanInverted) ? Boolean(isPanInverted[0]) : Boolean(isPanInverted);
48
+ const isPanYInverted = Array.isArray(isPanInverted) ? Boolean(isPanInverted[1]) : Boolean(isPanInverted);
49
+ const isZoomX = Array.isArray(isZoom) ? Boolean(isZoom[0]) : Boolean(isZoom);
50
+ const isZoomY = Array.isArray(isZoom) ? Boolean(isZoom[1]) : Boolean(isZoom);
51
+ const panOnMouseDownMove = defaultMouseDownMoveAction === "pan";
52
+
53
+ const KEY_MAP: Record<string, string> = {
54
+ alt: "altKey",
55
+ cmd: "metaKey",
56
+ ctrl: "ctrlKey",
57
+ meta: "metaKey",
58
+ shift: "shiftKey",
59
+ };
60
+
61
+
62
+ // --- Aspect ratio helpers ---
63
+
64
+ function computeAspectRatioFactors(vp: ViewportParams) {
65
+ const aspectRatio = vp.width / vp.height;
66
+ let xFactor = 1.0;
67
+ let yFactor = 1.0;
68
+
69
+ if (vp.aspectRatioMode === "Contain") {
70
+ if (aspectRatio > 1.0) xFactor = 1.0 / aspectRatio;
71
+ else if (aspectRatio < 1.0) yFactor = aspectRatio;
72
+ } else if (vp.aspectRatioMode === "Cover") {
73
+ if (aspectRatio > 1.0) yFactor = aspectRatio;
74
+ else if (aspectRatio < 1.0) xFactor = 1.0 / aspectRatio;
75
+ }
76
+
77
+ let xAlignTranslation = 0.0;
78
+ let yAlignTranslation = 0.0;
79
+ if (vp.aspectRatioAlignmentMode === "Start") {
80
+ xAlignTranslation = xFactor - 1.0;
81
+ yAlignTranslation = yFactor - 1.0;
82
+ } else if (vp.aspectRatioAlignmentMode === "End") {
83
+ xAlignTranslation = 1.0 - xFactor;
84
+ yAlignTranslation = 1.0 - yFactor;
85
+ }
86
+
87
+ return { xFactor, yFactor, xAlignTranslation, yAlignTranslation };
88
+ }
89
+
90
+
91
+
92
+ // --- Event handlers ---
93
+
94
+ export function onWheel(viewportParams: ViewportParams, prevCameraMatrix: CameraMatrix, event: WheelEvent): CameraMatrix {
95
+ event.preventDefault();
96
+ if ((!isZoomX && !isZoomY) || isFixed) return prevCameraMatrix;
97
+
98
+ const { width: plotWidth, height: plotHeight, margins } = viewportParams;
99
+ const width = plotWidth - ((margins?.marginLeft ?? 0) + (margins?.marginRight ?? 0));
100
+ const height = plotHeight - ((margins?.marginBottom ?? 0) + (margins?.marginTop ?? 0));
101
+ const { xFactor, yFactor, xAlignTranslation, yAlignTranslation } = computeAspectRatioFactors(viewportParams);
102
+
103
+ const deltaModeScale = event.deltaMode === 1 ? 12 : 1;
104
+ const scrollDist = deltaModeScale * (event.deltaY || event.deltaX || 0);
105
+ if (!scrollDist) return prevCameraMatrix;
106
+
107
+ const dZ = zoomSpeed * Math.exp(scrollDist / height);
108
+
109
+ const px = isNdc
110
+ ? ((-1 + (event.offsetX / width) * 2) - xAlignTranslation) * (1.0 / xFactor)
111
+ : event.offsetX;
112
+ const py = isNdc
113
+ ? ((1 - (event.offsetY / height) * 2) - yAlignTranslation) * (1.0 / yFactor)
114
+ : event.offsetY;
115
+
116
+ let dx = isZoomX ? 1 / dZ : 1;
117
+ let dy = isZoomY ? 1 / dZ : 1;
118
+
119
+ if (dx <= 0 || dy <= 0 || (dx === 1 && dy === 1)) return prevCameraMatrix;
120
+
121
+ const view = mat4.clone(prevCameraMatrix);
122
+ const s = mat4.fromScaling(mat4.create(), new Float32Array([dx, dy, 1]));
123
+ const p = new Float32Array([px, py, 0]);
124
+ const a = mat4.fromTranslation(mat4.create(), p);
125
+ const aInv = mat4.invert(mat4.create(), a)!;
126
+
127
+ // view = a * s * aInv * prevCameraMatrix (scale about mouse pivot)
128
+ mat4.multiply(view, aInv, view);
129
+ mat4.multiply(view, s, view);
130
+ mat4.multiply(view, a, view);
131
+
132
+ return view as unknown as CameraMatrix;
133
+ }
134
+
135
+ export function onMouseMove(viewportParams: ViewportParams, prevCameraMatrix: CameraMatrix, event: MouseEvent): CameraMatrix {
136
+ const { width: plotWidth, height: plotHeight, margins } = viewportParams;
137
+ const width = plotWidth - ((margins?.marginLeft ?? 0) + (margins?.marginRight ?? 0));
138
+ const height = plotHeight - ((margins?.marginBottom ?? 0) + (margins?.marginTop ?? 0));
139
+ const { xFactor, yFactor } = computeAspectRatioFactors(viewportParams);
140
+
141
+ const isLeftMousePressed = (event.buttons & 1) !== 0;
142
+ const isMouseDownMoveModActive = Boolean((event as unknown as Record<string, boolean>)[KEY_MAP[mouseDownMoveModKey]]);
143
+
144
+ const view = mat4.clone(prevCameraMatrix);
145
+ let changed = false;
146
+
147
+ // Pan
148
+ if (
149
+ (isPanX || isPanY) &&
150
+ isLeftMousePressed &&
151
+ ((panOnMouseDownMove && !isMouseDownMoveModActive) ||
152
+ (!panOnMouseDownMove && isMouseDownMoveModActive))
153
+ ) {
154
+ const dX = isPanXInverted ? -event.movementX : event.movementX;
155
+ const dY = isPanYInverted ? -event.movementY : event.movementY;
156
+ const tx = isPanX ? (isNdc ? ((panSpeed * dX) / width) * 2 * (1.0 / xFactor) : panSpeed * dX) : 0;
157
+ const ty = isPanY ? (isNdc ? ((panSpeed * dY) / height) * 2 * (1.0 / yFactor) : -(panSpeed * dY)) : 0;
158
+
159
+ if (tx !== 0 || ty !== 0) {
160
+ const t = mat4.fromTranslation(mat4.create(), new Float32Array([tx, ty, 0]));
161
+ mat4.multiply(view, t, view);
162
+ changed = true;
163
+ }
164
+ }
165
+
166
+ // Rotate
167
+ if (
168
+ isRotate &&
169
+ isLeftMousePressed &&
170
+ ((panOnMouseDownMove && isMouseDownMoveModActive) ||
171
+ (!panOnMouseDownMove && !isMouseDownMoveModActive)) &&
172
+ (Math.abs(event.movementX) + Math.abs(event.movementY)) > 0
173
+ ) {
174
+ const wh = width / 2;
175
+ const hh = height / 2;
176
+ const x1 = (event.offsetX - event.movementX) - wh;
177
+ const y1 = hh - (event.offsetY - event.movementY);
178
+ const x2 = event.offsetX - wh;
179
+ const y2 = hh - event.offsetY;
180
+
181
+ if (x1 * x1 + y1 * y1 > 0 && x2 * x2 + y2 * y2 > 0) {
182
+ const radians = vec2.angle([x1, y1], [x2, y2]);
183
+ const cross = x1 * y2 - x2 * y1;
184
+ const rad = rotateSpeed * radians * Math.sign(cross);
185
+ if (rad !== 0) {
186
+ const r = mat4.fromRotation(mat4.create(), rad, new Float32Array([0, 0, 1]));
187
+ mat4.multiply(view, r, view);
188
+ changed = true;
189
+ }
190
+ }
191
+ }
192
+
193
+ return changed ? view as unknown as CameraMatrix : prevCameraMatrix;
194
+ }
195
+
196
+ // These handlers don't modify the camera matrix in the functional approach.
197
+ // Button state is read from event.buttons in onMouseMove; modifier key state
198
+ // is read from event.altKey/etc. in onMouseMove.
199
+
200
+ export function onMouseDown(_viewportParams: ViewportParams, prevCameraMatrix: CameraMatrix, _event: MouseEvent): CameraMatrix {
201
+ return prevCameraMatrix;
202
+ }
203
+
204
+ export function onMouseUp(_viewportParams: ViewportParams, prevCameraMatrix: CameraMatrix, _event: MouseEvent): CameraMatrix {
205
+ return prevCameraMatrix;
206
+ }
207
+
208
+ export function onKeyDown(_viewportParams: ViewportParams, prevCameraMatrix: CameraMatrix, _event: KeyboardEvent): CameraMatrix {
209
+ return prevCameraMatrix;
210
+ }
211
+
212
+ export function onKeyUp(_viewportParams: ViewportParams, prevCameraMatrix: CameraMatrix, _event: KeyboardEvent): CameraMatrix {
213
+ return prevCameraMatrix;
214
+ }
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ export {
7
7
  setStoreByName,
8
8
  getStore,
9
9
  } from './core.js';
10
- export { default as create2dCamera } from "./dom-2d-camera.js";
11
- export { default as create3dCamera } from "./3d-view-controls.js";
12
10
  export { checkWebGpuFeatureDetection } from './feature-detection.js';
13
11
  export { getBounds, getCameraMatrixFromBounds } from './viewport.js';
12
+ export { onMouseMove as onMouseMove2d, onWheel as onWheel2d } from './functional-dom-2d-camera.js';
13
+ export { onMouseMove as onMouseMove3d, onWheel as onWheel3d } from './functional-3d-view-controls.js';
package/src/lru-store.ts CHANGED
@@ -45,6 +45,9 @@ export class LruStore<S extends AsyncReadable> implements AsyncReadable {
45
45
 
46
46
  // We need a way to synchronously peek at the promise state (a-la Bun's peek or Effect's Deferred.poll).
47
47
  // We can probably do something more sophisticated but will try this first.
48
+ // TODO: should this map be stored on the Rust side instead, so that the peeking can be performed without
49
+ // the JS function call? Instead, JS would "push" the promise states by calling a Rust function upon any
50
+ // promise state change, via a new function exposed from Rust such as `wasm.push_promise_state(key, 'fulfilled')`
48
51
  #promise_states: Map<string, 'pending' | 'fulfilled' | 'rejected'>;
49
52
 
50
53
  constructor(store: S, maxSize = 100) {
@@ -0,0 +1,262 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getBounds, getCameraMatrixFromBounds } from './viewport.js';
3
+ import type { AspectRatioMode, AspectRatioAlignmentMode, ViewportParams } from './viewport.js';
4
+
5
+ function identityCamera(): Float32Array {
6
+ return new Float32Array([
7
+ 1, 0, 0, 0,
8
+ 0, 1, 0, 0,
9
+ 0, 0, 1, 0,
10
+ 0, 0, 0, 1,
11
+ ]);
12
+ }
13
+
14
+ function zoomCamera(zoom: number): Float32Array {
15
+ return new Float32Array([
16
+ zoom, 0, 0, 0,
17
+ 0, zoom, 0, 0,
18
+ 0, 0, 1, 0,
19
+ 0, 0, 0, 1,
20
+ ]);
21
+ }
22
+
23
+ function zoomAndTranslateCamera(zoom: number, tx: number, ty: number): Float32Array {
24
+ return new Float32Array([
25
+ zoom, 0, 0, 0,
26
+ 0, zoom, 0, 0,
27
+ 0, 0, 1, 0,
28
+ tx, ty, 0, 1,
29
+ ]);
30
+ }
31
+
32
+ function makeViewport(
33
+ width: number,
34
+ height: number,
35
+ aspectRatioMode: AspectRatioMode,
36
+ aspectRatioAlignmentMode: AspectRatioAlignmentMode = 'Center',
37
+ ): ViewportParams {
38
+ return { width, height, aspectRatioMode, aspectRatioAlignmentMode };
39
+ }
40
+
41
+ function expectArrayCloseTo(actual: Float32Array, expected: Float32Array, numDigits = 5): void {
42
+ expect(actual.length).toBe(expected.length);
43
+ for (let i = 0; i < actual.length; i++) {
44
+ expect(actual[i]).toBeCloseTo(expected[i], numDigits);
45
+ }
46
+ }
47
+
48
+ // =================== getBounds ===================
49
+
50
+ describe('getBounds', () => {
51
+ it('identity camera, square, Ignore → full [0, 1] range', () => {
52
+ const b = getBounds(identityCamera(), makeViewport(100, 100, 'Ignore'));
53
+ expect(b.xMin).toBeCloseTo(0);
54
+ expect(b.xMax).toBeCloseTo(1);
55
+ expect(b.yMin).toBeCloseTo(0);
56
+ expect(b.yMax).toBeCloseTo(1);
57
+ });
58
+
59
+ it('2x zoom, square, Ignore → [0.25, 0.75]', () => {
60
+ const b = getBounds(zoomCamera(2), makeViewport(100, 100, 'Ignore'));
61
+ expect(b.xMin).toBeCloseTo(0.25);
62
+ expect(b.xMax).toBeCloseTo(0.75);
63
+ expect(b.yMin).toBeCloseTo(0.25);
64
+ expect(b.yMax).toBeCloseTo(0.75);
65
+ });
66
+
67
+ it('0.5x zoom (zoomed out 2x), square, Ignore → [-0.5, 1.5]', () => {
68
+ const b = getBounds(zoomCamera(0.5), makeViewport(100, 100, 'Ignore'));
69
+ expect(b.xMin).toBeCloseTo(-0.5);
70
+ expect(b.xMax).toBeCloseTo(1.5);
71
+ expect(b.yMin).toBeCloseTo(-0.5);
72
+ expect(b.yMax).toBeCloseTo(1.5);
73
+ });
74
+
75
+ it('wide (200×100), Contain → x extends to [-0.5, 1.5], y stays [0, 1]', () => {
76
+ const b = getBounds(identityCamera(), makeViewport(200, 100, 'Contain'));
77
+ expect(b.xMin).toBeCloseTo(-0.5);
78
+ expect(b.xMax).toBeCloseTo(1.5);
79
+ expect(b.yMin).toBeCloseTo(0);
80
+ expect(b.yMax).toBeCloseTo(1);
81
+ });
82
+
83
+ it('tall (100×200), Contain → x stays [0, 1], y extends to [-0.5, 1.5]', () => {
84
+ const b = getBounds(identityCamera(), makeViewport(100, 200, 'Contain'));
85
+ expect(b.xMin).toBeCloseTo(0);
86
+ expect(b.xMax).toBeCloseTo(1);
87
+ expect(b.yMin).toBeCloseTo(-0.5);
88
+ expect(b.yMax).toBeCloseTo(1.5);
89
+ });
90
+
91
+ it('wide (200×100), Cover → x stays [0, 1], y shrinks to [0.25, 0.75]', () => {
92
+ const b = getBounds(identityCamera(), makeViewport(200, 100, 'Cover'));
93
+ expect(b.xMin).toBeCloseTo(0);
94
+ expect(b.xMax).toBeCloseTo(1);
95
+ expect(b.yMin).toBeCloseTo(0.25);
96
+ expect(b.yMax).toBeCloseTo(0.75);
97
+ });
98
+
99
+ it('with margins, square (100×100), Ignore → [0, 1] × [0, 1]', () => {
100
+ const viewport: ViewportParams = {
101
+ ...makeViewport(100, 100, 'Ignore'),
102
+ margins: { marginLeft: 20, marginBottom: 20 },
103
+ };
104
+ const b = getBounds(identityCamera(), viewport);
105
+ expect(b.xMin).toBeCloseTo(0);
106
+ expect(b.xMax).toBeCloseTo(1);
107
+ expect(b.yMin).toBeCloseTo(0);
108
+ expect(b.yMax).toBeCloseTo(1);
109
+ });
110
+ });
111
+
112
+ // =================== getCameraMatrixFromBounds ===================
113
+
114
+ describe('getCameraMatrixFromBounds', () => {
115
+ it('full [0, 1] range → identity camera', () => {
116
+ const camera = getCameraMatrixFromBounds(
117
+ { xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
118
+ identityCamera(),
119
+ makeViewport(100, 100, 'Ignore'),
120
+ );
121
+ expectArrayCloseTo(camera, identityCamera());
122
+ });
123
+
124
+ it('[0.25, 0.75] × [0.25, 0.75] → 2x zoom camera', () => {
125
+ const camera = getCameraMatrixFromBounds(
126
+ { xMin: 0.25, xMax: 0.75, yMin: 0.25, yMax: 0.75 },
127
+ identityCamera(),
128
+ makeViewport(100, 100, 'Ignore'),
129
+ );
130
+ expectArrayCloseTo(camera, zoomCamera(2));
131
+ });
132
+
133
+ it('[-0.5, 1.5] × [-0.5, 1.5] → 0.5x zoom (zoomed out 2x)', () => {
134
+ const camera = getCameraMatrixFromBounds(
135
+ { xMin: -0.5, xMax: 1.5, yMin: -0.5, yMax: 1.5 },
136
+ identityCamera(),
137
+ makeViewport(100, 100, 'Ignore'),
138
+ );
139
+ expectArrayCloseTo(camera, zoomCamera(0.5));
140
+ });
141
+
142
+ it('x-offset bounds → zoom=1, translateX=0.5, translateY=0', () => {
143
+ const camera = getCameraMatrixFromBounds(
144
+ { xMin: -0.25, xMax: 0.75, yMin: 0, yMax: 1 },
145
+ identityCamera(),
146
+ makeViewport(100, 100, 'Ignore'),
147
+ );
148
+ expectArrayCloseTo(camera, zoomAndTranslateCamera(1, 0.5, 0));
149
+ });
150
+
151
+ it('zoom + translation bounds → zoom=2, translateX=0.5, translateY=0.25', () => {
152
+ const camera = getCameraMatrixFromBounds(
153
+ { xMin: 0.125, xMax: 0.625, yMin: 0.1875, yMax: 0.6875 },
154
+ identityCamera(),
155
+ makeViewport(100, 100, 'Ignore'),
156
+ );
157
+ expectArrayCloseTo(camera, zoomAndTranslateCamera(2, 0.5, 0.25));
158
+ });
159
+
160
+ it('wide Contain bounds [-0.5, 1.5] × [0, 1] → identity camera', () => {
161
+ const camera = getCameraMatrixFromBounds(
162
+ { xMin: -0.5, xMax: 1.5, yMin: 0, yMax: 1 },
163
+ identityCamera(),
164
+ makeViewport(200, 100, 'Contain'),
165
+ );
166
+ expectArrayCloseTo(camera, identityCamera());
167
+ });
168
+
169
+ it('tall Contain bounds [0, 1] × [-0.5, 1.5] → identity camera', () => {
170
+ const camera = getCameraMatrixFromBounds(
171
+ { xMin: 0, xMax: 1, yMin: -0.5, yMax: 1.5 },
172
+ identityCamera(),
173
+ makeViewport(100, 200, 'Contain'),
174
+ );
175
+ expectArrayCloseTo(camera, identityCamera());
176
+ });
177
+
178
+ it('wide Cover bounds [0, 1] × [0.25, 0.75] → identity camera', () => {
179
+ const camera = getCameraMatrixFromBounds(
180
+ { xMin: 0, xMax: 1, yMin: 0.25, yMax: 0.75 },
181
+ identityCamera(),
182
+ makeViewport(200, 100, 'Cover'),
183
+ );
184
+ expectArrayCloseTo(camera, identityCamera());
185
+ });
186
+
187
+ it('asymmetric ranges, Ignore → independent x/y zoom', () => {
188
+ const camera = getCameraMatrixFromBounds(
189
+ { xMin: 0, xMax: 0.5, yMin: 0, yMax: 1 },
190
+ identityCamera(),
191
+ makeViewport(100, 100, 'Ignore'),
192
+ );
193
+ expectArrayCloseTo(camera, new Float32Array([
194
+ 2, 0, 0, 0,
195
+ 0, 1, 0, 0,
196
+ 0, 0, 1, 0,
197
+ 1, 0, 0, 1,
198
+ ]));
199
+ });
200
+
201
+ it('partial bounds (x-axis only) preserves y from prevCameraMatrix', () => {
202
+ const prevCamera = zoomAndTranslateCamera(2, 0.5, 0.25);
203
+ const viewport = makeViewport(100, 100, 'Ignore');
204
+ const prevBounds = getBounds(prevCamera, viewport);
205
+
206
+ const camera = getCameraMatrixFromBounds(
207
+ { xMin: 0, xMax: 1 },
208
+ prevCamera,
209
+ viewport,
210
+ );
211
+ const newBounds = getBounds(camera, viewport);
212
+
213
+ expect(newBounds.xMin).toBeCloseTo(0);
214
+ expect(newBounds.xMax).toBeCloseTo(1);
215
+ expect(newBounds.yMin).toBeCloseTo(prevBounds.yMin);
216
+ expect(newBounds.yMax).toBeCloseTo(prevBounds.yMax);
217
+ });
218
+ });
219
+
220
+ // =================== getBounds / getCameraMatrixFromBounds roundtrip ===================
221
+
222
+ describe('getBounds / getCameraMatrixFromBounds roundtrip', () => {
223
+ it('identity camera → bounds → identity camera', () => {
224
+ const viewport = makeViewport(100, 100, 'Ignore');
225
+ const bounds = getBounds(identityCamera(), viewport);
226
+ const camera = getCameraMatrixFromBounds(bounds, identityCamera(), viewport);
227
+ expectArrayCloseTo(camera, identityCamera());
228
+ });
229
+
230
+ it('2x zoom camera → bounds → 2x zoom camera', () => {
231
+ const viewport = makeViewport(100, 100, 'Ignore');
232
+ const camera0 = zoomCamera(2);
233
+ const bounds = getBounds(camera0, viewport);
234
+ const camera1 = getCameraMatrixFromBounds(bounds, identityCamera(), viewport);
235
+ expectArrayCloseTo(camera1, camera0);
236
+ });
237
+
238
+ it('wide Contain viewport, identity camera → bounds → identity camera', () => {
239
+ const viewport = makeViewport(200, 100, 'Contain');
240
+ const bounds = getBounds(identityCamera(), viewport);
241
+ const camera = getCameraMatrixFromBounds(bounds, identityCamera(), viewport);
242
+ expectArrayCloseTo(camera, identityCamera());
243
+ });
244
+
245
+ it('zoom + translation → bounds → same camera', () => {
246
+ const viewport = makeViewport(100, 100, 'Ignore');
247
+ const camera0 = zoomAndTranslateCamera(2, 0.5, 0.25);
248
+ const bounds = getBounds(camera0, viewport);
249
+ const camera1 = getCameraMatrixFromBounds(bounds, identityCamera(), viewport);
250
+ expectArrayCloseTo(camera1, camera0);
251
+ });
252
+
253
+ it('with margins, identity camera → bounds → identity camera', () => {
254
+ const viewport: ViewportParams = {
255
+ ...makeViewport(100, 100, 'Ignore'),
256
+ margins: { marginLeft: 20, marginBottom: 20 },
257
+ };
258
+ const bounds = getBounds(identityCamera(), viewport);
259
+ const camera = getCameraMatrixFromBounds(bounds, identityCamera(), viewport);
260
+ expectArrayCloseTo(camera, identityCamera());
261
+ });
262
+ });
package/src/viewport.ts CHANGED
@@ -29,7 +29,44 @@ export type Bounds = {
29
29
  yMax?: number;
30
30
  };
31
31
 
32
- // Calculate the visible data range based on camera view and viewport parameters.
32
+ /**
33
+ * Calculate the visible data range based on camera view and viewport parameters.
34
+ *
35
+ * Bounds are expressed in normalized data coordinates where 0.0 is the
36
+ * left/top edge of the data and 1.0 is the right/bottom edge.
37
+ *
38
+ * @param cameraMatrix - The current camera matrix, typically from the renderer's
39
+ * current state.
40
+ * @param viewportParams - Describes the canvas size, aspect-ratio handling, and
41
+ * margins.
42
+ * @returns The visible data range as `{ xMin, xMax, yMin, yMax }` in normalized
43
+ * data coordinates.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * import { getBounds } from "@pluot/core";
48
+ *
49
+ * const viewport = {
50
+ * width: 800,
51
+ * height: 600,
52
+ * aspectRatioMode: "Contain",
53
+ * aspectRatioAlignmentMode: "Center",
54
+ * margins: { marginTop: 10, marginRight: 10, marginBottom: 10, marginLeft: 10 },
55
+ * };
56
+ *
57
+ * // Identity camera matrix (full data range visible).
58
+ * const identity = new Float32Array([
59
+ * 1, 0, 0, 0,
60
+ * 0, 1, 0, 0,
61
+ * 0, 0, 1, 0,
62
+ * 0, 0, 0, 1,
63
+ * ]);
64
+ *
65
+ * const bounds = getBounds(identity, viewport);
66
+ * // bounds.xMin, bounds.xMax, bounds.yMin, bounds.yMax are all in [0, 1]
67
+ * // for the identity matrix with "Contain" mode.
68
+ * ```
69
+ */
33
70
  export function getBounds(cameraMatrix: Float32Array, viewportParams: ViewportParams): Required<Bounds> {
34
71
  const zoomX = cameraMatrix[0];
35
72
  const zoomY = cameraMatrix[5];
@@ -76,8 +113,59 @@ export function getBounds(cameraMatrix: Float32Array, viewportParams: ViewportPa
76
113
  return { xMin, xMax, yMin, yMax };
77
114
  }
78
115
 
79
- // Given data bounds, compute the corresponding camera matrix.
80
- // Missing bound values are filled in from prevCameraMatrix.
116
+ /**
117
+ * Given data bounds, compute the corresponding camera matrix.
118
+ * Missing bound values are filled in from `prevCameraMatrix` so partial updates
119
+ * (e.g. panning only the X axis) work without resetting the other axis.
120
+ *
121
+ * Bounds are expressed in normalized data coordinates where 0.0 is the
122
+ * left/top edge of the data and 1.0 is the right/bottom edge.
123
+ *
124
+ * @param bounds - The desired visible data range. Any omitted fields are
125
+ * preserved from `prevCameraMatrix`.
126
+ * @param prevCameraMatrix - The current camera matrix, used to fill in any
127
+ * omitted bound values. Typically the matrix returned by a previous call or
128
+ * the renderer's current state.
129
+ * @param viewportParams - Describes the canvas size, aspect-ratio handling, and
130
+ * margins so the zoom level can be computed correctly.
131
+ * @returns A column-major 4×4 `Float32Array` suitable for passing directly to
132
+ * the renderer as the camera matrix.
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * import { getCameraMatrixFromBounds } from "@pluot/core";
137
+ *
138
+ * const viewport = {
139
+ * width: 800,
140
+ * height: 600,
141
+ * aspectRatioMode: "Contain",
142
+ * aspectRatioAlignmentMode: "Center",
143
+ * margins: { marginTop: 10, marginRight: 10, marginBottom: 10, marginLeft: 10 },
144
+ * };
145
+ *
146
+ * // Identity camera matrix (full data range visible).
147
+ * const identity = new Float32Array([
148
+ * 1, 0, 0, 0,
149
+ * 0, 1, 0, 0,
150
+ * 0, 0, 1, 0,
151
+ * 0, 0, 0, 1,
152
+ * ]);
153
+ *
154
+ * // Zoom into the top-left quadrant of the data.
155
+ * const cameraMatrix = getCameraMatrixFromBounds(
156
+ * { xMin: 0.0, xMax: 0.5, yMin: 0.0, yMax: 0.5 },
157
+ * identity,
158
+ * viewport,
159
+ * );
160
+ *
161
+ * // Pan to shift only the X axis, keeping Y unchanged.
162
+ * const pannedMatrix = getCameraMatrixFromBounds(
163
+ * { xMin: 0.1, xMax: 0.6 },
164
+ * cameraMatrix,
165
+ * viewport,
166
+ * );
167
+ * ```
168
+ */
81
169
  export function getCameraMatrixFromBounds(bounds: Bounds, prevCameraMatrix: Float32Array, viewportParams: ViewportParams): Float32Array {
82
170
  // Fill in missing bounds from the previous camera matrix.
83
171
  const currentBounds = getBounds(prevCameraMatrix, viewportParams);