Repository: chartjs/chartjs-plugin-zoom
Pull request #708 (2022-11-23 23:48:20)

Handling zoom edge cases (#690)
This PR includes two commits fixing the following edge cases (see #690 for details and demos):

* unexpected `isZoomedOrPanned()` true after `resetZoom` if data changed while zoomed
* jitter on zoom-in with fully constrained limits (like a "locked" axis)
* zoom out against a limit vanishes to a no-op as the focal point approaches the other side of the chart

One remaining concern is that this can affect zooming out from category scales in a potentially unintuitive way; further adjustments may be possible once we decide what the desired behavior is.

Thank you; let me know if you'd like any edits!


Patch:
diff --git a/src/core.js b/src/core.js
index 2011676..77bd94c 100644
--- a/src/core.js
+++ b/src/core.js
@@ -199,6 +199,7 @@ export function pan(chart, delta, enabledScales, transition = 'none') {
 
 export function getInitialScaleBounds(chart) {
   const state = getState(chart);
+  storeOriginalScaleLimits(chart, state);
   const scaleBounds = {};
   for (const scaleId of Object.keys(chart.scales)) {
     const {min, max} = state.originalScaleLimits[scaleId] || {min: {}, max: {}};
diff --git a/src/scale.types.js b/src/scale.types.js
index 18f68cf..0d83ebf 100644
--- a/src/scale.types.js
+++ b/src/scale.types.js
@@ -47,24 +47,17 @@ export function updateRange(scale, {min, max}, limits, zoom = false) {
   const minLimit = getLimit(state, scale, scaleLimits, 'min', -Infinity);
   const maxLimit = getLimit(state, scale, scaleLimits, 'max', Infinity);
 
-  const cmin = Math.max(min, minLimit);
-  const cmax = Math.min(max, maxLimit);
-  const range = zoom ? Math.max(cmax - cmin, minRange) : scale.max - scale.min;
-  if (cmax - cmin !== range) {
-    if (minLimit > cmax - range) {
-      min = cmin;
-      max = cmin + range;
-    } else if (maxLimit < cmin + range) {
-      max = cmax;
-      min = cmax - range;
-    } else {
-      const offset = (range - cmax + cmin) / 2;
-      min = cmin - offset;
-      max = cmax + offset;
-    }
-  } else {
-    min = cmin;
-    max = cmax;
+  const range = zoom ? Math.max(max - min, minRange) : scale.max - scale.min;
+  const offset = (range - max + min) / 2;
+  min -= offset;
+  max += offset;
+
+  if (min < minLimit) {
+    min = minLimit;
+    max = Math.min(minLimit + range, maxLimit);
+  } else if (max > maxLimit) {
+    max = maxLimit;
+    min = Math.max(maxLimit - range, minLimit);
   }
   scaleOpts.min = min;
   scaleOpts.max = max;


Test patch:
diff --git a/test/specs/api.spec.js b/test/specs/api.spec.js
index b11ef5e..181bf47 100644
--- a/test/specs/api.spec.js
+++ b/test/specs/api.spec.js
@@ -91,6 +91,107 @@ describe('api', function() {
       expect(chart.scales.y.min).toBe(0);
       expect(chart.scales.y.max).toBe(100);
     });
+
+    it('should honor fitted scale updates on reset', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data: {
+          datasets: [
+            {
+              data: [
+                {x: 0, y: 0},
+                {x: 100, y: 100}
+              ]
+            }
+          ]
+        }
+      });
+
+      chart.zoom(1.5);
+      chart.data.datasets[0].data[0].x = -100;
+      chart.update();
+      chart.resetZoom();
+      expect(chart.scales.x.min).toBe(-100);
+      expect(chart.scales.x.max).toBe(100);
+    });
+
+    it('should no-op with fully constrained limits', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        options: {
+          scales: {
+            x: {
+              min: 0,
+              max: 100
+            },
+            y: {
+              min: 0,
+              max: 100
+            }
+          },
+          plugins: {
+            zoom: {
+              limits: {
+                x: {
+                  min: 0,
+                  max: 100,
+                  minRange: 100
+                }
+              }
+            }
+          }
+        }
+      });
+
+      chart.zoom(1.5);
+      expect(chart.scales.x.min).toBe(0);
+      expect(chart.scales.x.max).toBe(100);
+    });
+
+    it('should honor zoom changes against a limit', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        options: {
+          scales: {
+            x: {
+              min: 0,
+              max: 100
+            },
+            y: {
+              min: 0,
+              max: 100
+            }
+          },
+          plugins: {
+            zoom: {
+              limits: {
+                x: {
+                  min: 0,
+                  max: 100
+                }
+              }
+            }
+          }
+        }
+      });
+      chart.zoom({
+        x: 1.99,
+        focalPoint: {
+          x: chart.scales.x.getPixelForValue(0)
+        }
+      });
+      expect(chart.scales.x.min).toBe(0);
+      expect(chart.scales.x.max).toBe(1);
+
+      chart.zoom({
+        x: -98,
+        focalPoint: {
+          x: chart.scales.x.getPixelForValue(1)
+        }
+      });
+      expect(chart.scales.x.min).toBe(0);
+      expect(chart.scales.x.max).toBe(100);
+    });
   });
 
   describe('getInitialScaleBounds', function() {
@@ -123,6 +224,35 @@ describe('api', function() {
       expect(chart.getInitialScaleBounds().y.min).toBe(0);
       expect(chart.getInitialScaleBounds().y.max).toBe(100);
     });
+
+    it('should provide updated scale bounds upon data update', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data: {
+          datasets: [
+            {
+              data: [
+                {x: 0, y: 0},
+                {x: 100, y: 100}
+              ]
+            }
+          ]
+        }
+      });
+
+      expect(chart.getInitialScaleBounds().x.min).toBe(0);
+      expect(chart.getInitialScaleBounds().x.max).toBe(100);
+      expect(chart.getInitialScaleBounds().y.min).toBe(0);
+      expect(chart.getInitialScaleBounds().y.max).toBe(100);
+
+      chart.data.datasets[0].data[0].x = -100;
+      chart.update();
+
+      expect(chart.getInitialScaleBounds().x.min).toBe(-100);
+      expect(chart.getInitialScaleBounds().x.max).toBe(100);
+      expect(chart.getInitialScaleBounds().y.min).toBe(0);
+      expect(chart.getInitialScaleBounds().y.max).toBe(100);
+    });
   });
 
   describe('isZoomedOrPanned', function() {
@@ -186,5 +316,30 @@ describe('api', function() {
       chart.resetZoom();
       expect(chart.isZoomedOrPanned()).toBe(false);
     });
+
+    it('should work with updated data and fitted scales', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data: {
+          datasets: [
+            {
+              data: [
+                {x: 0, y: 0},
+                {x: 100, y: 100}
+              ]
+            }
+          ]
+        }
+      });
+
+      // This sequence of operations captures a previous bug.
+      chart.zoom(1.5);
+      chart.data.datasets[0].data[0].x = -100;
+      chart.update();
+      chart.resetZoom();
+      expect(chart.scales.x.min).toBe(-100);
+      expect(chart.scales.x.max).toBe(100);
+      expect(chart.isZoomedOrPanned()).toBe(false);
+    });
   });
 });
diff --git a/test/specs/zoom.spec.js b/test/specs/zoom.spec.js
index bb76e5c..add2eb7 100644
--- a/test/specs/zoom.spec.js
+++ b/test/specs/zoom.spec.js
@@ -933,7 +933,7 @@ describe('zoom', function() {
       expect(chart.scales.y.min).toBe(3);
       expect(chart.scales.y.max).toBe(5);
       chart.zoom(0.9);
-      expect(chart.scales.y.min).toBe(2);
+      expect(chart.scales.y.min).toBe(1);
       expect(chart.scales.y.max).toBe(5);
       chart.zoom(0.9);
       expect(chart.scales.y.min).toBe(1);