Repository: chartjs/chartjs-plugin-zoom
Pull request #774 (2023-07-25 02:05:23)

Fix "original" scale limits with nonlinear pan
Nonlinear scales failed to resolve "original" within panNumericalScale.

To fix this:

* Resolving "original" within panNumericalScale seemed redundant, when updateRange was already doing it, so I instead moved the logic into updateRange and control it with a new special value for the `zoom` parameter.  (Hopefully the use of a special string like this is acceptable as long as JSDoc calls it out - if I should use another approach, please let me know.)
* This change put updateRange over the configured ESLint complexity limit, so I extracted a new getScaleLimits function to reduce complexity.

Add unit tests, both for this bug and for the preexisting but untested    "original" feature.

I wanted JSDoc + TypeScript checks to help validate and document these changes, but that exposed apparent limitations of Chart.js core's type definitions. I'll open a separate PR for those.

trullock: This is merged into my fork at https://github.com/trullock/chartjs-plugin-zoom which is published on npm as @trullock/chartjs-plugin-zoom, as this fork seems abandoned

Patch:
diff --git a/package-lock.json b/package-lock.json
index 80e20a7..7f21847 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,7 +19,7 @@
         "@typescript-eslint/eslint-plugin": "^5.4.0",
         "@typescript-eslint/parser": "^5.4.0",
         "babel-loader": "^8.3.0",
-        "chart.js": "^4.2.1",
+        "chart.js": "^4.3.2",
         "chartjs-adapter-date-fns": "^2.0.1",
         "chartjs-test-utils": "^0.3.0",
         "concurrently": "^6.0.2",
@@ -5428,15 +5428,15 @@
       }
     },
     "node_modules/chart.js": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.2.1.tgz",
-      "integrity": "sha512-6YbpQ0nt3NovAgOzbkSSeeAQu/3za1319dPUQTXn9WcOpywM8rGKxJHrhS8V8xEkAlk8YhEfjbuAPfUyp6jIsw==",
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.2.tgz",
+      "integrity": "sha512-pvQNyFOY1QmbmIr8oDORL16/FFivfxj8V26VFpFilMo4cNvkV5WXLJetDio365pd9gKUHGdirUTbqJfw8tr+Dg==",
       "dev": true,
       "dependencies": {
         "@kurkle/color": "^0.3.0"
       },
       "engines": {
-        "pnpm": "^7.0.0"
+        "pnpm": ">=7"
       }
     },
     "node_modules/chartjs-adapter-date-fns": {
@@ -15231,9 +15231,9 @@
       }
     },
     "node_modules/socket.io-parser": {
-      "version": "4.2.2",
-      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.2.tgz",
-      "integrity": "sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==",
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
+      "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
       "dev": true,
       "dependencies": {
         "@socket.io/component-emitter": "~3.1.0",
@@ -18914,9 +18914,9 @@
       }
     },
     "node_modules/word-wrap": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
       "dev": true,
       "engines": {
         "node": ">=0.10.0"
@@ -23314,9 +23314,9 @@
       "dev": true
     },
     "chart.js": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.2.1.tgz",
