@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,193 @@
|
|
|
1
|
+
import { mat4, vec2 } from "gl-matrix";
|
|
2
|
+
// Closure state inherited from dom-2d-camera.js (initialized elsewhere):
|
|
3
|
+
// element
|
|
4
|
+
// isPanX, isPanY, isPanXInverted, isPanYInverted
|
|
5
|
+
// isZoomX, isZoomY, isFixed, isNdc, isRotate
|
|
6
|
+
// panSpeed, zoomSpeed, rotateSpeed
|
|
7
|
+
// panOnMouseDownMove, isMouseDownMoveModActive, isLeftMousePressed
|
|
8
|
+
// width, height
|
|
9
|
+
// xAspectRatioModeFactor, yAspectRatioModeFactor
|
|
10
|
+
// xAlignmentTranslation, yAlignmentTranslation
|
|
11
|
+
// mouseX, mouseY, mouseRelX, mouseRelY, prevMouseX, prevMouseY
|
|
12
|
+
// scrollDist
|
|
13
|
+
// view (mat4), viewCenter (vec4)
|
|
14
|
+
// scaleXBounds, scaleYBounds
|
|
15
|
+
// scratch0, scratch1, scratch2 (Float32Array(16))
|
|
16
|
+
// isInteractivelyChanged, isProgrammaticallyChanged
|
|
17
|
+
// offsetXSupport = document.createEvent("MouseEvent").offsetX !== undefined
|
|
18
|
+
// onMouseMove, onWheel (user callbacks)
|
|
19
|
+
function onMouseMove(event) {
|
|
20
|
+
// ---- Inlined: mouseMoveHandler ---------------------------------------
|
|
21
|
+
// Inlined: updateMouseXY(event)
|
|
22
|
+
mouseX = event.clientX;
|
|
23
|
+
mouseY = event.clientY;
|
|
24
|
+
//onMouseMove(event); // user callback
|
|
25
|
+
// ---- Inlined: tick() — pan + rotate branches -------------------------
|
|
26
|
+
// (In the original, tick() runs on each animation frame and consumes
|
|
27
|
+
// the mouseX/mouseY the mousemove handler just wrote. Inlining it here
|
|
28
|
+
// shows the full path of work triggered by a mousemove event.)
|
|
29
|
+
isInteractivelyChanged = false;
|
|
30
|
+
const currentMouseX = mouseX;
|
|
31
|
+
const currentMouseY = mouseY;
|
|
32
|
+
// ---- pan branch ------------------------------------------------------
|
|
33
|
+
if ((isPanX || isPanY) &&
|
|
34
|
+
isLeftMousePressed &&
|
|
35
|
+
((panOnMouseDownMove && !isMouseDownMoveModActive) ||
|
|
36
|
+
(!panOnMouseDownMove && isMouseDownMoveModActive))) {
|
|
37
|
+
const dX = isPanXInverted
|
|
38
|
+
? prevMouseX - currentMouseX
|
|
39
|
+
: currentMouseX - prevMouseX;
|
|
40
|
+
// Inlined: transformPanX(panSpeed * dX)
|
|
41
|
+
const transformedPanX = isPanX
|
|
42
|
+
? (isNdc
|
|
43
|
+
? ((panSpeed * dX) / width) * 2 * (1.0 / xAspectRatioModeFactor)
|
|
44
|
+
: panSpeed * dX)
|
|
45
|
+
: 0;
|
|
46
|
+
const dY = isPanYInverted
|
|
47
|
+
? prevMouseY - currentMouseY
|
|
48
|
+
: currentMouseY - prevMouseY;
|
|
49
|
+
// Inlined: transformPanY(panSpeed * dY)
|
|
50
|
+
const transformedPanY = isPanY
|
|
51
|
+
? (isNdc
|
|
52
|
+
? ((panSpeed * dY) / height) * 2 * (1.0 / yAspectRatioModeFactor)
|
|
53
|
+
: -(panSpeed * dY))
|
|
54
|
+
: 0;
|
|
55
|
+
if (transformedPanX !== 0 || transformedPanY !== 0) {
|
|
56
|
+
// Inlined: camera.pan([transformedPanX, transformedPanY])
|
|
57
|
+
// — `pan` is an alias for `translate` in camera-2d-simple.
|
|
58
|
+
{
|
|
59
|
+
const x = transformedPanX;
|
|
60
|
+
const y = transformedPanY;
|
|
61
|
+
scratch0[0] = x;
|
|
62
|
+
scratch0[1] = y;
|
|
63
|
+
scratch0[2] = 0;
|
|
64
|
+
const t = mat4.fromTranslation(scratch1, scratch0);
|
|
65
|
+
// Translate about the viewport center
|
|
66
|
+
// (identical to `i * t * i * view` where `i` is the identity).
|
|
67
|
+
mat4.multiply(view, t, view);
|
|
68
|
+
// Inlined: withProgrammaticChange wrapper around camera.pan.
|
|
69
|
+
isProgrammaticallyChanged = true;
|
|
70
|
+
}
|
|
71
|
+
isInteractivelyChanged = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// ---- rotate branch ---------------------------------------------------
|
|
75
|
+
if (isRotate &&
|
|
76
|
+
isLeftMousePressed &&
|
|
77
|
+
((panOnMouseDownMove && isMouseDownMoveModActive) ||
|
|
78
|
+
(!panOnMouseDownMove && !isMouseDownMoveModActive)) &&
|
|
79
|
+
Math.abs(prevMouseX - currentMouseX) +
|
|
80
|
+
Math.abs(prevMouseY - currentMouseY) >
|
|
81
|
+
0) {
|
|
82
|
+
const wh = width / 2;
|
|
83
|
+
const hh = height / 2;
|
|
84
|
+
const x1 = prevMouseX - wh;
|
|
85
|
+
const y1 = hh - prevMouseY;
|
|
86
|
+
const x2 = currentMouseX - wh;
|
|
87
|
+
const y2 = hh - currentMouseY;
|
|
88
|
+
// Angle between the start and end mouse position with respect to the
|
|
89
|
+
// viewport center.
|
|
90
|
+
const radians = vec2.angle([x1, y1], [x2, y2]);
|
|
91
|
+
// Determine the orientation.
|
|
92
|
+
const cross = x1 * y2 - x2 * y1;
|
|
93
|
+
// Inlined: camera.rotate(rotateSpeed * radians * Math.sign(cross))
|
|
94
|
+
// — from camera-2d-simple/dist/camera-2d.esm.js
|
|
95
|
+
{
|
|
96
|
+
const rad = rotateSpeed * radians * Math.sign(cross);
|
|
97
|
+
const r = mat4.create();
|
|
98
|
+
mat4.fromRotation(r, rad, [0, 0, 1]);
|
|
99
|
+
// Rotate about the viewport center
|
|
100
|
+
// (identical to `i * r * i * view` where `i` is the identity).
|
|
101
|
+
mat4.multiply(view, r, view);
|
|
102
|
+
// Inlined: withProgrammaticChange wrapper around camera.rotate.
|
|
103
|
+
isProgrammaticallyChanged = true;
|
|
104
|
+
}
|
|
105
|
+
isInteractivelyChanged = true;
|
|
106
|
+
}
|
|
107
|
+
// Inlined: tick()'s tail — advance prev mouse position, report change.
|
|
108
|
+
prevMouseX = currentMouseX;
|
|
109
|
+
prevMouseY = currentMouseY;
|
|
110
|
+
const isChanged = isInteractivelyChanged || isProgrammaticallyChanged;
|
|
111
|
+
isProgrammaticallyChanged = false;
|
|
112
|
+
return isChanged;
|
|
113
|
+
}
|
|
114
|
+
function onWheel(event) {
|
|
115
|
+
// ---- Inlined: wheelHandler -------------------------------------------
|
|
116
|
+
if ((isZoomX || isZoomY) && !isFixed) {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
// Inlined: updateMouseXY(event)
|
|
119
|
+
mouseX = event.clientX;
|
|
120
|
+
mouseY = event.clientY;
|
|
121
|
+
// Inlined: updateMouseRelXY(event)
|
|
122
|
+
if (offsetXSupport) {
|
|
123
|
+
mouseRelX = event.offsetX;
|
|
124
|
+
mouseRelY = event.offsetY;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const bBox = element.getBoundingClientRect();
|
|
128
|
+
mouseRelX = event.clientX - bBox.left;
|
|
129
|
+
mouseRelY = event.clientY - bBox.top;
|
|
130
|
+
}
|
|
131
|
+
const deltaModeScale = event.deltaMode === 1 ? 12 : 1;
|
|
132
|
+
scrollDist += deltaModeScale * (event.deltaY || event.deltaX || 0);
|
|
133
|
+
}
|
|
134
|
+
//onWheel(event); // user callback
|
|
135
|
+
// ---- Inlined: tick() — zoom branch only ------------------------------
|
|
136
|
+
// (In the original, tick() runs on each animation frame and consumes
|
|
137
|
+
// the scrollDist that the wheel handler accumulated. Inlining it here
|
|
138
|
+
// shows the full path of work triggered by a wheel event.)
|
|
139
|
+
isInteractivelyChanged = false;
|
|
140
|
+
if ((isZoomX || isZoomY) && scrollDist) {
|
|
141
|
+
const dZ = zoomSpeed * Math.exp(scrollDist / height);
|
|
142
|
+
// Inlined: transformScaleX(mouseRelX)
|
|
143
|
+
const transformedX = isNdc
|
|
144
|
+
? ((-1 + (mouseRelX / width) * 2) - xAlignmentTranslation) * (1.0 / xAspectRatioModeFactor)
|
|
145
|
+
: mouseRelX;
|
|
146
|
+
// Inlined: transformScaleY(mouseRelY)
|
|
147
|
+
const transformedY = isNdc
|
|
148
|
+
? ((1 - (mouseRelY / height) * 2) - yAlignmentTranslation) * (1.0 / yAspectRatioModeFactor)
|
|
149
|
+
: mouseRelY;
|
|
150
|
+
// Inlined: camera.scale(
|
|
151
|
+
// [isZoomX ? 1 / dZ : 1, isZoomY ? 1 / dZ : 1],
|
|
152
|
+
// [transformedX, transformedY],
|
|
153
|
+
// )
|
|
154
|
+
// — from camera-2d-simple/dist/camera-2d.esm.js
|
|
155
|
+
{
|
|
156
|
+
const d = [isZoomX ? 1 / dZ : 1, isZoomY ? 1 / dZ : 1];
|
|
157
|
+
const mousePos = [transformedX, transformedY];
|
|
158
|
+
// const isArray = Array.isArray(d); // always true here
|
|
159
|
+
let dx = d[0];
|
|
160
|
+
let dy = d[1];
|
|
161
|
+
if (!(dx <= 0 || dy <= 0 || (dx === 1 && dy === 1))) {
|
|
162
|
+
// Inlined: getScaling() => mat4.getScaling(scratch0, view).slice(0, 2)
|
|
163
|
+
const scaling = mat4.getScaling(scratch0, view).slice(0, 2);
|
|
164
|
+
const newXScale = scaling[0] * dx;
|
|
165
|
+
const newYScale = scaling[1] * dy;
|
|
166
|
+
dx =
|
|
167
|
+
Math.max(scaleXBounds[0], Math.min(newXScale, scaleXBounds[1])) /
|
|
168
|
+
scaling[0];
|
|
169
|
+
dy =
|
|
170
|
+
Math.max(scaleYBounds[0], Math.min(newYScale, scaleYBounds[1])) /
|
|
171
|
+
scaling[1];
|
|
172
|
+
if (!(dx === 1 && dy === 1)) {
|
|
173
|
+
scratch0[0] = dx;
|
|
174
|
+
scratch0[1] = dy;
|
|
175
|
+
scratch0[2] = 1;
|
|
176
|
+
const s = mat4.fromScaling(scratch1, scratch0);
|
|
177
|
+
const scaleCenter = mousePos ? [...mousePos, 0] : viewCenter;
|
|
178
|
+
const a = mat4.fromTranslation(scratch0, scaleCenter);
|
|
179
|
+
// Translate about the scale center (mouse position).
|
|
180
|
+
mat4.multiply(view, a, mat4.multiply(view, s, mat4.multiply(view, mat4.invert(scratch2, a), view)));
|
|
181
|
+
// Inlined: withProgrammaticChange wrapper around camera.scale.
|
|
182
|
+
isProgrammaticallyChanged = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
isInteractivelyChanged = true;
|
|
187
|
+
}
|
|
188
|
+
// Inlined: tick()'s tail — reset scroll delta and report change.
|
|
189
|
+
scrollDist = 0;
|
|
190
|
+
const isChanged = isInteractivelyChanged || isProgrammaticallyChanged;
|
|
191
|
+
isProgrammaticallyChanged = false;
|
|
192
|
+
return isChanged;
|
|
193
|
+
}
|
package/dist-tsc/viewport.d.ts
CHANGED
|
@@ -19,6 +19,97 @@ export type Bounds = {
|
|
|
19
19
|
yMin?: number;
|
|
20
20
|
yMax?: number;
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* Calculate the visible data range based on camera view and viewport parameters.
|
|
24
|
+
*
|
|
25
|
+
* Bounds are expressed in normalized data coordinates where 0.0 is the
|
|
26
|
+
* left/top edge of the data and 1.0 is the right/bottom edge.
|
|
27
|
+
*
|
|
28
|
+
* @param cameraMatrix - The current camera matrix, typically from the renderer's
|
|
29
|
+
* current state.
|
|
30
|
+
* @param viewportParams - Describes the canvas size, aspect-ratio handling, and
|
|
31
|
+
* margins.
|
|
32
|
+
* @returns The visible data range as `{ xMin, xMax, yMin, yMax }` in normalized
|
|
33
|
+
* data coordinates.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { getBounds } from "@pluot/core";
|
|
38
|
+
*
|
|
39
|
+
* const viewport = {
|
|
40
|
+
* width: 800,
|
|
41
|
+
* height: 600,
|
|
42
|
+
* aspectRatioMode: "Contain",
|
|
43
|
+
* aspectRatioAlignmentMode: "Center",
|
|
44
|
+
* margins: { marginTop: 10, marginRight: 10, marginBottom: 10, marginLeft: 10 },
|
|
45
|
+
* };
|
|
46
|
+
*
|
|
47
|
+
* // Identity camera matrix (full data range visible).
|
|
48
|
+
* const identity = new Float32Array([
|
|
49
|
+
* 1, 0, 0, 0,
|
|
50
|
+
* 0, 1, 0, 0,
|
|
51
|
+
* 0, 0, 1, 0,
|
|
52
|
+
* 0, 0, 0, 1,
|
|
53
|
+
* ]);
|
|
54
|
+
*
|
|
55
|
+
* const bounds = getBounds(identity, viewport);
|
|
56
|
+
* // bounds.xMin, bounds.xMax, bounds.yMin, bounds.yMax are all in [0, 1]
|
|
57
|
+
* // for the identity matrix with "Contain" mode.
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
22
60
|
export declare function getBounds(cameraMatrix: Float32Array, viewportParams: ViewportParams): Required<Bounds>;
|
|
61
|
+
/**
|
|
62
|
+
* Given data bounds, compute the corresponding camera matrix.
|
|
63
|
+
* Missing bound values are filled in from `prevCameraMatrix` so partial updates
|
|
64
|
+
* (e.g. panning only the X axis) work without resetting the other axis.
|
|
65
|
+
*
|
|
66
|
+
* Bounds are expressed in normalized data coordinates where 0.0 is the
|
|
67
|
+
* left/top edge of the data and 1.0 is the right/bottom edge.
|
|
68
|
+
*
|
|
69
|
+
* @param bounds - The desired visible data range. Any omitted fields are
|
|
70
|
+
* preserved from `prevCameraMatrix`.
|
|
71
|
+
* @param prevCameraMatrix - The current camera matrix, used to fill in any
|
|
72
|
+
* omitted bound values. Typically the matrix returned by a previous call or
|
|
73
|
+
* the renderer's current state.
|
|
74
|
+
* @param viewportParams - Describes the canvas size, aspect-ratio handling, and
|
|
75
|
+
* margins so the zoom level can be computed correctly.
|
|
76
|
+
* @returns A column-major 4×4 `Float32Array` suitable for passing directly to
|
|
77
|
+
* the renderer as the camera matrix.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* import { getCameraMatrixFromBounds } from "@pluot/core";
|
|
82
|
+
*
|
|
83
|
+
* const viewport = {
|
|
84
|
+
* width: 800,
|
|
85
|
+
* height: 600,
|
|
86
|
+
* aspectRatioMode: "Contain",
|
|
87
|
+
* aspectRatioAlignmentMode: "Center",
|
|
88
|
+
* margins: { marginTop: 10, marginRight: 10, marginBottom: 10, marginLeft: 10 },
|
|
89
|
+
* };
|
|
90
|
+
*
|
|
91
|
+
* // Identity camera matrix (full data range visible).
|
|
92
|
+
* const identity = new Float32Array([
|
|
93
|
+
* 1, 0, 0, 0,
|
|
94
|
+
* 0, 1, 0, 0,
|
|
95
|
+
* 0, 0, 1, 0,
|
|
96
|
+
* 0, 0, 0, 1,
|
|
97
|
+
* ]);
|
|
98
|
+
*
|
|
99
|
+
* // Zoom into the top-left quadrant of the data.
|
|
100
|
+
* const cameraMatrix = getCameraMatrixFromBounds(
|
|
101
|
+
* { xMin: 0.0, xMax: 0.5, yMin: 0.0, yMax: 0.5 },
|
|
102
|
+
* identity,
|
|
103
|
+
* viewport,
|
|
104
|
+
* );
|
|
105
|
+
*
|
|
106
|
+
* // Pan to shift only the X axis, keeping Y unchanged.
|
|
107
|
+
* const pannedMatrix = getCameraMatrixFromBounds(
|
|
108
|
+
* { xMin: 0.1, xMax: 0.6 },
|
|
109
|
+
* cameraMatrix,
|
|
110
|
+
* viewport,
|
|
111
|
+
* );
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
23
114
|
export declare function getCameraMatrixFromBounds(bounds: Bounds, prevCameraMatrix: Float32Array, viewportParams: ViewportParams): Float32Array;
|
|
24
115
|
//# sourceMappingURL=viewport.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"viewport.d.ts","sourceRoot":"","sources":["../src/viewport.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAC;AAC7D,MAAM,MAAM,wBAAwB,GAAG,QAAQ,GAAG,OAAO,GAAG,KAAK,CAAC;AAElE,MAAM,MAAM,OAAO,GAAG;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,eAAe,CAAC;IACjC,wBAAwB,EAAE,wBAAwB,CAAC;IACnD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,MAAM,GAAG;IAKnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;
|
|
1
|
+
{"version":3,"file":"viewport.d.ts","sourceRoot":"","sources":["../src/viewport.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAC;AAC7D,MAAM,MAAM,wBAAwB,GAAG,QAAQ,GAAG,OAAO,GAAG,KAAK,CAAC;AAElE,MAAM,MAAM,OAAO,GAAG;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,eAAe,CAAC;IACjC,wBAAwB,EAAE,wBAAwB,CAAC;IACnD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,MAAM,GAAG;IAKnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,SAAS,CAAC,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,GAAG,QAAQ,CAAC,MAAM,CAAC,CA4CtG;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoDG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,GAAG,YAAY,CAgEtI"}
|
package/dist-tsc/viewport.js
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Calculate the visible data range based on camera view and viewport parameters.
|
|
3
|
+
*
|
|
4
|
+
* Bounds are expressed in normalized data coordinates where 0.0 is the
|
|
5
|
+
* left/top edge of the data and 1.0 is the right/bottom edge.
|
|
6
|
+
*
|
|
7
|
+
* @param cameraMatrix - The current camera matrix, typically from the renderer's
|
|
8
|
+
* current state.
|
|
9
|
+
* @param viewportParams - Describes the canvas size, aspect-ratio handling, and
|
|
10
|
+
* margins.
|
|
11
|
+
* @returns The visible data range as `{ xMin, xMax, yMin, yMax }` in normalized
|
|
12
|
+
* data coordinates.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { getBounds } from "@pluot/core";
|
|
17
|
+
*
|
|
18
|
+
* const viewport = {
|
|
19
|
+
* width: 800,
|
|
20
|
+
* height: 600,
|
|
21
|
+
* aspectRatioMode: "Contain",
|
|
22
|
+
* aspectRatioAlignmentMode: "Center",
|
|
23
|
+
* margins: { marginTop: 10, marginRight: 10, marginBottom: 10, marginLeft: 10 },
|
|
24
|
+
* };
|
|
25
|
+
*
|
|
26
|
+
* // Identity camera matrix (full data range visible).
|
|
27
|
+
* const identity = new Float32Array([
|
|
28
|
+
* 1, 0, 0, 0,
|
|
29
|
+
* 0, 1, 0, 0,
|
|
30
|
+
* 0, 0, 1, 0,
|
|
31
|
+
* 0, 0, 0, 1,
|
|
32
|
+
* ]);
|
|
33
|
+
*
|
|
34
|
+
* const bounds = getBounds(identity, viewport);
|
|
35
|
+
* // bounds.xMin, bounds.xMax, bounds.yMin, bounds.yMax are all in [0, 1]
|
|
36
|
+
* // for the identity matrix with "Contain" mode.
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
2
39
|
export function getBounds(cameraMatrix, viewportParams) {
|
|
3
40
|
const zoomX = cameraMatrix[0];
|
|
4
41
|
const zoomY = cameraMatrix[5];
|
|
@@ -43,8 +80,59 @@ export function getBounds(cameraMatrix, viewportParams) {
|
|
|
43
80
|
const yMax = ((-translateY + 1.0 + yAdj + yAlignTranslation) / zoomY + 1.0) / 2.0;
|
|
44
81
|
return { xMin, xMax, yMin, yMax };
|
|
45
82
|
}
|
|
46
|
-
|
|
47
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Given data bounds, compute the corresponding camera matrix.
|
|
85
|
+
* Missing bound values are filled in from `prevCameraMatrix` so partial updates
|
|
86
|
+
* (e.g. panning only the X axis) work without resetting the other axis.
|
|
87
|
+
*
|
|
88
|
+
* Bounds are expressed in normalized data coordinates where 0.0 is the
|
|
89
|
+
* left/top edge of the data and 1.0 is the right/bottom edge.
|
|
90
|
+
*
|
|
91
|
+
* @param bounds - The desired visible data range. Any omitted fields are
|
|
92
|
+
* preserved from `prevCameraMatrix`.
|
|
93
|
+
* @param prevCameraMatrix - The current camera matrix, used to fill in any
|
|
94
|
+
* omitted bound values. Typically the matrix returned by a previous call or
|
|
95
|
+
* the renderer's current state.
|
|
96
|
+
* @param viewportParams - Describes the canvas size, aspect-ratio handling, and
|
|
97
|
+
* margins so the zoom level can be computed correctly.
|
|
98
|
+
* @returns A column-major 4×4 `Float32Array` suitable for passing directly to
|
|
99
|
+
* the renderer as the camera matrix.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* import { getCameraMatrixFromBounds } from "@pluot/core";
|
|
104
|
+
*
|
|
105
|
+
* const viewport = {
|
|
106
|
+
* width: 800,
|
|
107
|
+
* height: 600,
|
|
108
|
+
* aspectRatioMode: "Contain",
|
|
109
|
+
* aspectRatioAlignmentMode: "Center",
|
|
110
|
+
* margins: { marginTop: 10, marginRight: 10, marginBottom: 10, marginLeft: 10 },
|
|
111
|
+
* };
|
|
112
|
+
*
|
|
113
|
+
* // Identity camera matrix (full data range visible).
|
|
114
|
+
* const identity = new Float32Array([
|
|
115
|
+
* 1, 0, 0, 0,
|
|
116
|
+
* 0, 1, 0, 0,
|
|
117
|
+
* 0, 0, 1, 0,
|
|
118
|
+
* 0, 0, 0, 1,
|
|
119
|
+
* ]);
|
|
120
|
+
*
|
|
121
|
+
* // Zoom into the top-left quadrant of the data.
|
|
122
|
+
* const cameraMatrix = getCameraMatrixFromBounds(
|
|
123
|
+
* { xMin: 0.0, xMax: 0.5, yMin: 0.0, yMax: 0.5 },
|
|
124
|
+
* identity,
|
|
125
|
+
* viewport,
|
|
126
|
+
* );
|
|
127
|
+
*
|
|
128
|
+
* // Pan to shift only the X axis, keeping Y unchanged.
|
|
129
|
+
* const pannedMatrix = getCameraMatrixFromBounds(
|
|
130
|
+
* { xMin: 0.1, xMax: 0.6 },
|
|
131
|
+
* cameraMatrix,
|
|
132
|
+
* viewport,
|
|
133
|
+
* );
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
48
136
|
export function getCameraMatrixFromBounds(bounds, prevCameraMatrix, viewportParams) {
|
|
49
137
|
// Fill in missing bounds from the previous camera matrix.
|
|
50
138
|
const currentBounds = getBounds(prevCameraMatrix, viewportParams);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"viewport.test.d.ts","sourceRoot":"","sources":["../src/viewport.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getBounds, getCameraMatrixFromBounds } from './viewport.js';
|
|
3
|
+
function identityCamera() {
|
|
4
|
+
return new Float32Array([
|
|
5
|
+
1, 0, 0, 0,
|
|
6
|
+
0, 1, 0, 0,
|
|
7
|
+
0, 0, 1, 0,
|
|
8
|
+
0, 0, 0, 1,
|
|
9
|
+
]);
|
|
10
|
+
}
|
|
11
|
+
function zoomCamera(zoom) {
|
|
12
|
+
return new Float32Array([
|
|
13
|
+
zoom, 0, 0, 0,
|
|
14
|
+
0, zoom, 0, 0,
|
|
15
|
+
0, 0, 1, 0,
|
|
16
|
+
0, 0, 0, 1,
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
function zoomAndTranslateCamera(zoom, tx, ty) {
|
|
20
|
+
return new Float32Array([
|
|
21
|
+
zoom, 0, 0, 0,
|
|
22
|
+
0, zoom, 0, 0,
|
|
23
|
+
0, 0, 1, 0,
|
|
24
|
+
tx, ty, 0, 1,
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
function makeViewport(width, height, aspectRatioMode, aspectRatioAlignmentMode = 'Center') {
|
|
28
|
+
return { width, height, aspectRatioMode, aspectRatioAlignmentMode };
|
|
29
|
+
}
|
|
30
|
+
function expectArrayCloseTo(actual, expected, numDigits = 5) {
|
|
31
|
+
expect(actual.length).toBe(expected.length);
|
|
32
|
+
for (let i = 0; i < actual.length; i++) {
|
|
33
|
+
expect(actual[i]).toBeCloseTo(expected[i], numDigits);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// =================== getBounds ===================
|
|
37
|
+
describe('getBounds', () => {
|
|
38
|
+
it('identity camera, square, Ignore → full [0, 1] range', () => {
|
|
39
|
+
const b = getBounds(identityCamera(), makeViewport(100, 100, 'Ignore'));
|
|
40
|
+
expect(b.xMin).toBeCloseTo(0);
|
|
41
|
+
expect(b.xMax).toBeCloseTo(1);
|
|
42
|
+
expect(b.yMin).toBeCloseTo(0);
|
|
43
|
+
expect(b.yMax).toBeCloseTo(1);
|
|
44
|
+
});
|
|
45
|
+
it('2x zoom, square, Ignore → [0.25, 0.75]', () => {
|
|
46
|
+
const b = getBounds(zoomCamera(2), makeViewport(100, 100, 'Ignore'));
|
|
47
|
+
expect(b.xMin).toBeCloseTo(0.25);
|
|
48
|
+
expect(b.xMax).toBeCloseTo(0.75);
|
|
49
|
+
expect(b.yMin).toBeCloseTo(0.25);
|
|
50
|
+
expect(b.yMax).toBeCloseTo(0.75);
|
|
51
|
+
});
|
|
52
|
+
it('0.5x zoom (zoomed out 2x), square, Ignore → [-0.5, 1.5]', () => {
|
|
53
|
+
const b = getBounds(zoomCamera(0.5), makeViewport(100, 100, 'Ignore'));
|
|
54
|
+
expect(b.xMin).toBeCloseTo(-0.5);
|
|
55
|
+
expect(b.xMax).toBeCloseTo(1.5);
|
|
56
|
+
expect(b.yMin).toBeCloseTo(-0.5);
|
|
57
|
+
expect(b.yMax).toBeCloseTo(1.5);
|
|
58
|
+
});
|
|
59
|
+
it('wide (200×100), Contain → x extends to [-0.5, 1.5], y stays [0, 1]', () => {
|
|
60
|
+
const b = getBounds(identityCamera(), makeViewport(200, 100, 'Contain'));
|
|
61
|
+
expect(b.xMin).toBeCloseTo(-0.5);
|
|
62
|
+
expect(b.xMax).toBeCloseTo(1.5);
|
|
63
|
+
expect(b.yMin).toBeCloseTo(0);
|
|
64
|
+
expect(b.yMax).toBeCloseTo(1);
|
|
65
|
+
});
|
|
66
|
+
it('tall (100×200), Contain → x stays [0, 1], y extends to [-0.5, 1.5]', () => {
|
|
67
|
+
const b = getBounds(identityCamera(), makeViewport(100, 200, 'Contain'));
|
|
68
|
+
expect(b.xMin).toBeCloseTo(0);
|
|
69
|
+
expect(b.xMax).toBeCloseTo(1);
|
|
70
|
+
expect(b.yMin).toBeCloseTo(-0.5);
|
|
71
|
+
expect(b.yMax).toBeCloseTo(1.5);
|
|
72
|
+
});
|
|
73
|
+
it('wide (200×100), Cover → x stays [0, 1], y shrinks to [0.25, 0.75]', () => {
|
|
74
|
+
const b = getBounds(identityCamera(), makeViewport(200, 100, 'Cover'));
|
|
75
|
+
expect(b.xMin).toBeCloseTo(0);
|
|
76
|
+
expect(b.xMax).toBeCloseTo(1);
|
|
77
|
+
expect(b.yMin).toBeCloseTo(0.25);
|
|
78
|
+
expect(b.yMax).toBeCloseTo(0.75);
|
|
79
|
+
});
|
|
80
|
+
it('with margins, square (100×100), Ignore → [0, 1] × [0, 1]', () => {
|
|
81
|
+
const viewport = {
|
|
82
|
+
...makeViewport(100, 100, 'Ignore'),
|
|
83
|
+
margins: { marginLeft: 20, marginBottom: 20 },
|
|
84
|
+
};
|
|
85
|
+
const b = getBounds(identityCamera(), viewport);
|
|
86
|
+
expect(b.xMin).toBeCloseTo(0);
|
|
87
|
+
expect(b.xMax).toBeCloseTo(1);
|
|
88
|
+
expect(b.yMin).toBeCloseTo(0);
|
|
89
|
+
expect(b.yMax).toBeCloseTo(1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// =================== getCameraMatrixFromBounds ===================
|
|
93
|
+
describe('getCameraMatrixFromBounds', () => {
|
|
94
|
+
it('full [0, 1] range → identity camera', () => {
|
|
95
|
+
const camera = getCameraMatrixFromBounds({ xMin: 0, xMax: 1, yMin: 0, yMax: 1 }, identityCamera(), makeViewport(100, 100, 'Ignore'));
|
|
96
|
+
expectArrayCloseTo(camera, identityCamera());
|
|
97
|
+
});
|
|
98
|
+
it('[0.25, 0.75] × [0.25, 0.75] → 2x zoom camera', () => {
|
|
99
|
+
const camera = getCameraMatrixFromBounds({ xMin: 0.25, xMax: 0.75, yMin: 0.25, yMax: 0.75 }, identityCamera(), makeViewport(100, 100, 'Ignore'));
|
|
100
|
+
expectArrayCloseTo(camera, zoomCamera(2));
|
|
101
|
+
});
|
|
102
|
+
it('[-0.5, 1.5] × [-0.5, 1.5] → 0.5x zoom (zoomed out 2x)', () => {
|
|
103
|
+
const camera = getCameraMatrixFromBounds({ xMin: -0.5, xMax: 1.5, yMin: -0.5, yMax: 1.5 }, identityCamera(), makeViewport(100, 100, 'Ignore'));
|
|
104
|
+
expectArrayCloseTo(camera, zoomCamera(0.5));
|
|
105
|
+
});
|
|
106
|
+
it('x-offset bounds → zoom=1, translateX=0.5, translateY=0', () => {
|
|
107
|
+
const camera = getCameraMatrixFromBounds({ xMin: -0.25, xMax: 0.75, yMin: 0, yMax: 1 }, identityCamera(), makeViewport(100, 100, 'Ignore'));
|
|
108
|
+
expectArrayCloseTo(camera, zoomAndTranslateCamera(1, 0.5, 0));
|
|
109
|
+
});
|
|
110
|
+
it('zoom + translation bounds → zoom=2, translateX=0.5, translateY=0.25', () => {
|
|
111
|
+
const camera = getCameraMatrixFromBounds({ xMin: 0.125, xMax: 0.625, yMin: 0.1875, yMax: 0.6875 }, identityCamera(), makeViewport(100, 100, 'Ignore'));
|
|
112
|
+
expectArrayCloseTo(camera, zoomAndTranslateCamera(2, 0.5, 0.25));
|
|
113
|
+
});
|
|
114
|
+
it('wide Contain bounds [-0.5, 1.5] × [0, 1] → identity camera', () => {
|
|
115
|
+
const camera = getCameraMatrixFromBounds({ xMin: -0.5, xMax: 1.5, yMin: 0, yMax: 1 }, identityCamera(), makeViewport(200, 100, 'Contain'));
|
|
116
|
+
expectArrayCloseTo(camera, identityCamera());
|
|
117
|
+
});
|
|
118
|
+
it('tall Contain bounds [0, 1] × [-0.5, 1.5] → identity camera', () => {
|
|
119
|
+
const camera = getCameraMatrixFromBounds({ xMin: 0, xMax: 1, yMin: -0.5, yMax: 1.5 }, identityCamera(), makeViewport(100, 200, 'Contain'));
|
|
120
|
+
expectArrayCloseTo(camera, identityCamera());
|
|
121
|
+
});
|
|
122
|
+
it('wide Cover bounds [0, 1] × [0.25, 0.75] → identity camera', () => {
|
|
123
|
+
const camera = getCameraMatrixFromBounds({ xMin: 0, xMax: 1, yMin: 0.25, yMax: 0.75 }, identityCamera(), makeViewport(200, 100, 'Cover'));
|
|
124
|
+
expectArrayCloseTo(camera, identityCamera());
|
|
125
|
+
});
|
|
126
|
+
it('asymmetric ranges, Ignore → independent x/y zoom', () => {
|
|
127
|
+
const camera = getCameraMatrixFromBounds({ xMin: 0, xMax: 0.5, yMin: 0, yMax: 1 }, identityCamera(), makeViewport(100, 100, 'Ignore'));
|
|
128
|
+
expectArrayCloseTo(camera, new Float32Array([
|
|
129
|
+
2, 0, 0, 0,
|
|
130
|
+
0, 1, 0, 0,
|
|
131
|
+
0, 0, 1, 0,
|
|
132
|
+
1, 0, 0, 1,
|
|
133
|
+
]));
|
|
134
|
+
});
|
|
135
|
+
it('partial bounds (x-axis only) preserves y from prevCameraMatrix', () => {
|
|
136
|
+
const prevCamera = zoomAndTranslateCamera(2, 0.5, 0.25);
|
|
137
|
+
const viewport = makeViewport(100, 100, 'Ignore');
|
|
138
|
+
const prevBounds = getBounds(prevCamera, viewport);
|
|
139
|
+
const camera = getCameraMatrixFromBounds({ xMin: 0, xMax: 1 }, prevCamera, viewport);
|
|
140
|
+
const newBounds = getBounds(camera, viewport);
|
|
141
|
+
expect(newBounds.xMin).toBeCloseTo(0);
|
|
142
|
+
expect(newBounds.xMax).toBeCloseTo(1);
|
|
143
|
+
expect(newBounds.yMin).toBeCloseTo(prevBounds.yMin);
|
|
144
|
+
expect(newBounds.yMax).toBeCloseTo(prevBounds.yMax);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
// =================== getBounds / getCameraMatrixFromBounds roundtrip ===================
|
|
148
|
+
describe('getBounds / getCameraMatrixFromBounds roundtrip', () => {
|
|
149
|
+
it('identity camera → bounds → identity camera', () => {
|
|
150
|
+
const viewport = makeViewport(100, 100, 'Ignore');
|
|
151
|
+
const bounds = getBounds(identityCamera(), viewport);
|
|
152
|
+
const camera = getCameraMatrixFromBounds(bounds, identityCamera(), viewport);
|
|
153
|
+
expectArrayCloseTo(camera, identityCamera());
|
|
154
|
+
});
|
|
155
|
+
it('2x zoom camera → bounds → 2x zoom camera', () => {
|
|
156
|
+
const viewport = makeViewport(100, 100, 'Ignore');
|
|
157
|
+
const camera0 = zoomCamera(2);
|
|
158
|
+
const bounds = getBounds(camera0, viewport);
|
|
159
|
+
const camera1 = getCameraMatrixFromBounds(bounds, identityCamera(), viewport);
|
|
160
|
+
expectArrayCloseTo(camera1, camera0);
|
|
161
|
+
});
|
|
162
|
+
it('wide Contain viewport, identity camera → bounds → identity camera', () => {
|
|
163
|
+
const viewport = makeViewport(200, 100, 'Contain');
|
|
164
|
+
const bounds = getBounds(identityCamera(), viewport);
|
|
165
|
+
const camera = getCameraMatrixFromBounds(bounds, identityCamera(), viewport);
|
|
166
|
+
expectArrayCloseTo(camera, identityCamera());
|
|
167
|
+
});
|
|
168
|
+
it('zoom + translation → bounds → same camera', () => {
|
|
169
|
+
const viewport = makeViewport(100, 100, 'Ignore');
|
|
170
|
+
const camera0 = zoomAndTranslateCamera(2, 0.5, 0.25);
|
|
171
|
+
const bounds = getBounds(camera0, viewport);
|
|
172
|
+
const camera1 = getCameraMatrixFromBounds(bounds, identityCamera(), viewport);
|
|
173
|
+
expectArrayCloseTo(camera1, camera0);
|
|
174
|
+
});
|
|
175
|
+
it('with margins, identity camera → bounds → identity camera', () => {
|
|
176
|
+
const viewport = {
|
|
177
|
+
...makeViewport(100, 100, 'Ignore'),
|
|
178
|
+
margins: { marginLeft: 20, marginBottom: 20 },
|
|
179
|
+
};
|
|
180
|
+
const bounds = getBounds(identityCamera(), viewport);
|
|
181
|
+
const camera = getCameraMatrixFromBounds(bounds, identityCamera(), viewport);
|
|
182
|
+
expectArrayCloseTo(camera, identityCamera());
|
|
183
|
+
});
|
|
184
|
+
});
|
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pluot/core",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.1.
|
|
5
|
-
"description": "",
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"description": "Bindings to the Pluot Rust crate for data visualization",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
|
+
"author": "Mark Keller",
|
|
8
|
+
"homepage": "https://pluot.dev",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/keller-mark/pluot.git"
|
|
12
|
+
},
|
|
7
13
|
"type": "module",
|
|
8
14
|
"main": "dist/index.js",
|
|
9
15
|
"files": [
|