@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,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
+ }
@@ -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;AAGF,wBAAgB,SAAS,CAAC,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,GAAG,QAAQ,CAAC,MAAM,CAAC,CA4CtG;AAID,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,GAAG,YAAY,CAgEtI"}
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"}
@@ -1,4 +1,41 @@
1
- // Calculate the visible data range based on camera view and viewport parameters.
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
- // Given data bounds, compute the corresponding camera matrix.
47
- // Missing bound values are filled in from prevCameraMatrix.
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=viewport.test.d.ts.map
@@ -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.0",
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": [