Repository: chartjs/chartjs-plugin-zoom
Pull request #873 (2024-11-17 18:16:49)

chore: add sample for minRange on category scale
closes: #796

github-actions[bot]: **Size Change:** 0 B 

**Total Size:** 18.9 kB



<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size |
| :--- | :---: |
| `dist/chartjs-plugin-zoom.esm.js` | 6.82 kB |
| `dist/chartjs-plugin-zoom.js` | 6.97 kB |
| `dist/chartjs-plugin-zoom.min.js` | 5.08 kB |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>

Patch:
diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js
index 24b46a3..a55fa36 100755
--- a/docs/.vuepress/config.js
+++ b/docs/.vuepress/config.js
@@ -134,6 +134,7 @@ module.exports = {
           title: 'Wheel Zoom',
           children: [
             'wheel/category',
+            'wheel/category-min-range',
             'wheel/log',
             'wheel/time',
             'wheel/over-scale-mode',
diff --git a/docs/samples/wheel/category-min-range.md b/docs/samples/wheel/category-min-range.md
new file mode 100644
index 0000000..09af5b9
--- /dev/null
+++ b/docs/samples/wheel/category-min-range.md
@@ -0,0 +1,98 @@
+# Category Scale + minRange
+
+```js chart-editor
+// <block:data:1>
+const DATA_COUNT = 20;
+const NUMBER_CFG = {count: DATA_COUNT, min: -100, max: 100};
+const data = {
+  labels: Utils.months({count: DATA_COUNT}),
+  datasets: [{
+    label: 'Dataset 1',
+    borderColor: Utils.randomColor(0.7),
+    backgroundColor: Utils.randomColor(0.5),
+    data: Utils.numbers(NUMBER_CFG),
+  }, {
+    label: 'Dataset 2',
+    borderColor: Utils.randomColor(0.7),
+    backgroundColor: Utils.randomColor(0.5),
+    data: Utils.numbers(NUMBER_CFG),
+  }, {
+    label: 'Dataset 3',
+    borderColor: Utils.randomColor(0.7),
+    backgroundColor: Utils.randomColor(0.5),
+    data: Utils.numbers(NUMBER_CFG),
+  }]
+};
+// </block:data>
+
+// <block:scales:2>
+const scaleOpts = {
+  grid: {
+    borderColor: Utils.randomColor(1),
+    color: 'rgba( 0, 0, 0, 0.1)',
+  },
+  title: {
+    display: true,
+    text: (ctx) => ctx.scale.axis + ' axis',
+  }
+};
+const scales = {
+  x: {
+    type: 'category',
+    min: 5,
+    max: 11,
+  },
+  y: {
+    type: 'linear'
+  },
+};
+Object.keys(scales).forEach(scale => Object.assign(scales[scale], scaleOpts));
+// </block:scales>
+
+// <block:config:0>
+const config = {
+  type: 'bar',
+  data: data,
+  options: {
+    scales: scales,
+    plugins: {
+      zoom: {
+        pan: {
+          enabled: true,
+          mode: 'x',
+          threshold: 5,
+        },
+        zoom: {
+          wheel: {
+            enabled: true
+          },
+          pinch: {
+            enabled: true
+          },
+          mode: 'x',
+        },
+        limits: {
+          x: {
+            minRange: 4
+          }
+        }
+      },
+    },
+  }
+};
+// </block:config>
+
+const actions = [
+  {
+    name: 'Reset zoom',
+    handler(chart) {
+      chart.resetZoom();
+    }
+  }
+];
+
+module.exports = {
+  actions,
+  config,
+};
+```
diff --git a/package.json b/package.json
index 9ad33c1..5370b9f 100644
--- a/package.json
+++ b/package.json
@@ -14,11 +14,12 @@
     "url": "https://github.com/chartjs/chartjs-plugin-zoom.git"
   },
   "scripts": {
+    "autobuild": "rollup -c -w",
     "build": "rollup -c",
     "dev": "karma start --auto-watch --no-single-run --browsers chrome",
     "dev:ff": "karma start --auto-watch --no-single-run --browsers firefox",
     "docs": "npm run build && vuepress build docs --no-cache",
-    "docs:dev": "npm run build && vuepress dev docs --no-cache",
+    "docs:dev": "concurrently \"npm run autobuild\" \"vuepress dev docs --no-cache\"",
     "lint-js": "eslint \"samples/**/*.html\" \"test/**/*.js\" \"src/**/*.js\"",
     "lint-md": "eslint \"**/*.md\"",
     "lint-tsc": "tsc",
diff --git a/src/scale.types.js b/src/scale.types.js
index a4699d0..cc9b6c1 100644
--- a/src/scale.types.js
+++ b/src/scale.types.js
@@ -9,28 +9,73 @@ import {getState} from './state';
  * @typedef {import('../types/options').ScaleLimits} ScaleLimits
  */
 
+/**
+ *
+ * @param {number} val
+ * @param {number} min
+ * @param {number} range
+ * @param {number} newRange
+ * @returns {ScaleRange}
+ */
+function zoomDelta(val, min, range, newRange) {
+  const minPercent = Math.max(0, Math.min(1, (val - min) / range || 0));
+  const maxPercent = 1 - minPercent;
+
+  return {
+    min: newRange * minPercent,
+    max: newRange * maxPercent
+  };
+}
+
+/**
+ * @param {Scale} scale
+ * @param {Point} point
+ * @returns number | undefined
+ */
+function getValueAtPoint(scale, point) {
+  const pixel = scale.isHorizontal() ? point.x : point.y;
+
+  return scale.getValueForPixel(pixel);
+}
+
 /**
  * @param {Scale} scale
  * @param {number} zoom
  * @param {Point} center
  * @returns {ScaleRange}
  */
-function zoomDelta(scale, zoom, center) {
+function linearZoomDelta(scale, zoom, center) {
   const range = scale.max - scale.min;
   const newRange = range * (zoom - 1);
+  const centerValue = getValueAtPoint(scale, center);
 
-  const centerPoint = scale.isHorizontal() ? center.x : center.y;
-  // `scale.getValueForPixel()` can return a value less than the `scale.min` or
-  // greater than `scale.max` when `centerPoint` is outside chartArea.
-  const minPercent = Math.max(0, Math.min(1,
-    (scale.getValueForPixel(centerPoint) - scale.min) / range || 0
-  ));
+  return zoomDelta(centerValue, scale.min, range, newRange);
+}
 
-  const maxPercent = 1 - minPercent;
+/**
+ * @param {Scale} scale
+ * @param {number} zoom
+ * @param {Point} center
+ * @returns {ScaleRange}
+ */
+function logarithmicZoomRange(scale, zoom, center) {
+  const centerValue = getValueAtPoint(scale, center);
+
+  // Return the original range, if value could not be determined.
+  if (centerValue === undefined) {
+    return {min: scale.min, max: scale.max};
+  }
+
+  const logMin = Math.log10(scale.min);
+  const logMax = Math.log10(scale.max);
+  const logCenter = Math.log10(centerValue);
+  const logRange = logMax - logMin;
+  const newLogRange = logRange * (zoom - 1);
+  const delta = zoomDelta(logCenter, logMin, logRange, newLogRange);
 
   return {
-    min: newRange * minPercent,
-    max: newRange * maxPercent
+    min: Math.pow(10, logMin + delta.min),
+    max: Math.pow(10, logMax - delta.max),
   };
 }
 
@@ -58,7 +103,7 @@ function getLimit(state, scale, scaleLimits, prop, fallback) {
  * @param {number} pixel1
  * @returns {ScaleRange}
  */
-function getRange(scale, pixel0, pixel1) {
+function linearRange(scale, pixel0, pixel1) {
   const v0 = scale.getValueForPixel(pixel0);
   const v1 = scale.getValueForPixel(pixel1);
   return {
@@ -88,7 +133,14 @@ export function updateRange(scale, {min, max}, limits, zoom = false) {
     return true;
   }
 
-  const range = zoom ? Math.max(max - min, minRange) : scale.max - scale.min;
+  const scaleRange = scale.max - scale.min;
+  const range = zoom ? Math.max(max - min, minRange) : scaleRange;
+
+  if (zoom && range === minRange && scaleRange <= minRange) {
+    // At range limit: No change but return true to indicate no need to store the delta.
+    return true;
+  }
+
   const offset = (range - max + min) / 2;
   min -= offset;
   max += offset;
@@ -110,13 +162,24 @@ export function updateRange(scale, {min, max}, limits, zoom = false) {
 }
 
 function zoomNumericalScale(scale, zoom, center, limits) {
-  const delta = zoomDelta(scale, zoom, center);
+  const delta = linearZoomDelta(scale, zoom, center);
   const newRange = {min: scale.min + delta.min, max: scale.max - delta.max};
   return updateRange(scale, newRange, limits, true);
 }
 
+function zoomLogarithmicScale(scale, zoom, center, limits) {
+  const newRange = logarithmicZoomRange(scale, zoom, center);
+  return updateRange(scale, newRange, limits, true);
+}
+
+/**
+ * @param {Scale} scale
+ * @param {number} from
+ * @param {number} to
+ * @param {LimitOptions} [limits]
+ */
 function zoomRectNumericalScale(scale, from, to, limits) {
-  updateRange(scale, getRange(scale, from, to), limits, true);
+  updateRange(scale, linearRange(scale, from, to), limits, true);
 }
 
 const integerChange = (v) => v === 0 || isNaN(v) ? 0 : v < 0 ? Math.min(Math.round(v), -1) : Math.max(Math.round(v), 1);
@@ -134,7 +197,7 @@ function existCategoryFromMaxZoom(scale) {
 }
 
 function zoomCategoryScale(scale, zoom, center, limits) {
-  const delta = zoomDelta(scale, zoom, center);
+  const delta = linearZoomDelta(scale, zoom, center);
   if (scale.min === scale.max && zoom < 1) {
     existCategoryFromMaxZoom(scale);
   }
@@ -201,6 +264,7 @@ function panNonLinearScale(scale, delta, limits) {
 export const zoomFunctions = {
   category: zoomCategoryScale,
   default: zoomNumericalScale,
+  logarithmic: zoomLogarithmicScale,
 };
 
 export const zoomRectFunctions = {


Test patch:
diff --git a/test/specs/api.spec.js b/test/specs/api.spec.js
index 5f21ef7..7102591 100644
--- a/test/specs/api.spec.js
+++ b/test/specs/api.spec.js
@@ -9,6 +9,10 @@ describe('api', function() {
     expect(typeof chart.resetZoom).toBe('function');
     expect(typeof chart.getZoomLevel).toBe('function');
     expect(typeof chart.getInitialScaleBounds).toBe('function');
+    expect(typeof chart.getZoomedScaleBounds).toBe('function');
+    expect(typeof chart.isZoomedOrPanned).toBe('function');
+    expect(typeof chart.isZoomingOrPanning).toBe('function');
+
   });
 
   describe('zoom and resetZoom', function() {
@@ -148,6 +152,48 @@ describe('api', function() {
       expect(chart.scales.x.max).toBe(100);
     });
 
+    it('should no-op whan laready at limit', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        options: {
+          scales: {
+            x: {
+              min: 0,
+              max: 100
+            },
+            y: {
+              min: 0,
+              max: 100
+            }
+          },
+          plugins: {
+            zoom: {
+              mode: 'x',
+              limits: {
+                x: {
+                  min: 0,
+                  max: 100,
+                  minRange: 50
+                }
+              }
+            }
+          }
+        }
+      });
+
+      chart.zoom({x: 1.5, focalPoint: {x: chart.scales.x.getPixelForValue(100)}});
+      expect(chart.scales.x.min).toBe(50);
+      expect(chart.scales.x.max).toBe(100);
+
+      chart.zoom({x: 1.5, focalPoint: {x: chart.scales.x.getPixelForValue(100)}});
+      expect(chart.scales.x.min).toBe(50);
+      expect(chart.scales.x.max).toBe(100);
+
+      chart.zoom({x: 1.5, focalPoint: {x: chart.scales.x.getPixelForValue(50)}});
+      expect(chart.scales.x.min).toBe(50);
+      expect(chart.scales.x.max).toBe(100);
+    });
+
     it('should honor zoom changes against a limit', function() {
       const chart = window.acquireChart({
         type: 'scatter',
@@ -369,4 +415,136 @@ describe('api', function() {
       expect(chart.getZoomedScaleBounds().x).toBeUndefined();
     });
   });
+
+  describe('with category scale', function() {
+    it('should zoom up to and out from single category', function() {
+      const chart = window.acquireChart({
+        type: 'bar',
+        data: {
+          labels: ['a', 'b', 'c', 'd', 'e'],
+          datasets: [{
+            data: [1, 2, 3, 2, 1]
+          }]
+        },
+        options: {
+          scales: {
+            x: {
+              min: 'b',
+              max: 'd'
+            }
+          },
+        }
+      });
+      expect(chart.scales.x.min).toBe(1);
+      expect(chart.scales.x.max).toBe(3);
+      chart.zoom(1.1);
+      expect(chart.scales.x.min).toBe(2);
+      expect(chart.scales.x.max).toBe(2);
+      chart.zoom(0.9);
+      expect(chart.scales.x.min).toBe(1);
+      expect(chart.scales.x.max).toBe(3);
+      chart.zoom(0.9);
+      expect(chart.scales.x.min).toBe(0);
+      expect(chart.scales.x.max).toBe(4);
+      chart.resetZoom();
+      expect(chart.scales.x.min).toBe(1);
+      expect(chart.scales.x.max).toBe(3);
+    });
+
+    it('should not exceed limits', function() {
+      const chart = window.acquireChart({
+        type: 'bar',
+        data: {
+          labels: ['0', '1', '2', '3', '4', '5', '6'],
+          datasets: [{
+            data: [1, 2, 3, 2, 1, 0, 1]
+          }]
+        },
+        options: {
+          indexAxis: 'y',
+          scales: {
+            y: {
+              min: 2,
+              max: 4
+            }
+          },
+          plugins: {
+            zoom: {
+              limits: {
+                y: {
+                  min: 1,
+                  max: 5,
+                  minRange: 1
+                }
+              },
+              zoom: {
+                wheel: {
+                  enabled: true,
+                },
+                mode: 'y'
+              }
+            }
+          }
+        }
+      });
+      expect(chart.scales.y.min).toBe(2);
+      expect(chart.scales.y.max).toBe(4);
+      chart.zoom(1.1);
+      expect(chart.scales.y.min).toBe(3);
+      expect(chart.scales.y.max).toBe(4);
+      chart.pan(-100);
+      expect(chart.scales.y.min).toBe(4);
+      expect(chart.scales.y.max).toBe(5);
+      chart.zoom(0.9);
+      expect(chart.scales.y.min).toBe(3);
+      expect(chart.scales.y.max).toBe(5);
+      chart.zoom(0.9);
+      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);
+      expect(chart.scales.y.max).toBe(5);
+      chart.pan(-100);
+      expect(chart.scales.y.min).toBe(1);
+      expect(chart.scales.y.max).toBe(5);
+      chart.pan(100);
+      expect(chart.scales.y.min).toBe(1);
+      expect(chart.scales.y.max).toBe(5);
+    });
+  });
+
+  describe('with logarithmic scale', function() {
+    it('should zoom in and out', function() {
+      const chart = window.acquireChart({
+        type: 'bar',
+        data: {
+          labels: ['a', 'b', 'c', 'd', 'e'],
+          datasets: [{
+            data: [1, 22, 3333, 22, 1],
+          }]
+        },
+        options: {
+          scales: {
+            y: {
+              type: 'logarithmic',
+            }
+          },
+        }
+      });
+      expect(chart.scales.y.min).toBe(0.1);
+      expect(chart.scales.y.max).toBe(4000);
+      chart.zoom(1.1);
+      expect(chart.scales.y.min).toBeCloseTo(0.17, 2);
+      expect(chart.scales.y.max).toBeCloseTo(2355, 0);
+      chart.zoom(0.9);
+      expect(chart.scales.y.min).toBeCloseTo(0.105, 3);
+      expect(chart.scales.y.max).toBeCloseTo(3794, 0);
+      chart.zoom(0.9);
+      expect(chart.scales.y.min).toBeCloseTo(0.06, 2);
+      expect(chart.scales.y.max).toBeCloseTo(6410, 0);
+      chart.resetZoom();
+      expect(chart.scales.y.min).toBe(0.1);
+      expect(chart.scales.y.max).toBe(4000);
+    });
+  });
 });
diff --git a/test/specs/fixtures.spec.js b/test/specs/fixtures.spec.js
new file mode 100644
index 0000000..7161c43
--- /dev/null
+++ b/test/specs/fixtures.spec.js
@@ -0,0 +1,4 @@
+describe('fixtures', function() {
+  describe('zoom', jasmine.fixture.specs('zoom'));
+  describe('pan', jasmine.fixture.specs('pan'));
+});
diff --git a/test/specs/pan.spec.js b/test/specs/pan.spec.js
index 072e757..ca5d42b 100644
--- a/test/specs/pan.spec.js
+++ b/test/specs/pan.spec.js
@@ -1,6 +1,4 @@
 describe('pan', function() {
-  describe('auto', jasmine.fixture.specs('pan'));
-
   const data = {
     labels: ['a', 'b', 'c', 'd', 'e'],
     datasets: [{
diff --git a/test/specs/zoom.drag.spec.js b/test/specs/zoom.drag.spec.js
new file mode 100644
index 0000000..d9ff260
--- /dev/null
+++ b/test/specs/zoom.drag.spec.js
@@ -0,0 +1,493 @@
+describe('zoom with drag', function() {
+  const data = {
+    datasets: [{
+      data: [{
+        x: 1,
+        y: 3
+      }, {
+        x: 2,
+        y: 2
+      }, {
+        x: 3,
+        y: 1
+      }]
+    }]
+  };
+
+  describe('on linear scale', function() {
+    let chart, scaleX, scaleY;
+
+    it('should be applied on X scale when mode = x', function() {
+      chart = window.acquireChart({
+        type: 'line',
+        data,
+        options: {
+          scales: {
+            xScale0: {
+              id: 'xScale0',
+              type: 'linear'
+            },
+            yScale0: {
+              id: 'yScale0',
+              type: 'linear'
+            }
+          },
+          plugins: {
+            zoom: {
+              zoom: {
+                drag: {
+                  enabled: true
+                },
+                mode: 'x'
+              }
+            }
+          }
+        }
+      });
+
+      scaleX = chart.scales.xScale0;
+      scaleY = chart.scales.yScale0;
+
+      jasmine.triggerMouseEvent(chart, 'mousedown', {
+        x: scaleX.getPixelForValue(1.5),
+        y: scaleY.getPixelForValue(1.1)
+      });
+      jasmine.triggerMouseEvent(chart, 'mouseup', {
+        x: scaleX.getPixelForValue(2.8),
+        y: scaleY.getPixelForValue(1.7)
+      });
+
+      expect(scaleX.options.min).toBeCloseTo(1.5);
+      expect(scaleX.options.max).toBeCloseTo(2.8);
+      expect(scaleY.options.min).toBeUndefined();
+      expect(scaleY.options.max).toBeUndefined();
+    });
+
+    it('should be applied on X scale when mode = f() => x', function() {
+      chart = window.acquireChart({
+        type: 'line',
+        data,
+        options: {
+          scales: {
+            xScale0: {
+              id: 'xScale0',
+              type: 'linear'
+            },
+            yScale0: {
+              id: 'yScale0',
+              type: 'linear'
+            }
+          },
+          plugins: {
+            zoom: {
+              zoom: {
+                drag: {
+                  enabled: true
+                },
+                mode: function() {
+                  return 'x';
+                }
+              }
+            }
+          }
+        }
+      });
+
+      scaleX = chart.scales.xScale0;
+      scaleY = chart.scales.yScale0;
+
+      jasmine.triggerMouseEvent(chart, 'mousedown', {
+        x: scaleX.getPixelForValue(1.5),
+        y: scaleY.getPixelForValue(1.1)
+      });
+      jasmine.triggerMouseEvent(chart, 'mouseup', {
+        x: scaleX.getPixelForValue(2.8),
+        y: scaleY.getPixelForValue(1.7)
+      });
+
+      expect(scaleX.options.min).toBeCloseTo(1.5);
+      expect(scaleX.options.max).toBeCloseTo(2.8);
+      expect(scaleY.options.min).toBeUndefined();
+      expect(scaleY.options.max).toBeUndefined();
+    });
+
+    it('should be applied on Y scale when mode = y', function() {
+      chart = window.acquireChart({
+        type: 'line',
+        data,
+        options: {
+          scales: {
+            xScale0: {
+              id: 'xScale0',
+              type: 'linear'
+            },
+            yScale0: {
+              id: 'yScale0',
+              type: 'linear'
+            }
+          },
+          plugins: {
+            zoom: {
+              zoom: {
+                drag: {
+                  enabled: true
+                },
+                mode: 'y'
+              }
+            }
+          }
+        }
+      });
+
+      scaleX = chart.scales.xScale0;
+      scaleY = chart.scales.yScale0;
+
+      jasmine.triggerMouseEvent(chart, 'mousedown', {
+        x: scaleX.getPixelForValue(1.5),
+        y: scaleY.getPixelForValue(1.1)
+      });
+      jasmine.triggerMouseEvent(chart, 'mouseup', {
+        x: scaleX.getPixelForValue(2.8),
+        y: scaleY.getPixelForValue(1.7)
+      });
+
+      expect(scaleX.options.min).toBeUndefined();
+      expect(scaleX.options.max).toBeUndefined();
+      expect(scaleY.options.min).toBeCloseTo(1.1);
+      expect(scaleY.options.max).toBeCloseTo(1.7);
+    });
+
+    it('should be applied on Y scale when mode = f() => y', function() {
+      chart = window.acquireChart({
+        type: 'line',
+        data,
+        options: {
+          scales: {
+            xScale0: {
+              id: 'xScale0',
+              type: 'linear'
+            },
+            yScale0: {
+              id: 'yScale0',
+              type: 'linear'
+            }
+          },
+          plugins: {
+            zoom: {
+              zoom: {
+                drag: {
+                  enabled: true
+                },
+                mode: function() {
+                  return 'y';
+                }
+              }
+            }
+          }
+        }
+      });
+
+      scaleX = chart.scales.xScale0;
+      scaleY = chart.scales.yScale0;
+
+      jasmine.triggerMouseEvent(chart, 'mousedown', {
+        x: scaleX.getPixelForValue(1.5),
+        y: scaleY.getPixelForValue(1.1)
+      });
+      jasmine.triggerMouseEvent(chart, 'mouseup', {
+        x: scaleX.getPixelForValue(2.8),
+        y: scaleY.getPixelForValue(1.7)
+      });
+
+      expect(scaleX.options.min).toBeUndefined();
+      expect(scaleX.options.max).toBeUndefined();
+      expect(scaleY.options.min).toBeCloseTo(1.1);
+      expect(scaleY.options.max).toBeCloseTo(1.7);
+    });
+
+    it('should be applied on X and Y scales when mode = xy', function() {
+      chart = window.acquireChart({
+        type: 'line',
+        data,
+        options: {
+          scales: {
+            xScale0: {
+              id: 'xScale0',
+              type: 'linear'
+            },
+            yScale0: {
+              id: 'yScale0',
+              type: 'linear'
+            }
+          },
+          plugins: {
+            zoom: {
+              zoom: {
+                drag: {
+                  enabled: true
+                },
+                mode: 'xy'
+              }
+            }
+          }
+        }
+      });
+
+      scaleX = chart.scales.xScale0;
+      scaleY = chart.scales.yScale0;
+
+      jasmine.triggerMouseEvent(chart, 'mousedown', {
+        x: scaleX.getPixelForValue(1.5),
+        y: scaleY.getPixelForValue(1.1)
+      });
+      jasmine.triggerMouseEvent(chart, 'mouseup', {
+        x: scaleX.getPixelForValue(2.8),
+        y: scaleY.getPixelForValue(1.7)
+      });
+
+      expect(scaleX.options.min).toBeCloseTo(1.5);
+      expect(scaleX.options.max).toBeCloseTo(2.8);
+      expect(scaleY.options.min).toBeCloseTo(1.1);
+      expect(scaleY.options.max).toBeCloseTo(1.7);
+    });
+  });
+
+  describe('with modifierKey', function() {
+    for (const key of ['ctrl', 'alt', 'shift', 'meta']) {
+      for (const pressed of [true, false]) {
+        let chart, scaleX, scaleY;
+        it(`should ${pressed ? '' : 'not '}change ${pressed ? 'with' : 'without'} key ${key}`, async function() {
+          const rejectedSpy = jasmine.createSpy('wheelFailed');
+          chart = window.acquireChart({
+            type: 'line',
+            data,
+            options: {
+              scales: {
+                x: {
+                  type: 'linear',
+                  min: 0,
+                  max: 10
+                },
+                y: {
+                  type: 'linear'
+                }
+              },
+              plugins: {
+                zoom: {
+                  zoom: {
+                    drag: {
+                      enabled: true,
+                      modifierKey: key,
+                    },
+                    mode: 'x',
+                    onZoomRejected: rejectedSpy
+                  }
+                }
+              }
+            }
+          });
+
+          scaleX = chart.scales.x;
+          scaleY = chart.scales.y;
+
+          const oldMinX = scaleX.options.min;
+          const oldMaxX = scaleX.options.max;
+
+          const pt = {
+            x: scaleX.getPixelForValue(1.5),
+            y: scaleY.getPixelForValue(1.1),
+          };
+          const pt2 = {x: pt.x + 20, y: pt.y + 20};
+          const init = {};
+          if (pressed) {
+            init[key + 'Key'] = true;
+          }
+
+          jasmine.dispatchEvent(chart, 'mousedown', pt, init);
+          jasmine.dispatchEvent(chart, 'mousemove', pt2, init);
+          jasmine.dispatchEvent(chart, 'mouseup', pt2, init);
+
+          if (pressed) {
+            expect(scaleX.options.min).not.toEqual(oldMinX);
+            expect(scaleX.options.max).not.toEqual(oldMaxX);
+            expect(rejectedSpy).not.toHaveBeenCalled();
+          } else {
+            expect(scaleX.options.min).toEqual(oldMinX);
+            expect(scaleX.options.max).toEqual(oldMaxX);
+            expect(rejectedSpy).toHaveBeenCalled();
+          }
+        });
+      }
+    }
+  });
+
+  describe('drag with pan.modifierKey', function() {
+    for (const key of ['ctrl', 'alt', 'shift', 'meta']) {
+      for (const pressed of [true, false]) {
+        let chart, scaleX, scaleY;
+        it(`should ${pressed ? 'not ' : ''}change ${pressed ? 'without' : 'with'} key ${key}`, async function() {
+          const rejectedSpy = jasmine.createSpy('zoomRejected');
+          const clickSpy = jasmine.createSpy('clicked');
+          chart = window.acquireChart({
+            type: 'line',
+            data,
+            options: {
+              scales: {
+                x: {
+                  type: 'linear',
+                  min: 0,
+                  max: 10
+                },
+                y: {
+                  type: 'linear'
+                }
+              },
+              plugins: {
+                zoom: {
+                  pan: {
+                    enabled: true,
+                    modifierKey: key,
+                  },
+                  zoom: {
+                    drag: {
+                      enabled: true,
+                    },
+                    mode: 'x',
+                    onZoomRejected: rejectedSpy
+                  }
+                }
+              },
+              onClick: clickSpy
+            }
+          });
+
+          scaleX = chart.scales.x;
+          scaleY = chart.scales.y;
+
+          const oldMinX = scaleX.options.min;
+          const oldMaxX = scaleX.options.max;
+
+          const pt = {
+            x: scaleX.getPixelForValue(1.5),
+            y: scaleY.getPixelForValue(1.1),
+          };
+          const pt2 = {x: pt.x + 20, y: pt.y + 20};
+          const init = {};
+          if (pressed) {
+            init[key + 'Key'] = true;
+          }
+
+          jasmine.dispatchEvent(chart, 'mousedown', pt, init);
+          jasmine.dispatchEvent(chart, 'mousemove', pt2, init);
+          jasmine.dispatchEvent(chart, 'mouseup', pt2, init);
+
+          if (pressed) {
+            expect(scaleX.options.min).toEqual(oldMinX);
+            expect(scaleX.options.max).toEqual(oldMaxX);
+            expect(rejectedSpy).toHaveBeenCalled();
+          } else {
+            expect(scaleX.options.min).not.toEqual(oldMinX);
+            expect(scaleX.options.max).not.toEqual(oldMaxX);
+            expect(rejectedSpy).not.toHaveBeenCalled();
+          }
+          expect(clickSpy).not.toHaveBeenCalled();
+        });
+      }
+    }
+  });
+
+  describe('events', function() {
+    it('should call onZoomStart, onZoom and onZoomComplete', function(done) {
+      const startSpy = jasmine.createSpy('start');
+      const zoomSpy = jasmine.createSpy('zoom');
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              zoom: {
+                drag: {
+                  enabled: true,
+                },
+                mode: 'xy',
+                onZoomStart: startSpy,
+                onZoom: zoomSpy,
+                onZoomComplete: () => done()
+              }
+            }
+          }
+        }
+      });
+
+      const pt = {
+        x: chart.scales.x.getPixelForValue(1.5),
+        y: chart.scales.y.getPixelForValue(1.1),
+      };
+      const pt2 = {x: pt.x + 20, y: pt.y + 20};
+
+      expect(chart.isZoomingOrPanning()).toBe(false);
+
+      jasmine.dispatchEvent(chart, 'mousedown', pt);
+      jasmine.dispatchEvent(chart, 'mousemove', pt2);
+
+      expect(chart.isZoomingOrPanning()).toBe(true);
+
+      jasmine.dispatchEvent(chart, 'mouseup', pt2);
+
+      // Drag state isn't cleared until a timeout fires (later), so we can't
+      // easily test this here.
+      // expect(chart.isZoomingOrPanning()).toBe(false);
+
+      expect(startSpy).toHaveBeenCalled();
+      expect(zoomSpy).toHaveBeenCalled();
+    });
+
+    it('should call onZoomRejected when onZoomStart returns false', function() {
+      const zoomSpy = jasmine.createSpy('zoom');
+      const rejectSpy = jasmine.createSpy('reject');
+      const doneSpy = jasmine.createSpy('done');
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              zoom: {
+                drag: {
+                  enabled: true,
+                },
+                mode: 'xy',
+                onZoomStart: () => false,
+                onZoom: zoomSpy,
+                onZoomComplete: doneSpy,
+                onZoomRejected: rejectSpy
+              }
+            }
+          }
+        }
+      });
+
+      const pt = {
+        x: chart.scales.x.getPixelForValue(1.5),
+        y: chart.scales.y.getPixelForValue(1.1),
+      };
+      const pt2 = {x: pt.x + 20, y: pt.y + 20};
+
+      expect(chart.isZoomingOrPanning()).toBe(false);
+
+      jasmine.dispatchEvent(chart, 'mousedown', pt);
+
+      expect(chart.isZoomingOrPanning()).toBe(false);
+
+      jasmine.dispatchEvent(chart, 'mousemove', pt2);
+      jasmine.dispatchEvent(chart, 'mouseup', pt2);
+
+      expect(chart.isZoomingOrPanning()).toBe(false);
+
+      expect(rejectSpy).toHaveBeenCalled();
+      expect(zoomSpy).not.toHaveBeenCalled();
+      expect(doneSpy).not.toHaveBeenCalled();
+    });
+  });
+});
diff --git a/test/specs/zoom.spec.js b/test/specs/zoom.spec.js
deleted file mode 100644
index 615bc6a..0000000
--- a/test/specs/zoom.spec.js
+++ /dev/null
@@ -1,965 +0,0 @@
-describe('zoom', function() {
-  describe('auto', jasmine.fixture.specs('zoom'));
-
-  const data = {
-    datasets: [{
-      data: [{
-        x: 1,
-        y: 3
-      }, {
-        x: 2,
-        y: 2
-      }, {
-        x: 3,
-        y: 1
-      }]
-    }]
-  };
-
-  describe('with drag', function() {
-    describe('on linear scale', function() {
-      let chart, scaleX, scaleY;
-
-      it('should be applied on X scale when mode = x', function() {
-        chart = window.acquireChart({
-          type: 'line',
-          data,
-          options: {
-            scales: {
-              xScale0: {
-                id: 'xScale0',
-                type: 'linear'
-              },
-              yScale0: {
-                id: 'yScale0',
-                type: 'linear'
-              }
-            },
-            plugins: {
-              zoom: {
-                zoom: {
-                  drag: {
-                    enabled: true
-                  },
-                  mode: 'x'
-                }
-              }
-            }
-          }
-        });
-
-        scaleX = chart.scales.xScale0;
-        scaleY = chart.scales.yScale0;
-
-        jasmine.triggerMouseEvent(chart, 'mousedown', {
-          x: scaleX.getPixelForValue(1.5),
-          y: scaleY.getPixelForValue(1.1)
-        });
-        jasmine.triggerMouseEvent(chart, 'mouseup', {
-          x: scaleX.getPixelForValue(2.8),
-          y: scaleY.getPixelForValue(1.7)
-        });
-
-        expect(scaleX.options.min).toBeCloseTo(1.5);
-        expect(scaleX.options.max).toBeCloseTo(2.8);
-        expect(scaleY.options.min).toBeUndefined();
-        expect(scaleY.options.max).toBeUndefined();
-      });
-
-      it('should be applied on X scale when mode = f() => x', function() {
-        chart = window.acquireChart({
-          type: 'line',
-          data,
-          options: {
-            scales: {
-              xScale0: {
-                id: 'xScale0',
-                type: 'linear'
-              },
-              yScale0: {
-                id: 'yScale0',
-                type: 'linear'
-              }
-            },
-            plugins: {
-              zoom: {
-                zoom: {
-                  drag: {
-                    enabled: true
-                  },
-                  mode: function() {
-                    return 'x';
-                  }
-                }
-              }
-            }
-          }
-        });
-
-        scaleX = chart.scales.xScale0;
-        scaleY = chart.scales.yScale0;
-
-        jasmine.triggerMouseEvent(chart, 'mousedown', {
-          x: scaleX.getPixelForValue(1.5),
-          y: scaleY.getPixelForValue(1.1)
-        });
-        jasmine.triggerMouseEvent(chart, 'mouseup', {
-          x: scaleX.getPixelForValue(2.8),
-          y: scaleY.getPixelForValue(1.7)
-        });
-
-        expect(scaleX.options.min).toBeCloseTo(1.5);
-        expect(scaleX.options.max).toBeCloseTo(2.8);
-        expect(scaleY.options.min).toBeUndefined();
-        expect(scaleY.options.max).toBeUndefined();
-      });
-
-      it('should be applied on Y scale when mode = y', function() {
-        chart = window.acquireChart({
-          type: 'line',
-          data,
-          options: {
-            scales: {
-              xScale0: {
-                id: 'xScale0',
-                type: 'linear'
-              },
-              yScale0: {
-                id: 'yScale0',
-                type: 'linear'
-              }
-            },
-            plugins: {
-              zoom: {
-                zoom: {
-                  drag: {
-                    enabled: true
-                  },
-                  mode: 'y'
-                }
-              }
-            }
-          }
-        });
-
-        scaleX = chart.scales.xScale0;
-        scaleY = chart.scales.yScale0;
-
-        jasmine.triggerMouseEvent(chart, 'mousedown', {
-          x: scaleX.getPixelForValue(1.5),
-          y: scaleY.getPixelForValue(1.1)
-        });
-        jasmine.triggerMouseEvent(chart, 'mouseup', {
-          x: scaleX.getPixelForValue(2.8),
-          y: scaleY.getPixelForValue(1.7)
-        });
-
-        expect(scaleX.options.min).toBeUndefined();
-        expect(scaleX.options.max).toBeUndefined();
-        expect(scaleY.options.min).toBeCloseTo(1.1);
-        expect(scaleY.options.max).toBeCloseTo(1.7);
-      });
-
-      it('should be applied on Y scale when mode = f() => y', function() {
-        chart = window.acquireChart({
-          type: 'line',
-          data,
-          options: {
-            scales: {
-              xScale0: {
-                id: 'xScale0',
-                type: 'linear'
-              },
-              yScale0: {
-                id: 'yScale0',
-                type: 'linear'
-              }
-            },
-            plugins: {
-              zoom: {
-                zoom: {
-                  drag: {
-                    enabled: true
-                  },
-                  mode: function() {
-                    return 'y';
-                  }
-                }
-              }
-            }
-          }
-        });
-
-        scaleX = chart.scales.xScale0;
-        scaleY = chart.scales.yScale0;
-
-        jasmine.triggerMouseEvent(chart, 'mousedown', {
-          x: scaleX.getPixelForValue(1.5),
-          y: scaleY.getPixelForValue(1.1)
-        });
-        jasmine.triggerMouseEvent(chart, 'mouseup', {
-          x: scaleX.getPixelForValue(2.8),
-          y: scaleY.getPixelForValue(1.7)
-        });
-
-        expect(scaleX.options.min).toBeUndefined();
-        expect(scaleX.options.max).toBeUndefined();
-        expect(scaleY.options.min).toBeCloseTo(1.1);
-        expect(scaleY.options.max).toBeCloseTo(1.7);
-      });
-
-      it('should be applied on X and Y scales when mode = xy', function() {
-        chart = window.acquireChart({
-          type: 'line',
-          data,
-          options: {
-            scales: {
-              xScale0: {
-                id: 'xScale0',
-                type: 'linear'
-              },
-              yScale0: {
-                id: 'yScale0',
-                type: 'linear'
-              }
-            },
-            plugins: {
-              zoom: {
-                zoom: {
-                  drag: {
-                    enabled: true
-                  },
-                  mode: 'xy'
-                }
-              }
-            }
-          }
-        });
-
-        scaleX = chart.scales.xScale0;
-        scaleY = chart.scales.yScale0;
-
-        jasmine.triggerMouseEvent(chart, 'mousedown', {
-          x: scaleX.getPixelForValue(1.5),
-          y: scaleY.getPixelForValue(1.1)
-        });
-        jasmine.triggerMouseEvent(chart, 'mouseup', {
-          x: scaleX.getPixelForValue(2.8),
-          y: scaleY.getPixelForValue(1.7)
-        });
-
-        expect(scaleX.options.min).toBeCloseTo(1.5);
-        expect(scaleX.options.max).toBeCloseTo(2.8);
-        expect(scaleY.options.min).toBeCloseTo(1.1);
-        expect(scaleY.options.max).toBeCloseTo(1.7);
-      });
-    });
-  });
-
-  describe('with modifierKey', function() {
-    for (const key of ['ctrl', 'alt', 'shift', 'meta']) {
-      for (const pressed of [true, false]) {
-        let chart, scaleX, scaleY;
-        it(`should ${pressed ? '' : 'not '}change ${pressed ? 'with' : 'without'} key ${key}`, async function() {
-          const rejectedSpy = jasmine.createSpy('wheelFailed');
-          chart = window.acquireChart({
-            type: 'line',
-            data,
-            options: {
-              scales: {
-                x: {
-                  type: 'linear',
-                  min: 0,
-                  max: 10
-                },
-                y: {
-                  type: 'linear'
-                }
-              },
-              plugins: {
-                zoom: {
-                  zoom: {
-                    wheel: {
-                      enabled: true,
-                      modifierKey: key,
-                    },
-                    mode: 'x',
-                    onZoomRejected: rejectedSpy
-                  }
-                }
-              }
-            }
-          });
-
-          scaleX = chart.scales.x;
-          scaleY = chart.scales.y;
-
-          const oldMinX = scaleX.options.min;
-          const oldMaxX = scaleX.options.max;
-
-          const wheelEv = {
-            x: scaleX.getPixelForValue(1.5),
-            y: scaleY.getPixelForValue(1.1),
-            deltaY: 1
-          };
-          if (pressed) {
-            wheelEv[key + 'Key'] = true;
-          }
-
-          jasmine.triggerWheelEvent(chart, wheelEv);
-
-          if (pressed) {
-            expect(scaleX.options.min).not.toEqual(oldMinX);
-            expect(scaleX.options.max).not.toEqual(oldMaxX);
-            expect(rejectedSpy).not.toHaveBeenCalled();
-          } else {
-            expect(scaleX.options.min).toEqual(oldMinX);
-            expect(scaleX.options.max).toEqual(oldMaxX);
-            expect(rejectedSpy).toHaveBeenCalled();
-          }
-        });
-      }
-    }
-  });
-
-  describe('drag with pan.modifierKey', function() {
-    for (const key of ['ctrl', 'alt', 'shift', 'meta']) {
-      for (const pressed of [true, false]) {
-        let chart, scaleX, scaleY;
-        it(`should ${pressed ? 'not ' : ''}change ${pressed ? 'without' : 'with'} key ${key}`, async function() {
-          const rejectedSpy = jasmine.createSpy('zoomRejected');
-          const clickSpy = jasmine.createSpy('clicked');
-          chart = window.acquireChart({
-            type: 'line',
-            data,
-            options: {
-              scales: {
-                x: {
-                  type: 'linear',
-                  min: 0,
-                  max: 10
-                },
-                y: {
-                  type: 'linear'
-                }
-              },
-              plugins: {
-                zoom: {
-                  pan: {
-                    enabled: true,
-                    modifierKey: key,
-                  },
-                  zoom: {
-                    drag: {
-                      enabled: true,
-                    },
-                    mode: 'x',
-                    onZoomRejected: rejectedSpy
-                  }
-                }
-              },
-              onClick: clickSpy
-            }
-          });
-
-          scaleX = chart.scales.x;
-          scaleY = chart.scales.y;
-
-          const oldMinX = scaleX.options.min;
-          const oldMaxX = scaleX.options.max;
-
-          const pt = {
-            x: scaleX.getPixelForValue(1.5),
-            y: scaleY.getPixelForValue(1.1),
-          };
-          const pt2 = {x: pt.x + 20, y: pt.y + 20};
-          const init = {};
-          if (pressed) {
-            init[key + 'Key'] = true;
-          }
-
-          jasmine.dispatchEvent(chart, 'mousedown', pt, init);
-          jasmine.dispatchEvent(chart, 'mousemove', pt2, init);
-          jasmine.dispatchEvent(chart, 'mouseup', pt2, init);
-
-          if (pressed) {
-            expect(scaleX.options.min).toEqual(oldMinX);
-            expect(scaleX.options.max).toEqual(oldMaxX);
-            expect(rejectedSpy).toHaveBeenCalled();
-          } else {
-            expect(scaleX.options.min).not.toEqual(oldMinX);
-            expect(scaleX.options.max).not.toEqual(oldMaxX);
-            expect(rejectedSpy).not.toHaveBeenCalled();
-          }
-          expect(clickSpy).not.toHaveBeenCalled();
-        });
-      }
-    }
-  });
-
-  describe('drag with modifierKey', function() {
-    for (const key of ['ctrl', 'alt', 'shift', 'meta']) {
-      for (const pressed of [true, false]) {
-        let chart, scaleX, scaleY;
-        it(`should ${pressed ? '' : 'not '}change ${pressed ? 'with' : 'without'} key ${key}`, async function() {
-          const rejectedSpy = jasmine.createSpy('wheelFailed');
-          chart = window.acquireChart({
-            type: 'line',
-            data,
-            options: {
-              scales: {
-                x: {
-                  type: 'linear',
-                  min: 0,
-                  max: 10
-                },
-                y: {
-                  type: 'linear'
-                }
-              },
-              plugins: {
-                zoom: {
-                  zoom: {
-                    drag: {
-                      enabled: true,
-                      modifierKey: key,
-                    },
-                    mode: 'x',
-                    onZoomRejected: rejectedSpy
-                  }
-                }
-              }
-            }
-          });
-
-          scaleX = chart.scales.x;
-          scaleY = chart.scales.y;
-
-          const oldMinX = scaleX.options.min;
-          const oldMaxX = scaleX.options.max;
-
-          const pt = {
-            x: scaleX.getPixelForValue(1.5),
-            y: scaleY.getPixelForValue(1.1),
-          };
-          const pt2 = {x: pt.x + 20, y: pt.y + 20};
-          const init = {};
-          if (pressed) {
-            init[key + 'Key'] = true;
-          }
-
-          jasmine.dispatchEvent(chart, 'mousedown', pt, init);
-          jasmine.dispatchEvent(chart, 'mousemove', pt2, init);
-          jasmine.dispatchEvent(chart, 'mouseup', pt2, init);
-
-          if (pressed) {
-            expect(scaleX.options.min).not.toEqual(oldMinX);
-            expect(scaleX.options.max).not.toEqual(oldMaxX);
-            expect(rejectedSpy).not.toHaveBeenCalled();
-          } else {
-            expect(scaleX.options.min).toEqual(oldMinX);
-            expect(scaleX.options.max).toEqual(oldMaxX);
-            expect(rejectedSpy).toHaveBeenCalled();
-          }
-        });
-      }
-    }
-  });
-
-  describe('with overScaleMode = 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',
-              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);
-      });
-    });
-  });
-
-  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() {
-        const startSpy = jasmine.createSpy('started');
-        const chart = window.acquireChart({
-          type: 'scatter',
-          data,
-          options: {
-            plugins: {
-              zoom: {
-                zoom: {
-                  wheel: {
-                    enabled: true,
-                  },
-                  mode: 'xy',
-                  onZoomStart: startSpy
-                }
-              }
-            }
-          }
-        });
-        const wheelEv = {
-          x: chart.scales.x.getPixelForValue(1.5),
-          y: chart.scales.y.getPixelForValue(1.1),
-          deltaY: 1
-        };
-        jasmine.triggerWheelEvent(chart, wheelEv);
-        expect(startSpy).toHaveBeenCalled();
-        expect(chart.scales.x.min).not.toBe(1);
-      });
-
-      it('should detect configuration change', function() {
-        const startSpy = jasmine.createSpy('started');
-        const chart = window.acquireChart({
-          type: 'scatter',
-          data,
-          options: {
-            plugins: {
-              zoom: {
-                zoom: {
-                  wheel: {
-                    enabled: false,
-                  },
-                  mode: 'xy',
-                  onZoomStart: startSpy
-                }
-              }
-            }
-          }
-        });
-        const wheelEv = {
-          x: chart.scales.x.getPixelForValue(1.5),
-          y: chart.scales.y.getPixelForValue(1.1),
-          deltaY: 1
-        };
-        jasmine.triggerWheelEvent(chart, wheelEv);
-        expect(startSpy).not.toHaveBeenCalled();
-        expect(chart.scales.x.min).toBe(1);
-
-        chart.options.plugins.zoom.zoom.wheel.enabled = true;
-        chart.update();
-
-        jasmine.triggerWheelEvent(chart, wheelEv);
-        expect(startSpy).toHaveBeenCalled();
-        expect(chart.scales.x.min).not.toBe(1);
-      });
-
-      it('should call onZoomRejected when onZoomStart returns false', function() {
-        const rejectSpy = jasmine.createSpy('rejected');
-        const chart = window.acquireChart({
-          type: 'scatter',
-          data,
-          options: {
-            plugins: {
-              zoom: {
-                zoom: {
-                  wheel: {
-                    enabled: true,
-                  },
-                  mode: 'xy',
-                  onZoomStart: () => false,
-                  onZoomRejected: rejectSpy
-                }
-              }
-            }
-          }
-        });
-        const wheelEv = {
-          x: chart.scales.x.getPixelForValue(1.5),
-          y: chart.scales.y.getPixelForValue(1.1),
-          deltaY: 1
-        };
-        jasmine.triggerWheelEvent(chart, wheelEv);
-        expect(rejectSpy).toHaveBeenCalled();
-        expect(chart.scales.x.min).toBe(1);
-      });
-
-      it('should call onZoomComplete', function(done) {
-        const chart = window.acquireChart({
-          type: 'scatter',
-          data,
-          options: {
-            plugins: {
-              zoom: {
-                zoom: {
-                  wheel: {
-                    enabled: true,
-                  },
-                  mode: 'xy',
-                  onZoomComplete(ctx) {
-                    expect(ctx.chart.scales.x.min).not.toBe(1);
-                    done();
-                  }
-                }
-              }
-            }
-          }
-        });
-        const wheelEv = {
-          x: chart.scales.x.getPixelForValue(1.5),
-          y: chart.scales.y.getPixelForValue(1.1),
-          deltaY: 1
-        };
-        jasmine.triggerWheelEvent(chart, wheelEv);
-      });
-    });
-
-    describe('drag', function() {
-      it('should call onZoomStart, onZoom and onZoomComplete', function(done) {
-        const startSpy = jasmine.createSpy('start');
-        const zoomSpy = jasmine.createSpy('zoom');
-        const chart = window.acquireChart({
-          type: 'scatter',
-          data,
-          options: {
-            plugins: {
-              zoom: {
-                zoom: {
-                  drag: {
-                    enabled: true,
-                  },
-                  mode: 'xy',
-                  onZoomStart: startSpy,
-                  onZoom: zoomSpy,
-                  onZoomComplete: () => done()
-                }
-              }
-            }
-          }
-        });
-
-        const pt = {
-          x: chart.scales.x.getPixelForValue(1.5),
-          y: chart.scales.y.getPixelForValue(1.1),
-        };
-        const pt2 = {x: pt.x + 20, y: pt.y + 20};
-
-        expect(chart.isZoomingOrPanning()).toBe(false);
-
-        jasmine.dispatchEvent(chart, 'mousedown', pt);
-        jasmine.dispatchEvent(chart, 'mousemove', pt2);
-
-        expect(chart.isZoomingOrPanning()).toBe(true);
-
-        jasmine.dispatchEvent(chart, 'mouseup', pt2);
-
-        // Drag state isn't cleared until a timeout fires (later), so we can't
-        // easily test this here.
-        // expect(chart.isZoomingOrPanning()).toBe(false);
-
-        expect(startSpy).toHaveBeenCalled();
-        expect(zoomSpy).toHaveBeenCalled();
-      });
-
-      it('should call onZoomRejected when onZoomStart returns false', function() {
-        const zoomSpy = jasmine.createSpy('zoom');
-        const rejectSpy = jasmine.createSpy('reject');
-        const doneSpy = jasmine.createSpy('done');
-        const chart = window.acquireChart({
-          type: 'scatter',
-          data,
-          options: {
-            plugins: {
-              zoom: {
-                zoom: {
-                  drag: {
-                    enabled: true,
-                  },
-                  mode: 'xy',
-                  onZoomStart: () => false,
-                  onZoom: zoomSpy,
-                  onZoomComplete: doneSpy,
-                  onZoomRejected: rejectSpy
-                }
-              }
-            }
-          }
-        });
-
-        const pt = {
-          x: chart.scales.x.getPixelForValue(1.5),
-          y: chart.scales.y.getPixelForValue(1.1),
-        };
-        const pt2 = {x: pt.x + 20, y: pt.y + 20};
-
-        expect(chart.isZoomingOrPanning()).toBe(false);
-
-        jasmine.dispatchEvent(chart, 'mousedown', pt);
-
-        expect(chart.isZoomingOrPanning()).toBe(false);
-
-        jasmine.dispatchEvent(chart, 'mousemove', pt2);
-        jasmine.dispatchEvent(chart, 'mouseup', pt2);
-
-        expect(chart.isZoomingOrPanning()).toBe(false);
-
-        expect(rejectSpy).toHaveBeenCalled();
-        expect(zoomSpy).not.toHaveBeenCalled();
-        expect(doneSpy).not.toHaveBeenCalled();
-      });
-    });
-  });
-
-  describe('category scale', function() {
-    it('should zoom up to and out from single category', function() {
-      const chart = window.acquireChart({
-        type: 'bar',
-        data: {
-          labels: ['a', 'b', 'c', 'd', 'e'],
-          datasets: [{
-            data: [1, 2, 3, 2, 1]
-          }]
-        },
-        options: {
-          scales: {
-            x: {
-              min: 'b',
-              max: 'd'
-            }
-          },
-          plugins: {
-            zoom: {
-              zoom: {
-                wheel: {
-                  enabled: true,
-                },
-              }
-            }
-          }
-        }
-      });
-      expect(chart.scales.x.min).toBe(1);
-      expect(chart.scales.x.max).toBe(3);
-      chart.zoom(1.1);
-      expect(chart.scales.x.min).toBe(2);
-      expect(chart.scales.x.max).toBe(2);
-      chart.zoom(0.9);
-      expect(chart.scales.x.min).toBe(1);
-      expect(chart.scales.x.max).toBe(3);
-      chart.zoom(0.9);
-      expect(chart.scales.x.min).toBe(0);
-      expect(chart.scales.x.max).toBe(4);
-      chart.resetZoom();
-      expect(chart.scales.x.min).toBe(1);
-      expect(chart.scales.x.max).toBe(3);
-    });
-
-    it('should not exceed limits', function() {
-      const chart = window.acquireChart({
-        type: 'bar',
-        data: {
-          labels: ['0', '1', '2', '3', '4', '5', '6'],
-          datasets: [{
-            data: [1, 2, 3, 2, 1, 0, 1]
-          }]
-        },
-        options: {
-          indexAxis: 'y',
-          scales: {
-            y: {
-              min: 2,
-              max: 4
-            }
-          },
-          plugins: {
-            zoom: {
-              limits: {
-                y: {
-                  min: 1,
-                  max: 5,
-                  minRange: 1
-                }
-              },
-              zoom: {
-                wheel: {
-                  enabled: true,
-                },
-                mode: 'y'
-              }
-            }
-          }
-        }
-      });
-      expect(chart.scales.y.min).toBe(2);
-      expect(chart.scales.y.max).toBe(4);
-      chart.zoom(1.1);
-      expect(chart.scales.y.min).toBe(3);
-      expect(chart.scales.y.max).toBe(4);
-      chart.pan(-100);
-      expect(chart.scales.y.min).toBe(4);
-      expect(chart.scales.y.max).toBe(5);
-      chart.zoom(0.9);
-      expect(chart.scales.y.min).toBe(3);
-      expect(chart.scales.y.max).toBe(5);
-      chart.zoom(0.9);
-      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);
-      expect(chart.scales.y.max).toBe(5);
-      chart.pan(-100);
-      expect(chart.scales.y.min).toBe(1);
-      expect(chart.scales.y.max).toBe(5);
-      chart.pan(100);
-      expect(chart.scales.y.min).toBe(1);
-      expect(chart.scales.y.max).toBe(5);
-    });
-  });
-});
diff --git a/test/specs/zoom.wheel.spec.js b/test/specs/zoom.wheel.spec.js
new file mode 100644
index 0000000..d0be274
--- /dev/null
+++ b/test/specs/zoom.wheel.spec.js
@@ -0,0 +1,437 @@
+describe('zoom with wheel', function() {
+  const data = {
+    datasets: [{
+      data: [{
+        x: 1,
+        y: 3
+      }, {
+        x: 2,
+        y: 2
+      }, {
+        x: 3,
+        y: 1
+      }]
+    }]
+  };
+
+  describe('with modifierKey', function() {
+    for (const key of ['ctrl', 'alt', 'shift', 'meta']) {
+      for (const pressed of [true, false]) {
+        let chart, scaleX, scaleY;
+        it(`should ${pressed ? '' : 'not '}change ${pressed ? 'with' : 'without'} key ${key}`, async function() {
+          const rejectedSpy = jasmine.createSpy('wheelFailed');
+          chart = window.acquireChart({
+            type: 'line',
+            data,
+            options: {
+              scales: {
+                x: {
+                  type: 'linear',
+                  min: 0,
+                  max: 10
+                },
+                y: {
+                  type: 'linear'
+                }
+              },
+              plugins: {
+                zoom: {
+                  zoom: {
+                    wheel: {
+                      enabled: true,
+                      modifierKey: key,
+                    },
+                    mode: 'x',
+                    onZoomRejected: rejectedSpy
+                  }
+                }
+              }
+            }
+          });
+
+          scaleX = chart.scales.x;
+          scaleY = chart.scales.y;
+
+          const oldMinX = scaleX.options.min;
+          const oldMaxX = scaleX.options.max;
+
+          const wheelEv = {
+            x: scaleX.getPixelForValue(1.5),
+            y: scaleY.getPixelForValue(1.1),
+            deltaY: 1
+          };
+          if (pressed) {
+            wheelEv[key + 'Key'] = true;
+          }
+
+          jasmine.triggerWheelEvent(chart, wheelEv);
+
+          if (pressed) {
+            expect(scaleX.options.min).not.toEqual(oldMinX);
+            expect(scaleX.options.max).not.toEqual(oldMaxX);
+            expect(rejectedSpy).not.toHaveBeenCalled();
+          } else {
+            expect(scaleX.options.min).toEqual(oldMinX);
+            expect(scaleX.options.max).toEqual(oldMaxX);
+            expect(rejectedSpy).toHaveBeenCalled();
+          }
+        });
+      }
+    }
+  });
+
+  describe('with overScaleMode = 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',
+              overScaleMode: 'y'
+            }
+          }
+        }
+      }
+    };
+
+    describe('Wheel under Y scale', function() {
+      it('should be applied on Y, but not on X scales.', 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
+        };
+
+        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.', 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
+        };
+
+        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);
+      });
+    });
+  });
+
+  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.', 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
+        };
+
+        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.', 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
+        };
+
+        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('with logarithmic scale', function() {
+    it('should zoom correctly when mouse in center of chart', function() {
+      const config = {
+        type: 'line',
+        data: {
+          datasets: [
+            {data: [1, 10, 100, 1000, 10000]}
+          ],
+        },
+        options: {
+          scales: {
+            y: {
+              type: 'logarithmic'
+            }
+          },
+          plugins: {
+            zoom: {
+              zoom: {
+                mode: 'y',
+                wheel: {
+                  enabled: true,
+                },
+              }
+            }
+          }
+        }
+      };
+      const chart = window.acquireChart(config);
+      const scaleY = chart.scales.y;
+
+      const wheelEv = {
+        x: Math.round(scaleY.left + (scaleY.right - scaleY.left) / 2),
+        y: Math.round(scaleY.top + (scaleY.bottom - scaleY.top) / 2),
+        deltaY: -1
+      };
+
+      expect(scaleY.min).toBe(1);
+      expect(scaleY.max).toBe(10000);
+
+      jasmine.triggerWheelEvent(chart, wheelEv);
+
+      expect(scaleY.min).toBeCloseTo(1.6, 1);
+      expect(scaleY.max).toBeCloseTo(6310, -1);
+
+      jasmine.triggerWheelEvent(chart, wheelEv);
+
+      expect(scaleY.min).toBeCloseTo(2.4, 1);
+      expect(scaleY.max).toBeCloseTo(4170, -1);
+
+      chart.resetZoom();
+
+      expect(scaleY.min).toBe(1);
+      expect(scaleY.max).toBe(10000);
+
+      jasmine.triggerWheelEvent(chart, {...wheelEv, deltaY: 1});
+
+      expect(scaleY.min).toBe(0.6);
+      expect(scaleY.max).toBeCloseTo(15800, -2);
+    });
+  });
+
+  describe('events', function() {
+    it('should call onZoomStart', function() {
+      const startSpy = jasmine.createSpy('started');
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              zoom: {
+                wheel: {
+                  enabled: true,
+                },
+                mode: 'xy',
+                onZoomStart: startSpy
+              }
+            }
+          }
+        }
+      });
+      const wheelEv = {
+        x: chart.scales.x.getPixelForValue(1.5),
+        y: chart.scales.y.getPixelForValue(1.1),
+        deltaY: 1
+      };
+      jasmine.triggerWheelEvent(chart, wheelEv);
+      expect(startSpy).toHaveBeenCalled();
+      expect(chart.scales.x.min).not.toBe(1);
+    });
+
+    it('should detect configuration change', function() {
+      const startSpy = jasmine.createSpy('started');
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              zoom: {
+                wheel: {
+                  enabled: false,
+                },
+                mode: 'xy',
+                onZoomStart: startSpy
+              }
+            }
+          }
+        }
+      });
+      const wheelEv = {
+        x: chart.scales.x.getPixelForValue(1.5),
+        y: chart.scales.y.getPixelForValue(1.1),
+        deltaY: 1
+      };
+      jasmine.triggerWheelEvent(chart, wheelEv);
+      expect(startSpy).not.toHaveBeenCalled();
+      expect(chart.scales.x.min).toBe(1);
+
+      chart.options.plugins.zoom.zoom.wheel.enabled = true;
+      chart.update();
+
+      jasmine.triggerWheelEvent(chart, wheelEv);
+      expect(startSpy).toHaveBeenCalled();
+      expect(chart.scales.x.min).not.toBe(1);
+    });
+
+    it('should call onZoomRejected when onZoomStart returns false', function() {
+      const rejectSpy = jasmine.createSpy('rejected');
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              zoom: {
+                wheel: {
+                  enabled: true,
+                },
+                mode: 'xy',
+                onZoomStart: () => false,
+                onZoomRejected: rejectSpy
+              }
+            }
+          }
+        }
+      });
+      const wheelEv = {
+        x: chart.scales.x.getPixelForValue(1.5),
+        y: chart.scales.y.getPixelForValue(1.1),
+        deltaY: 1
+      };
+      jasmine.triggerWheelEvent(chart, wheelEv);
+      expect(rejectSpy).toHaveBeenCalled();
+      expect(chart.scales.x.min).toBe(1);
+    });
+
+    it('should call onZoomComplete', function(done) {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              zoom: {
+                wheel: {
+                  enabled: true,
+                },
+                mode: 'xy',
+                onZoomComplete(ctx) {
+                  expect(ctx.chart.scales.x.min).not.toBe(1);
+                  done();
+                }
+              }
+            }
+          }
+        }
+      });
+      const wheelEv = {
+        x: chart.scales.x.getPixelForValue(1.5),
+        y: chart.scales.y.getPixelForValue(1.1),
+        deltaY: 1
+      };
+      jasmine.triggerWheelEvent(chart, wheelEv);
+    });
+  });
+});