@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.
- package/README.md +1 -0
- package/dist/index.js +1109 -4101
- package/dist-tsc/functional-3d-view-controls.d.ts +11 -0
- package/dist-tsc/functional-3d-view-controls.d.ts.map +1 -0
- package/dist-tsc/functional-3d-view-controls.js +517 -0
- package/dist-tsc/functional-dom-2d-camera.d.ts +9 -0
- package/dist-tsc/functional-dom-2d-camera.d.ts.map +1 -0
- package/dist-tsc/functional-dom-2d-camera.js +178 -0
- package/dist-tsc/index.d.ts +2 -2
- package/dist-tsc/index.d.ts.map +1 -1
- package/dist-tsc/index.js +2 -2
- package/dist-tsc/lru-store.d.ts.map +1 -1
- package/dist-tsc/lru-store.js +3 -0
- package/dist-tsc/unrolled-3d-view-controls.d.ts +2 -0
- package/dist-tsc/unrolled-3d-view-controls.d.ts.map +1 -0
- package/dist-tsc/unrolled-3d-view-controls.js +637 -0
- package/dist-tsc/unrolled-dom-2d-camera.d.ts +2 -0
- package/dist-tsc/unrolled-dom-2d-camera.d.ts.map +1 -0
- package/dist-tsc/unrolled-dom-2d-camera.js +193 -0
- package/dist-tsc/viewport.d.ts +91 -0
- package/dist-tsc/viewport.d.ts.map +1 -1
- package/dist-tsc/viewport.js +91 -3
- package/dist-tsc/viewport.test.d.ts +2 -0
- package/dist-tsc/viewport.test.d.ts.map +1 -0
- package/dist-tsc/viewport.test.js +184 -0
- package/package.json +8 -2
- package/src/functional-3d-view-controls.ts +585 -0
- package/src/functional-dom-2d-camera.ts +214 -0
- package/src/index.ts +2 -2
- package/src/lru-store.ts +3 -0
- package/src/viewport.test.ts +262 -0
- package/src/viewport.ts +91 -3
- package/src/3d-view-controls.js +0 -271
- 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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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);
|