Repository: flekschas/regl-scatterplot (js)

Issue:
Disable zoom and pan
Is there a way to disable zoom and pan? Can't find this in the documentation or the source code.

Pull request:
fix: handle camera fixing better
This PR introduces a better handling of the camera's fixed state and fixes an issue along the way where the fixed state was unset by the lasso end event.

## Description

> What was changed in this pull request?

I added a property called `cameraIsFixed` to make it easier to control the camera's fixed state.

> Why is it necessary?

Fixes #94

And because I noticed that the camera fixed state was unset upon lasso end

## Checklist

- [x] Provided a concise title as a [semantic commit message](https://www.conventionalcommits.org) (e.g. "fix: correctly handle undefined properties")
- [x] `CHANGELOG.md` updated
- [x] Tests added or updated
- [x] Documentation in `README.md` added or updated
- [ ] Example(s) added or updated
- [ ] Screenshot, gif, or video attached for visual changes


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23ad6dd..3ddb14e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 1.11.2
+
+- Fix: handle camera fixing better by adding a dedicated prop called `cameraIsFixed`. Previously, the lasso end interaction would unset the camera fixing. ([#94](https://github.com/flekschas/regl-scatterplot/issues/94))
+
 ## 1.11.1
 
 - Fix: ensure that the drawing order of points cannot be manipulated via `scatterplot.filter()` ([#197](https://github.com/flekschas/regl-scatterplot/issues/197))
diff --git a/README.md b/README.md
index 7d5dbec..15e9a63 100644
--- a/README.md
+++ b/README.md
@@ -756,7 +756,8 @@ can be read and written via [`scatterplot.get()`](#scatterplot.get) and [`scatte
 | cameraTarget                          | tuple                                        | `[0, 0]`                            |                                                                 | `true`   | `false`     |
 | cameraDistance                        | float                                        | `1`                                 | > 0                                                             | `true`   | `false`     |
 | cameraRotation                        | float                                        | `0`                                 |                                                                 | `true`   | `false`     |
-| cameraView                            | Float32Array                                 | `[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1`] |                                                                 | `true`   | `false`     |
+| cameraView                            | Float32Array                                 | `[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]` |                                                                 | `true`   | `false`     |
+| cameraIsFixed                         | boolean                                      | `false`                             |                                                                 | `true`   | `false`     |
 | colorBy                               | string                                       | `null`                              | See [data encoding](#property-by)                               | `true`   | `true`      |
 | sizeBy                                | string                                       | `null`                              | See [data encoding](#property-by)                               | `true`   | `true`      |
 | opacityBy                             | string                                       | `null`                              | See [data encoding](#property-by)                               | `true`   | `true`      |
diff --git a/src/constants.js b/src/constants.js
index 8c22444..5f004f5 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -166,6 +166,7 @@ export const Z_NAMES = new Set(['z', 'valueZ', 'valueA', 'value1', 'category']);
 export const W_NAMES = new Set(['w', 'valueW', 'valueB', 'value2', 'value']);
 export const DEFAULT_IMAGE_LOAD_TIMEOUT = 15000;
 export const DEFAULT_SPATIAL_INDEX_USE_WORKER = undefined;
+export const DEFAULT_CAMERA_IS_FIXED = false;
 
 // Error messages
 export const ERROR_POINTS_NOT_DRAWN = 'Points have not been drawn';
diff --git a/src/index.js b/src/index.js
index 0765f41..05852b0 100644
--- a/src/index.js
+++ b/src/index.js
@@ -38,6 +38,7 @@ import {
   DEFAULT_ANNOTATION_LINE_COLOR,
   DEFAULT_ANNOTATION_LINE_WIDTH,
   DEFAULT_BACKGROUND_IMAGE,
+  DEFAULT_CAMERA_IS_FIXED,
   DEFAULT_COLOR_ACTIVE,
   DEFAULT_COLOR_BG,
   DEFAULT_COLOR_BY,
@@ -287,6 +288,7 @@ const createScatterplot = (
     annotationLineColor = DEFAULT_ANNOTATION_LINE_COLOR,
     annotationLineWidth = DEFAULT_ANNOTATION_LINE_WIDTH,
     annotationHVLineLimit = DEFAULT_ANNOTATION_HVLINE_LIMIT,
+    cameraIsFixed = DEFAULT_CAMERA_IS_FIXED,
   } = initialProperties;
 
   let currentWidth = width === AUTO ? 1 : width;
@@ -888,7 +890,7 @@ const createScatterplot = (
   };
 
   const lassoEnd = (lassoPoints, lassoPointsFlat, { merge = false } = {}) => {
-    camera.config({ isFixed: false });
+    camera.config({ isFixed: cameraIsFixed });
     lassoPointsCurr = [...lassoPoints];
     const pointsInLasso = findPointsInLasso(lassoPointsFlat);
     select(pointsInLasso, { merge });
@@ -2722,7 +2724,7 @@ const createScatterplot = (
           'transitionEnd',
           () => {
             resolve();
-            camera.config({ isFixed: false });
+            camera.config({ isFixed: cameraIsFixed });
           },
           1,
         );
@@ -2921,6 +2923,11 @@ const createScatterplot = (
     }
   };
 
+  const setCameraIsFixed = (isFixed) => {
+    cameraIsFixed = Boolean(isFixed);
+    camera.config({ isFixed: cameraIsFixed });
+  };
+
   const setLassoColor = (newLassoColor) => {
     if (!newLassoColor) {
       return;
@@ -3296,6 +3303,10 @@ const createScatterplot = (
       return camera.view;
     }
 
+    if (property === 'cameraIsFixed') {
+      return cameraIsFixed;
+    }
+
     if (property === 'canvas') {
       return canvas;
     }
@@ -3616,6 +3627,10 @@ const createScatterplot = (
       setCameraView(properties.cameraView);
     }
 
+    if (properties.cameraIsFixed !== undefined) {
+      setCameraIsFixed(properties.cameraIsFixed);
+    }
+
     if (properties.colorBy !== undefined) {
       setColorBy(properties.colorBy);
     }
@@ -3873,6 +3888,7 @@ const createScatterplot = (
   const initCamera = () => {
     if (!camera) {
       camera = createDom2dCamera(canvas, {
+        isFixed: cameraIsFixed,
         isPanInverted: [false, true],
         defaultMouseDownMoveAction:
           mouseMode === MOUSE_MODE_ROTATE ? 'rotate' : 'pan',
@@ -4083,7 +4099,7 @@ const createScatterplot = (
   };
 
   const cancelFrameListener = renderer.onFrame(() => {
-    // Update camera: this needs to happen on every
+    // Update camera: this needs to happen on every frame
     isViewChanged = camera.tick();
 
     if (!((isPointsDrawn || isAnnotationsDrawn) && (draw || isTransitioning))) {
diff --git a/src/types.d.ts b/src/types.d.ts
index da49586..81807a0 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -164,6 +164,7 @@ interface BaseOptions {
   xScale: null | Scale;
   yScale: null | Scale;
   pointScaleMode: PointScaleMode;
+  cameraIsFixed: boolean;
 }
 
 // biome-ignore lint/style/useNamingConvention: KDBush is a library name


Test patch:
diff --git a/tests/constructor.test.js b/tests/constructor.test.js
index b39d004..6510362 100644
--- a/tests/constructor.test.js
+++ b/tests/constructor.test.js
@@ -10,6 +10,7 @@ import createScatterplot, {
 } from '../src';
 
 import {
+  DEFAULT_CAMERA_IS_FIXED,
   DEFAULT_COLOR_NORMAL,
   DEFAULT_COLOR_ACTIVE,
   DEFAULT_COLOR_HOVER,
@@ -73,6 +74,7 @@ test('createScatterplot()', () => {
   expect(scatterplot.get('opacityInactiveScale')).toBe(DEFAULT_OPACITY_INACTIVE_SCALE);
   expect(scatterplot.get('width')).toBe(DEFAULT_WIDTH);
   expect(scatterplot.get('height')).toBe(DEFAULT_HEIGHT);
+  expect(scatterplot.get('cameraIsFixed')).toBe(DEFAULT_CAMERA_IS_FIXED);
 
   scatterplot.destroy();
 });
diff --git a/tests/get-set.test.js b/tests/get-set.test.js
index 0cf6073..fb27a44 100644
--- a/tests/get-set.test.js
+++ b/tests/get-set.test.js
@@ -1,4 +1,5 @@
 import '@babel/polyfill';
+import { nextAnimationFrame } from '@flekschas/utils';
 import { assert, expect, test } from 'vitest';
 
 import { version } from '../package.json';
@@ -451,7 +452,7 @@ test(
     );
 
     expect(scatterplot.get('pointConnectionOpacityActive')).toBe(
-      scatterplot.get('pointConnectionOpacityActive'),
+      DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE,
     );
 
     scatterplot.set({
@@ -630,3 +631,63 @@ test(
     scatterplot.destroy();
   }
 );
+
+test('set({ cameraIsFixed })', async () => {
+  const canvas = createCanvas();
+  const scatterplot = createScatterplot({ canvas });
+
+  await scatterplot.draw([
+    [-1, 1],
+    [1, 1],
+    [0, 0],
+    [-1, -1],
+    [1, -1],
+  ]);
+
+  canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
+
+  await nextAnimationFrame();
+
+  console.log('1. camera distance', scatterplot.get('camera').distance[0]);
+
+  // We expect the distance to be less than one because we zoomed into the plot
+  // via wheeling
+  expect(scatterplot.get('camera').distance[0]).toBeLessThan(1);
+
+  await scatterplot.zoomToOrigin();
+
+  expect(scatterplot.get('camera').distance[0]).toBe(1);
+
+  scatterplot.set({ cameraIsFixed: true });
+  expect(scatterplot.get('cameraIsFixed')).toBe(true);
+
+  canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
+
+  await nextAnimationFrame();
+
+  // We expect the distance to be one because we fixed the camera
+  expect(scatterplot.get('camera').distance[0]).toBe(1);
+
+  scatterplot.set({ cameraIsFixed: false });
+  expect(scatterplot.get('cameraIsFixed')).toBe(false);
+
+  canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }));
+
+  await nextAnimationFrame();
+
+  // We expect the distance to be less than one because we unfixed the camera
+  expect(scatterplot.get('camera').distance[0]).toBeLessThan(1);
+
+  await scatterplot.zoomToOrigin();
+  expect(scatterplot.get('camera').distance[0]).toBe(1);
+
+  scatterplot.set({ cameraIsFixed: true });
+  await scatterplot.zoomToPoints([2]);
+
+  // Even though the camera is fixed, programmatic zooming still works. Only
+  // mouse wheel interactions are prevented
+  expect(scatterplot.get('cameraIsFixed')).toBe(true);
+  expect(scatterplot.get('camera').distance[0]).toBeLessThan(1);
+
+  scatterplot.destroy();
+});