Repository: chartjs/chartjs-plugin-zoom
Pull request #399 (2020-11-10 11:07:22)

Feature: Zoom each scale separately
Added the **overScaleMode** option so that you can use zoom and pan separately for each scale when the mouse is over the scale.
Try it: https://codepen.io/lirik90/pen/rNLqMVg
Or watch what is it: https://youtu.be/u3HlB3YX_fs

lirik90: Hi, @kurkle . I tried to add a comment, please see it.
kurkle: Thanks, the code looks good to me now. Would you be able to create a test for it?
lirik90: I added two small tests. Are they enough?

Patch:
diff --git a/README.md b/README.md
index 02c7177..b110694 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,10 @@ plugins: {
    //   },
    mode: 'xy',
 
+   // Which of the enabled panning directions should only be available
+   // when the mouse cursor is over one of scale.
+   overScaleMode: 'xy',
+
    rangeMin: {
     // Format of min pan range depends on scale type
     x: null,
@@ -93,6 +97,10 @@ plugins: {
    //   },
    mode: 'xy',
 
+   // Which of the enabled zooming directions should only be available
+   // when the mouse cursor is over one of scale.
+   overScaleMode: 'xy',
+
    rangeMin: {
     // Format of min zoom range depends on scale type
     x: null,
diff --git a/samples/zoom-separately.html b/samples/zoom-separately.html
new file mode 100644
index 0000000..d83257b
--- /dev/null
+++ b/samples/zoom-separately.html
@@ -0,0 +1,125 @@
+<!doctype html>
+<html>
+
+<head>
+	<title>Chart.js Zoom each scale separately</title>
+	<script src="../node_modules/chart.js/dist/chart.js"></script>
+	<script src="../node_modules/hammerjs/hammer.min.js"></script>
+	<script src="../dist/chartjs-plugin-zoom.min.js"></script>
+
+	<style>
+	canvas {
+		-moz-user-select: none;
+		-webkit-user-select: none;
+		-ms-user-select: none;
+	}
+	</style>
+</head>
+
+<body>
+	<canvas id="canvas"></canvas>
+	<script>
+		function genData() {
+			var analogData = [];
+			var digitalData = [];
+			var angle = 1980;
+			var limit = 35;
+			for (var x = 0; x <= angle; x += 10) {
+				var y = Math.sin(x * Math.PI / 180) * limit;
+				analogData.push({x, y});
+
+				y = x === 0 ? -limit : x === angle ? limit : y;
+				if (y === limit || y === -limit) {
+					digitalData.push({x, y: y === -limit});
+				}
+			}
+
+			return [analogData, digitalData];
+		}
+
+		function createConfig() {
+			var [analogData, digitalData] = genData();
+			return {
+				data: {
+					datasets: [{
+						yAxisID: 'yA',
+						label: 'Temperature',
+						type: 'line',
+						fill: false,
+						borderColor: 'rgb(54, 162, 235)',
+						data: analogData
+					}, {
+						yAxisID: 'yB',
+						label: 'Heater',
+						type: 'line',
+						fill: false,
+						steppedLine: true,
+						borderColor: 'rgb(255, 99, 132)',
+						data: digitalData
+					}]
+				},
+				options: {
+					responsive: true,
+					title: {
+						display: true,
+						text: 'Chart.js Zoom each scale separately'
+					},
+					scales: {
+						x: {
+							type: 'linear',
+							offset: true,
+							scaleLabel: {
+								display: true,
+								labelString: 'x axis'
+							}
+						},
+						yA: {
+							id: 'A',
+							offset: true,
+							position: 'left',
+							scaleLabel: {
+								display: true,
+								labelString: 'Analog'
+							}
+						},
+						yB: {
+							id: 'B',
+							position: 'right',
+							scaleLabel: {
+								display: true,
+								labelString: 'Digital'
+							},
+							ticks: {
+								max: 2,
+								min: -1,
+								stepSize: 1
+							}
+						}
+					},
+					plugins: {
+						zoom: {
+							pan: {
+								enabled: true,
+								mode: 'xy',
+								overScaleMode: 'y'
+							},
+							zoom: {
+								enabled: true,
+								mode: 'xy',
+								overScaleMode: 'y'
+							}
+						}
+					}
+				}
+			};
+		}
+
+		window.onload = function() {
+			var ctx = document.getElementById('canvas').getContext('2d');
+			var config = createConfig();
+			window.myChart = new window.Chart(ctx, config);
+		};
+	</script>
+</body>
+
+</html>
diff --git a/src/plugin.js b/src/plugin.js
index c09ec01..b1211b8 100644
--- a/src/plugin.js
+++ b/src/plugin.js
@@ -64,6 +64,54 @@ function directionEnabled(mode, dir, chart) {
   return false;
 }
 
+/** This function use for check what axis now under mouse cursor.
+ * @param {number} [x] X position
+ * @param {number} [y] Y position
+ * @param {import('chart.js').Chart} [chart] instance of the chart in question
+ */
+function getScaleUnderPoint(x, y, chart) {
+  var scales = chart.scales;
+  var scaleIds = Object.keys(scales);
+  for (var i = 0; i < scaleIds.length; i++) {
+    var scale = scales[scaleIds[i]];
+    if (y >= scale.top && y <= scale.bottom && x >= scale.left && x <= scale.right) {
+      return scale;
+    }
+  }
+  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.
+ * options.overScaleMode can be a function if user want zoom only one scale of many for example.
+ * @param {any} [options] pan or zoom options
+ * @param {number} [x] X position
+ * @param {number} [y] Y position
+ * @param {import('chart.js').Chart} [chart] instance of the chart in question
+ */
+function getEnabledScalesByPoint(options, x, y, chart) {
+  if (options.enabled && options.overScaleMode) {
+    var scale = getScaleUnderPoint(x, y, chart);
+    var mode = typeof options.overScaleMode === 'function' ? options.overScaleMode({chart: chart}, scale) : options.overScaleMode;
+
+    if (scale && directionEnabled(mode, scale.axis, chart)) {
+      return [scale];
+    }
+
+    var enabledScales = [];
+    each(chart.scales, function(scaleItem) {
+      if (!directionEnabled(mode, scaleItem.axis, chart)) {
+        enabledScales.push(scaleItem);
+      }
+    });
+    return enabledScales;
+  }
+  return null;
+}
+
 function rangeMaxLimiter(zoomPanOptions, newMax) {
   if (zoomPanOptions.scaleAxes && zoomPanOptions.rangeMax &&
       !isNullOrUndef(zoomPanOptions.rangeMax[zoomPanOptions.scaleAxes])) {
@@ -164,7 +212,9 @@ function doZoom(chart, percentZoomX, percentZoomY, focalPoint, whichAxes, animat
       _whichAxes = 'xy';
     }
 
-    each(chart.scales, function(scale) {
+    var enabledScales = getEnabledScalesByPoint(zoomOptions, focalPoint.x, focalPoint.y, chart);
+
+    each(enabledScales || chart.scales, function(scale) {
       if (scale.isHorizontal() && directionEnabled(zoomMode, 'x', chart) && directionEnabled(_whichAxes, 'x', chart)) {
         zoomOptions.scaleAxes = 'x';
         zoomScale(scale, percentZoomX, focalPoint, zoomOptions);
@@ -245,13 +295,13 @@ function panScale(scale, delta, panOptions) {
   }
 }
 
-function doPan(chartInstance, deltaX, deltaY) {
+function doPan(chartInstance, deltaX, deltaY, panningScales) {
   storeOriginalOptions(chartInstance);
   var panOptions = chartInstance.$zoom._options.pan;
   if (panOptions.enabled) {
     var panMode = typeof panOptions.mode === 'function' ? panOptions.mode({chart: chartInstance}) : panOptions.mode;
 
-    each(chartInstance.scales, function(scale) {
+    each(panningScales || chartInstance.scales, function(scale) {
       if (scale.isHorizontal() && directionEnabled(panMode, 'x', chartInstance) && deltaX !== 0) {
         panOptions.scaleAxes = 'x';
         panScale(scale, deltaX, panOptions);
@@ -538,6 +588,7 @@ var zoomPlugin = {
       var currentDeltaX = null;
       var currentDeltaY = null;
       var panning = false;
+      var panningScales = null;
       var handlePan = function(e) {
         if (currentDeltaX !== null && currentDeltaY !== null) {
           panning = true;
@@ -545,17 +596,25 @@ var zoomPlugin = {
           var deltaY = e.deltaY - currentDeltaY;
           currentDeltaX = e.deltaX;
           currentDeltaY = e.deltaY;
-          doPan(chartInstance, deltaX, deltaY);
+          doPan(chartInstance, deltaX, deltaY, panningScales);
         }
       };
 
       mc.on('panstart', function(e) {
+        if (panOptions.enabled) {
+          var rect = e.target.getBoundingClientRect();
+          var x = e.center.x - rect.left;
+          var y = e.center.y - rect.top;
+          panningScales = getEnabledScalesByPoint(panOptions, x, y, chartInstance);
+        }
+
         currentDeltaX = 0;
         currentDeltaY = 0;
         handlePan(e);
       });
       mc.on('panmove', handlePan);
       mc.on('panend', function() {
+        panningScales = null;
         currentDeltaX = null;
         currentDeltaY = null;
         setTimeout(function() {


Test patch:
diff --git a/test/specs/zoom.spec.js b/test/specs/zoom.spec.js
index b82c2db..f470aab 100644
--- a/test/specs/zoom.spec.js
+++ b/test/specs/zoom.spec.js
@@ -314,4 +314,86 @@ describe('zoom', function() {
       }
     }
   });
+
+  describe('with overScaleMode = y and mode = xy', function() {
+    let config = {
+      type: 'line',
+      data,
+      options: {
+        scales: {
+          x: {
+            type: 'linear',
+            min: 1,
+            max: 10
+          },
+          y: {
+            type: 'linear'
+          }
+        },
+        plugins: {
+          zoom: {
+            zoom: {
+              enabled: true,
+              mode: 'xy',
+              overScaleMode: '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, but not on 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).toEqual(oldMinY);
+        expect(scaleY.options.max).toEqual(oldMaxY);
+      });
+    });
+  });
 });