Repository: chartjs/chartjs-plugin-zoom
Pull request #658 (2022-05-20 17:57:07)

Replace overScaleMode with more flexible scaleMode
As described at https://github.com/chartjs/chartjs-plugin-zoom/discussions/612, I'd like to allow both regular zooming (to zoom both axes via mouse wheel or pinch on the main chart area) and zooming a specific scale (to zoom only one axis via mouse wheel or pinch over the scale axis), but it seems that `overScaleMode` prevents regular zooming from working.

I would have expected that `mode` control regular zooming, while `overScaleMode` controls scale zooming in particular. For example ("scenario A"):

* `{mode: 'xy', overScaleMode: 'y'}` would mean that zooming over the regular chart scales X and Y, and zooming over the Y axis scales Y.
* `{mode: 'x', overScaleMode: 'y'}` would mean that zooming over the regular chart scales X, and zooming over the Y axis scales Y.
* `{mode: 'xy', overScaleMode: 'xy'}` would mean that zooming over the regular chart scales X and Y, and zooming over the X or Y axis scales X or Y, respectively.

Instead, it appears that `overScaleMode` restricts and modifies `mode` ("scenario B"):

* `{mode: 'xy', overScaleMode: 'y'}` would mean that zooming over the regular chart scales only X, and zooming over the Y axis scales Y.
* `{mode: 'x', overScaleMode: 'y'}` is an invalid configuration.
* `{mode: 'xy', overScaleMode: 'xy'}` would mean that zooming over the regular chart does nothing, and zooming over the X or Y axis scales X or Y, respectively.

This PR adds a new property, `scaleMode`, that behaves like scenario A. Since scenario A is strictly more flexible than scenario B, it deprecates `overScaleMode`.

There is a potential change in behavior with this PR: Previously, the `pan` function used `chart.scales` if `overScaleMode` was undefined, and it evaluated `directionEnabled` (which called `mode` if `mode` was a function). This meant that it could handle changes to the `mode` function's return value, and changes to what chart scales exist, even in the middle of a pan operation. Now, the list of scales to check is only evaluated when the pan operation starts.