-      "integrity": "sha512-6YbpQ0nt3NovAgOzbkSSeeAQu/3za1319dPUQTXn9WcOpywM8rGKxJHrhS8V8xEkAlk8YhEfjbuAPfUyp6jIsw==",
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.2.tgz",
+      "integrity": "sha512-pvQNyFOY1QmbmIr8oDORL16/FFivfxj8V26VFpFilMo4cNvkV5WXLJetDio365pd9gKUHGdirUTbqJfw8tr+Dg==",
       "dev": true,
       "requires": {
         "@kurkle/color": "^0.3.0"
@@ -31198,9 +31198,9 @@
       }
     },
     "socket.io-parser": {
-      "version": "4.2.2",
-      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.2.tgz",
-      "integrity": "sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==",
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
+      "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
       "dev": true,
       "requires": {
         "@socket.io/component-emitter": "~3.1.0",
@@ -34161,9 +34161,9 @@
       }
     },
     "word-wrap": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
       "dev": true
     },
     "wordwrap": {
diff --git a/package.json b/package.json
index ed3c91e..f55c0ef 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
     "@typescript-eslint/eslint-plugin": "^5.4.0",
     "@typescript-eslint/parser": "^5.4.0",
     "babel-loader": "^8.3.0",
-    "chart.js": "^4.2.1",
+    "chart.js": "^4.3.2",
     "chartjs-adapter-date-fns": "^2.0.1",
     "chartjs-test-utils": "^0.3.0",
     "concurrently": "^6.0.2",
diff --git a/src/scale.types.js b/src/scale.types.js
index 0d83ebf..a4699d0 100644
--- a/src/scale.types.js
+++ b/src/scale.types.js
@@ -1,6 +1,20 @@
 import {valueOrDefault} from 'chart.js/helpers';
 import {getState} from './state';
 
+/**
+ * @typedef {import('chart.js').Point} Point
+ * @typedef {import('chart.js').Scale} Scale
+ * @typedef {import('../types/options').LimitOptions} LimitOptions
+ * @typedef {{min: number, max: number}} ScaleRange
+ * @typedef {import('../types/options').ScaleLimits} ScaleLimits
+ */
+
+/**
+ * @param {Scale} scale
+ * @param {number} zoom
+ * @param {Point} center
+ * @returns {ScaleRange}
+ */
 function zoomDelta(scale, zoom, center) {
   const range = scale.max - scale.min;
   const newRange = range * (zoom - 1);
@@ -20,6 +34,15 @@ function zoomDelta(scale, zoom, center) {
   };
 }
 
+/**
+ * @param {Scale} scale
+ * @param {LimitOptions|undefined} limits
+ * @returns {ScaleLimits}
+ */
+function getScaleLimits(scale, limits) {
+  return limits && (limits[scale.id] || limits[scale.axis]) || {};
+}
+
 function getLimit(state, scale, scaleLimits, prop, fallback) {
   let limit = scaleLimits[prop];
   if (limit === 'original') {
@@ -29,6 +52,12 @@ function getLimit(state, scale, scaleLimits, prop, fallback) {
   return valueOrDefault(limit, fallback);
 }
 
+/**
+ * @param {Scale} scale
+ * @param {number} pixel0
+ * @param {number} pixel1
+ * @returns {ScaleRange}
+ */
 function getRange(scale, pixel0, pixel1) {
   const v0 = scale.getValueForPixel(pixel0);
   const v1 = scale.getValueForPixel(pixel1);
@@ -38,15 +67,27 @@ function getRange(scale, pixel0, pixel1) {
   };
 }
 
+/**
+ * @param {Scale} scale
+ * @param {ScaleRange} minMax
+ * @param {LimitOptions} [limits]
+ * @param {boolean|'pan'} [zoom]
+ * @returns {boolean}
+ */
 export function updateRange(scale, {min, max}, limits, zoom = false) {
   const state = getState(scale.chart);
-  const {id, axis, options: scaleOpts} = scale;
+  const {options: scaleOpts} = scale;
 
-  const scaleLimits = limits && (limits[id] || limits[axis]) || {};
+  const scaleLimits = getScaleLimits(scale, limits);
   const {minRange = 0} = scaleLimits;
   const minLimit = getLimit(state, scale, scaleLimits, 'min', -Infinity);
   const maxLimit = getLimit(state, scale, scaleLimits, 'max', Infinity);
 
+  if (zoom === 'pan' && (min < minLimit || max > maxLimit)) {
+    // At limit: No change but return true to indicate no need to store the delta.
+    return true;
+  }
+
   const range = zoom ? Math.max(max - min, minRange) : scale.max - scale.min;
   const offset = (range - max + min) / 2;
   min -= offset;
@@ -139,20 +180,18 @@ const OFFSETS = {
   year: 182 * 24 * 60 * 60 * 1000 // 182 d
 };
 
-function panNumericalScale(scale, delta, limits, canZoom = false) {
+function panNumericalScale(scale, delta, limits, pan = false) {
   const {min: prevStart, max: prevEnd, options} = scale;
   const round = options.time && options.time.round;
   const offset = OFFSETS[round] || 0;
   const newMin = scale.getValueForPixel(scale.getPixelForValue(prevStart + offset) - delta);
   const newMax = scale.getValueForPixel(scale.getPixelForValue(prevEnd + offset) - delta);
-  const {min: minLimit = -Infinity, max: maxLimit = Infinity} = canZoom && limits && limits[scale.axis] || {};
-  if (isNaN(newMin) || isNaN(newMax) || newMin < minLimit || newMax > maxLimit) {
-    // At limit: No change but return true to indicate no need to store the delta.
+  if (isNaN(newMin) || isNaN(newMax)) {
     // NaN can happen for 0-dimension scales (either because they were configured
     // with min === max or because the chart has 0 plottable area).
     return true;
   }
-  return updateRange(scale, {min: newMin, max: newMax}, limits, canZoom);
+  return updateRange(scale, {min: newMin, max: newMax}, limits, pan ? 'pan' : false);
 }
 
 function panNonLinearScale(scale, delta, limits) {


Test patch:
diff --git a/test/specs/pan.spec.js b/test/specs/pan.spec.js
index 7772575..072e757 100644
--- a/test/specs/pan.spec.js
+++ b/test/specs/pan.spec.js
@@ -124,6 +124,77 @@ describe('pan', function() {
       expect(scale.options.min).toBe(2);
       expect(scale.options.max).toBe(2);
     });
+
+    it('should respect original limits', function() {
+      const chart = window.acquireChart({
+        type: 'line',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              pan: {
+                enabled: true,
+                mode: 'x',
+              },
+              limits: {
+                x: {
+                  min: 'original',
+                  max: 'original',
+                }
+              },
+            }
+          },
+          scales: {
+            x: {
+              min: 1,
+              max: 2
+            }
+          }
+        }
+      });
+      const scale = chart.scales.x;
+      expect(scale.min).toBe(1);
+      expect(scale.max).toBe(2);
+      chart.pan(100);
+      expect(scale.min).toBe(1);
+      expect(scale.max).toBe(2);
+    });
+
+    it('should respect original limits for nonlinear scales', function() {
+      const chart = window.acquireChart({
+        type: 'line',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              pan: {
+                enabled: true,
+                mode: 'x',
+              },
+              limits: {
+                x: {
+                  min: 'original',
+                  max: 'original',
+                }
+              },
+            }
+          },
+          scales: {
+            x: {
+              type: 'logarithmic',
+              min: 1,
+              max: 10
+            }
+          }
+        }
+      });
+      const scale = chart.scales.x;
+      expect(scale.min).toBe(1);
+      expect(scale.max).toBe(10);
+      chart.pan(100);
+      expect(scale.min).toBe(1);
+      expect(scale.max).toBe(10);
+    });
   });
 
   describe('events', function() {