Patch:
diff --git a/docs/guide/options.md b/docs/guide/options.md
index f45a50e..2eedf5c 100644
--- a/docs/guide/options.md
+++ b/docs/guide/options.md
@@ -35,7 +35,8 @@ const chart = new Chart('id', {
 | `enabled` | `boolean` | `false` | Enable panning
 | `mode` | `'x'`\|`'y'`\|`'xy'` | `'xy'` | Allowed panning directions
 | `modifierKey` | `'ctrl'`\|`'alt'`\|`'shift'`\|`'meta'` | `null` |  Modifier key required for panning with mouse
-| `overScaleMode` | `'x'`\|`'y'`\|`'xy'` | `undefined` | Which of the enabled panning directions should only be available when the mouse cursor is over a scale for that axis
+| `scaleMode` | `'x'`\|`'y'`\|`'xy'` | `undefined` | Enable panning over a scale for that axis (regardless of mode)
+| `overScaleMode` | `'x'`\|`'y'`\|`'xy'` | `undefined` | Enable panning over a scale for that axis (but only if mode is also enabled), and disables panning along that axis otherwise. Deprecated.
 | `threshold` | `number` | `10` | Minimal pan distance required before actually applying pan
 
 ### Pan Events
@@ -57,7 +58,8 @@ const chart = new Chart('id', {
 | `drag` | [`DragOptions`](#drag-options) | | Options of the drag-to-zoom behavior
 | `pinch` | [`PinchOptions`](#pinch-options) | | Options of the pinch behavior
 | `mode` | `'x'`\|`'y'`\|`'xy'` | `'xy'` | Allowed zoom directions
-| `overScaleMode` | `'x'`\|`'y'`\|`'xy'` | `undefined` | Which of the enabled zooming directions should only be available when the mouse cursor is over a scale for that axis
+| `scaleMode` | `'x'`\|`'y'`\|`'xy'` | `undefined` | Which of the enabled zooming directions should only be available when the mouse cursor is over a scale for that axis
+| `overScaleMode` | `'x'`\|`'y'`\|`'xy'` | `undefined` | Allowed zoom directions when the mouse cursor is over a scale for that axis (but only if mode is also enabled), and disables zooming along that axis otherwise. Deprecated; use `scaleMode` instead.
 
 #### Wheel options
 
diff --git a/docs/samples/wheel/over-scale-mode.md b/docs/samples/wheel/over-scale-mode.md
index d3b707d..685791d 100644
--- a/docs/samples/wheel/over-scale-mode.md
+++ b/docs/samples/wheel/over-scale-mode.md
@@ -62,12 +62,12 @@ const zoomOptions = {
       enabled: true,
     },
     mode: 'xy',
-    overScaleMode: 'xy',
+    scaleMode: 'xy',
   },
   pan: {
     enabled: true,
     mode: 'xy',
-    overScaleMode: 'xy',
+    scaleMode: 'xy',
   }
 };
 // </block>
diff --git a/package-lock.json b/package-lock.json
index 25db4fe..ad83e07 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4138,9 +4138,9 @@
       }
     },
     "node_modules/async": {
-      "version": "2.6.3",
-      "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
-      "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
+      "version": "2.6.4",
+      "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
+      "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
       "dev": true,
       "dependencies": {
         "lodash": "^4.17.14"
@@ -21975,9 +21975,9 @@
       "dev": true
     },
     "async": {
-      "version": "2.6.3",
-      "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
-      "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
+      "version": "2.6.4",
+      "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
+      "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
       "dev": true,
       "requires": {
         "lodash": "^4.17.14"
diff --git a/samples/zoom-separately.html b/samples/zoom-separately.html
index 84e2ca0..d5d20dc 100644
--- a/samples/zoom-separately.html
+++ b/samples/zoom-separately.html
@@ -100,8 +100,8 @@
 						zoom: {
 							pan: {
 								enabled: true,
-								mode: 'xy',
-								overScaleMode: 'y'
+								mode: 'x',
+								scaleMode: 'y'
 							},
 							zoom: {
 								wheel: {
@@ -110,8 +110,8 @@
 								pinch: {
 									enabled: true,
 								},
-								mode: 'xy',
-								overScaleMode: 'y'
+								mode: 'x',
+								scaleMode: 'y'
 							}
 						}
 					}
diff --git a/src/core.js b/src/core.js
index 79b4ad4..c065d39 100644
--- a/src/core.js
+++ b/src/core.js
@@ -60,13 +60,12 @@ export function zoom(chart, amount, transition = 'none') {
   const {x = 1, y = 1, focalPoint = getCenter(chart)} = typeof amount === 'number' ? {x: amount, y: amount} : amount;
   const state = getState(chart);
   const {options: {limits, zoom: zoomOptions}} = state;
-  const {mode = 'xy', overScaleMode} = zoomOptions || {};
 
   storeOriginalScaleLimits(chart, state);
 
-  const xEnabled = x !== 1 && directionEnabled(mode, 'x', chart);
-  const yEnabled = y !== 1 && directionEnabled(mode, 'y', chart);
-  const enabledScales = overScaleMode && getEnabledScalesByPoint(overScaleMode, focalPoint, chart);
+  const xEnabled = x !== 1;
+  const yEnabled = y !== 1;
+  const enabledScales = getEnabledScalesByPoint(zoomOptions, focalPoint, chart);
 
   each(enabledScales || chart.scales, function(scale) {
     if (scale.isHorizontal() && xEnabled) {
@@ -182,12 +181,12 @@ export function pan(chart, delta, enabledScales, transition = 'none') {
   const {x = 0, y = 0} = typeof delta === 'number' ? {x: delta, y: delta} : delta;
   const state = getState(chart);
   const {options: {pan: panOptions, limits}} = state;
-  const {mode = 'xy', onPan} = panOptions || {};
+  const {onPan} = panOptions || {};
 
   storeOriginalScaleLimits(chart, state);
 
-  const xEnabled = x !== 0 && directionEnabled(mode, 'x', chart);
-  const yEnabled = y !== 0 && directionEnabled(mode, 'y', chart);
+  const xEnabled = x !== 0;
+  const yEnabled = y !== 0;
 
   each(enabledScales || chart.scales, function(scale) {
     if (scale.isHorizontal() && xEnabled) {
diff --git a/src/hammer.js b/src/hammer.js
index 273f000..91b0c6a 100644
--- a/src/hammer.js
+++ b/src/hammer.js
@@ -90,7 +90,7 @@ function handlePan(chart, state, e) {
 }
 
 function startPan(chart, state, event) {
-  const {enabled, overScaleMode, onPanStart, onPanRejected} = state.options.pan;
+  const {enabled, onPanStart, onPanRejected} = state.options.pan;
   if (!enabled) {
     return;
   }
@@ -104,7 +104,7 @@ function startPan(chart, state, event) {
     return call(onPanRejected, [{chart, event}]);
   }
 
-  state.panScales = overScaleMode && getEnabledScalesByPoint(overScaleMode, point, chart);
+  state.panScales = getEnabledScalesByPoint(state.options.pan, point, chart);
   state.delta = {x: 0, y: 0};
   clearTimeout(state.panEndTimeout);
   handlePan(chart, state, event);
diff --git a/src/handlers.js b/src/handlers.js
index 00a9045..416f720 100644
--- a/src/handlers.js
+++ b/src/handlers.js
@@ -133,7 +133,7 @@ function wheelPreconditions(chart, event, zoomOptions) {
     return;
   }
 
-  // Prevent the event from triggering the default behavior (eg. Content scrolling).
+  // Prevent the event from triggering the default behavior (e.g. content scrolling).
   if (event.cancelable) {
     event.preventDefault();
   }
diff --git a/src/plugin.js b/src/plugin.js
index 26b13ab..ad4a991 100644
--- a/src/plugin.js
+++ b/src/plugin.js
@@ -42,6 +42,10 @@ export default {
     if (Object.prototype.hasOwnProperty.call(options.zoom, 'enabled')) {
       console.warn('The option `zoom.enabled` is no longer supported. Please use `zoom.wheel.enabled`, `zoom.drag.enabled`, or `zoom.pinch.enabled`.');
     }
+    if (Object.prototype.hasOwnProperty.call(options.zoom, 'overScaleMode')
+      || Object.prototype.hasOwnProperty.call(options.pan, 'overScaleMode')) {
+      console.warn('The option `overScaleMode` is deprecated. Please use `scaleMode` instead (and update `mode` as desired).');
+    }
 
     if (Hammer) {
       startHammer(chart, options);
diff --git a/src/utils.js b/src/utils.js
index cce065f..3d16880 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -22,6 +22,17 @@ export function directionEnabled(mode, dir, chart) {
   return false;
 }
 
+function directionsEnabled(mode, chart) {
+  if (typeof mode === 'function') {
+    mode = mode({chart});
+  }
+  if (typeof mode === 'string') {
+    return {x: mode.indexOf('x') !== -1, y: mode.indexOf('y') !== -1};
+  }
+
+  return {x: false, y: false};
+}
+
 /**
  * Debounces calling `fn` for `delay` ms
  * @param {function} fn - Function to call. No arguments are passed.
@@ -37,7 +48,8 @@ export function debounce(fn, delay) {
   };
 }
 
-/** This function use for check what axis now under mouse cursor.
+/**
+ * Checks which axis is under the mouse cursor.
  * @param {{x: number, y: number}} point - the mouse location
  * @param {import('chart.js').Chart} [chart] instance of the chart in question
  * @return {import('chart.js').Scale}
@@ -54,27 +66,40 @@ function getScaleUnderPoint({x, y}, chart) {
   return null;
 }
 
-/** This function return only one scale whose position is under mouse cursor and which direction is enabled.
- * If under mouse hasn't scale, then return all other scales which 'mode' is diffrent with overScaleMode.
- * So 'overScaleMode' works as a limiter to scale the user-selected scale (in 'mode') only when the cursor is under the scale,
- * and other directions in 'mode' works as before.
- * Example: mode = 'xy', overScaleMode = 'y' -> it's means 'x' - works as before, and 'y' only works for one scale when cursor is under it.
+/**
+ * Evaluate the chart's mode, scaleMode, and overScaleMode properties to
+ * determine which axes are eligible for scaling.
  * options.overScaleMode can be a function if user want zoom only one scale of many for example.
- * @param {string} mode - 'xy', 'x' or 'y'
+ * @param options - Zoom or pan options
  * @param {{x: number, y: number}} point - the mouse location
  * @param {import('chart.js').Chart} [chart] instance of the chart in question
  * @return {import('chart.js').Scale[]}
  */
-export function getEnabledScalesByPoint(mode, point, chart) {
+export function getEnabledScalesByPoint(options, point, chart) {
+  const {mode = 'xy', scaleMode, overScaleMode} = options || {};
   const scale = getScaleUnderPoint(point, chart);
 
-  if (scale && directionEnabled(mode, scale.axis, chart)) {
+  const enabled = directionsEnabled(mode, chart);
+  const scaleEnabled = directionsEnabled(scaleMode, chart);
+
+  // Convert deprecated overScaleEnabled to new scaleEnabled.
+  if (overScaleMode) {
+    const overScaleEnabled = directionsEnabled(overScaleMode, chart);
+    for (const axis of ['x', 'y']) {
+      if (overScaleEnabled[axis]) {
+        scaleEnabled[axis] = enabled[axis];
+        enabled[axis] = false;
+      }
+    }
+  }
+
+  if (scale && scaleEnabled[scale.axis]) {
     return [scale];
   }
 
   const enabledScales = [];
   each(chart.scales, function(scaleItem) {
-    if (!directionEnabled(mode, scaleItem.axis, chart)) {
+    if (enabled[scaleItem.axis]) {
       enabledScales.push(scaleItem);
     }
   });
diff --git a/types/options.d.ts b/types/options.d.ts
index d276e41..6b1f2eb 100644
--- a/types/options.d.ts
+++ b/types/options.d.ts
@@ -67,7 +67,7 @@ export interface PinchOptions {
 export interface ZoomOptions {
   /**
    * Zooming directions. Remove the appropriate direction to disable
-   * Eg. 'y' would only allow zooming in the y direction
+   * E.g. 'y' would only allow zooming in the y direction
    * A function that is called as the user is zooming and returns the
    * available directions can also be used:
    *    mode: function({ chart }) {
@@ -91,6 +91,8 @@ export interface ZoomOptions {
    */
   pinch?: PinchOptions;
 
+  scaleMode?: Mode | { (chart: Chart): Mode };
+  /** @deprecated Use scaleMode instead */
   overScaleMode?: Mode | { (chart: Chart): Mode };
 
   /**
@@ -122,7 +124,7 @@ export interface PanOptions {
 
   /**
    * Panning directions. Remove the appropriate direction to disable
-   * Eg. 'y' would only allow panning in the y direction
+   * E.g. 'y' would only allow panning in the y direction
    * A function that is called as the user is panning and returns the
    * available directions can also be used:
    *   mode: function({ chart }) {
@@ -136,7 +138,9 @@ export interface PanOptions {
    */
   modifierKey?: Key;
 
-  overScaleMode?: Mode | { (char: Chart): Mode };
+  scaleMode?: Mode | { (chart: Chart): Mode };
+  /** @deprecated Use scaleMode instead */
+  overScaleMode?: Mode | { (chart: Chart): Mode };
 
   /**
    * Minimal pan distance required before actually applying pan


Test patch:
diff --git a/test/specs/zoom.spec.js b/test/specs/zoom.spec.js
index 5c62469..bb76e5c 100644
--- a/test/specs/zoom.spec.js
+++ b/test/specs/zoom.spec.js
@@ -550,6 +550,90 @@ describe('zoom', function() {
     });
   });
 
+  describe('with scaleMode = y and mode = xy', function() {
+    const config = {
+      type: 'line',
+      data,
+      options: {
+        scales: {
+          x: {
+            type: 'linear',
+            min: 1,
+            max: 10
+          },
+          y: {
+            type: 'linear'
+          }
+        },
+        plugins: {
+          zoom: {
+            zoom: {
+              wheel: {
+                enabled: true,
+              },
+              mode: 'xy',
+              scaleMode: 'y'
+            }
+          }
+        }
+      }
+    };
+
+    describe('Wheel under Y scale', function() {
+      it('should be applied on Y, but not on X scales.', async function() {
+        const chart = window.acquireChart(config);
+
+        const scaleX = chart.scales.x;
+        const scaleY = chart.scales.y;
+
+        const oldMinX = scaleX.options.min;
+        const oldMaxX = scaleX.options.max;
+        const oldMinY = scaleY.options.min;
+        const oldMaxY = scaleY.options.max;
+
+        const wheelEv = {
+          x: scaleY.left + (scaleY.right - scaleY.left) / 2,
+          y: scaleY.top + (scaleY.bottom - scaleY.top) / 2,
+          deltaY: 1
+        };
+
+        await jasmine.triggerWheelEvent(chart, wheelEv);
+
+        expect(scaleX.options.min).toEqual(oldMinX);
+        expect(scaleX.options.max).toEqual(oldMaxX);
+        expect(scaleY.options.min).not.toEqual(oldMinY);
+        expect(scaleY.options.max).not.toEqual(oldMaxY);
+      });
+    });
+
+    describe('Wheel not under Y scale', function() {
+      it('should be applied on X and Y scales.', async function() {
+        const chart = window.acquireChart(config);
+
+        const scaleX = chart.scales.x;
+        const scaleY = chart.scales.y;
+
+        const oldMinX = scaleX.options.min;
+        const oldMaxX = scaleX.options.max;
+        const oldMinY = scaleY.options.min;
+        const oldMaxY = scaleY.options.max;
+
+        const wheelEv = {
+          x: scaleX.getPixelForValue(1.5),
+          y: scaleY.getPixelForValue(1.1),
+          deltaY: 1
+        };
+
+        await jasmine.triggerWheelEvent(chart, wheelEv);
+
+        expect(scaleX.options.min).not.toEqual(oldMinX);
+        expect(scaleX.options.max).not.toEqual(oldMaxX);
+        expect(scaleY.options.min).not.toEqual(oldMinY);
+        expect(scaleY.options.max).not.toEqual(oldMaxY);
+      });
+    });
+  });
+
   describe('events', function() {
     describe('wheel', function() {
       it('should call onZoomStart', function() {