Repository: chartjs/chartjs-plugin-zoom
Pull request #457 (2021-04-17 19:56:27)

Internal state handling
* removes $zoom from chart
* uses `scale.axis` to determine what limits to apply
* chores
* fixture for drag select


Patch:
diff --git a/src/core.js b/src/core.js
index 8fe131f..ce81c32 100644
--- a/src/core.js
+++ b/src/core.js
@@ -1,19 +1,21 @@
 import {each, callback as call} from 'chart.js/helpers';
 import {panFunctions, zoomFunctions} from './scale.types';
+import {getState} from './state';
 import {directionEnabled, getEnabledScalesByPoint} from './utils';
 
-function storeOriginalOptions(chart) {
-  const originalOptions = chart.$zoom._originalOptions;
+function storeOriginalScaleLimits(chart) {
+  const {originalScaleLimits} = getState(chart);
   each(chart.scales, function(scale) {
-    if (!originalOptions[scale.id]) {
-      originalOptions[scale.id] = {min: scale.options.min, max: scale.options.max};
+    if (!originalScaleLimits[scale.id]) {
+      originalScaleLimits[scale.id] = {min: scale.options.min, max: scale.options.max};
     }
   });
-  each(originalOptions, function(opt, key) {
+  each(originalScaleLimits, function(opt, key) {
     if (!chart.scales[key]) {
-      delete originalOptions[key];
+      delete originalScaleLimits[key];
     }
   });
+  return originalScaleLimits;
 }
 
 function zoomScale(scale, zoom, center, zoomOptions) {
@@ -31,56 +33,50 @@ function zoomScale(scale, zoom, center, zoomOptions) {
  * @param {boolean} [useTransition] Whether to use `zoom` transition
  */
 export function doZoom(chart, percentZoomX, percentZoomY, focalPoint, zoomOptions, whichAxes, useTransition) {
-  const ca = chart.chartArea;
   if (!focalPoint) {
+    const ca = chart.chartArea;
     focalPoint = {
       x: (ca.left + ca.right) / 2,
       y: (ca.top + ca.bottom) / 2,
     };
   }
 
-  if (zoomOptions.enabled) {
-    storeOriginalOptions(chart);
-    // Do the zoom here
-    const zoomMode = typeof zoomOptions.mode === 'function' ? zoomOptions.mode({chart: chart}) : zoomOptions.mode;
+  storeOriginalScaleLimits(chart);
+  // Do the zoom here
+  const zoomMode = typeof zoomOptions.mode === 'function' ? zoomOptions.mode({chart: chart}) : zoomOptions.mode;
+
+  // Which axes should be modified when fingers were used.
+  let _whichAxes;
+  if (zoomMode === 'xy' && whichAxes !== undefined) {
+    // based on fingers positions
+    _whichAxes = whichAxes;
+  } else {
+    // no effect
+    _whichAxes = 'xy';
+  }
 
-    // Which axes should be modified when fingers were used.
-    let _whichAxes;
-    if (zoomMode === 'xy' && whichAxes !== undefined) {
-      // based on fingers positions
-      _whichAxes = whichAxes;
-    } else {
-      // no effect
-      _whichAxes = 'xy';
+  const 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)) {
+      zoomScale(scale, percentZoomX, focalPoint, zoomOptions);
+    } else if (!scale.isHorizontal() && directionEnabled(zoomMode, 'y', chart) && directionEnabled(_whichAxes, 'y', chart)) {
+      zoomScale(scale, percentZoomY, focalPoint, zoomOptions);
     }
+  });
 
-    const 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);
-      } else if (!scale.isHorizontal() && directionEnabled(zoomMode, 'y', chart) && directionEnabled(_whichAxes, 'y', chart)) {
-        // Do Y zoom
-        zoomOptions.scaleAxes = 'y';
-        zoomScale(scale, percentZoomY, focalPoint, zoomOptions);
-      }
-    });
-
-    chart.update(useTransition ? 'zoom' : 'none');
+  chart.update(useTransition ? 'zoom' : 'none');
 
-    call(zoomOptions.onZoom, [chart]);
-  }
+  call(zoomOptions.onZoom, [chart]);
 }
 
 export function resetZoom(chart) {
-  storeOriginalOptions(chart);
-  const originalOptions = chart.$zoom._originalOptions;
-  each(chart.scales, function(scale) {
+  const originalScaleLimits = storeOriginalScaleLimits(chart);
 
+  each(chart.scales, function(scale) {
     const scaleOptions = scale.options;
-    if (originalOptions[scale.id]) {
-      scaleOptions.min = originalOptions[scale.id].min;
-      scaleOptions.max = originalOptions[scale.id].max;
+    if (originalScaleLimits[scale.id]) {
+      scaleOptions.min = originalScaleLimits[scale.id].min;
+      scaleOptions.max = originalScaleLimits[scale.id].max;
     } else {
       delete scaleOptions.min;
       delete scaleOptions.max;
@@ -95,16 +91,14 @@ function panScale(scale, delta, panOptions) {
 }
 
 export function doPan(chart, deltaX, deltaY, panOptions, panningScales) {
-  storeOriginalOptions(chart);
+  storeOriginalScaleLimits(chart);
   if (panOptions.enabled) {
     const panMode = typeof panOptions.mode === 'function' ? panOptions.mode({chart}) : panOptions.mode;
 
     each(panningScales || chart.scales, function(scale) {
       if (scale.isHorizontal() && directionEnabled(panMode, 'x', chart) && deltaX !== 0) {
-        panOptions.scaleAxes = 'x';
         panScale(scale, deltaX, panOptions);
       } else if (!scale.isHorizontal() && directionEnabled(panMode, 'y', chart) && deltaY !== 0) {
-        panOptions.scaleAxes = 'y';
         panScale(scale, deltaY, panOptions);
       }
     });
diff --git a/src/hammer.js b/src/hammer.js
index e246be8..580e398 100644
--- a/src/hammer.js
+++ b/src/hammer.js
@@ -1,18 +1,22 @@
 import {callback as call} from 'chart.js/helpers';
 import Hammer from 'hammerjs';
 import {doPan, doZoom} from './core';
+import {getState} from './state';
 import {getEnabledScalesByPoint} from './utils';
 
-function createEnabler(chart, panOptions) {
+function createEnabler(chart) {
+  const state = getState(chart);
   return function(recognizer, event) {
+    const panOptions = state.options.pan;
     if (!panOptions || !panOptions.enabled) {
       return false;
     }
     if (!event || !event.srcEvent) { // Sometimes Hammer queries this with a null event.
       return true;
     }
-    const requireModifier = panOptions.modifierKey && (event.pointerType === 'mouse');
-    if (requireModifier && !event.srcEvent[panOptions.modifierKey + 'Key']) {
+    const modifierKey = panOptions.modifierKey;
+    const requireModifier = modifierKey && (event.pointerType === 'mouse');
+    if (requireModifier && !event.srcEvent[modifierKey + 'Key']) {
       call(panOptions.onPanRejected, [{chart, event}]);
       return false;
     }
@@ -30,12 +34,11 @@ function pinchAxes(p0, p1) {
   return p > 0.3 && p < 1.7 ? 'xy' : x > y ? 'x' : 'y';
 }
 
-function createPinchHandlers(chart, zoomOptions) {
-  // Hammer reports the total scaling. We need the incremental amount
-  let currentPinchScaling;
-  const handlePinch = function(e) {
+function handlePinch(chart, state, e) {
+  if (state.scale) {
     const {center, pointers} = e;
-    const zoom = 1 / (currentPinchScaling) * e.scale;
+    // Hammer reports the total scaling. We need the incremental amount
+    const zoom = 1 / state.scale * e.scale;
     const rect = e.target.getBoundingClientRect();
     const focalPoint = {
       x: center.x - rect.left,
@@ -44,78 +47,81 @@ function createPinchHandlers(chart, zoomOptions) {
 
     const xy = pinchAxes(pointers[0], pointers[1]);
 
-    doZoom(chart, zoom, zoom, focalPoint, zoomOptions, xy);
+    doZoom(chart, zoom, zoom, focalPoint, state.options.zoom, xy);
 
     // Keep track of overall scale
-    currentPinchScaling = e.scale;
-  };
-  return {
-    start() {
-      currentPinchScaling = 1;
-    },
-    pinch: handlePinch,
-    end(e) {
-      handlePinch(e);
-      currentPinchScaling = null; // reset
-      call(zoomOptions.onZoomComplete, [chart]);
-    }
-  };
+    state.scale = e.scale;
+  }
 }
 
-function createPanHandlers(chart, panOptions) {
-  let delta = null;
-  let enabledScales = null;
-  const handlePan = function(e) {
-    if (delta !== null) {
-      chart.panning = true;
-      doPan(chart, e.deltaX - delta.x, e.deltaY - delta.y, panOptions, enabledScales);
-      delta = {x: e.deltaX, y: e.deltaY};
-    }
-  };
+function startPinch(chart, state) {
+  if (state.options.zoom.enabled) {
+    state.scale = 1;
+  }
+}
 
-  return {
-    start(e) {
-      const rect = e.target.getBoundingClientRect();
-      const x = e.center.x - rect.left;
-      const y = e.center.y - rect.top;
-      enabledScales = getEnabledScalesByPoint(panOptions, x, y, chart);
-
-      delta = {x: 0, y: 0};
-      handlePan(e);
-    },
-    move: handlePan,
-    end() {
-      delta = null;
-      setTimeout(() => (chart.panning = false), 500);
-      call(panOptions.onPanComplete, [chart]);
-    }
-  };
+function endPinch(chart, state, e) {
+  if (state.scale) {
+    handlePinch(chart, state, e);
+    state.scale = null; // reset
+    call(state.options.zoom.onZoomComplete, [chart]);
+  }
+}
+
+
+function handlePan(chart, state, e) {
+  const delta = state.delta;
+  if (delta !== null) {
+    state.panning = true;
+    doPan(chart, e.deltaX - delta.x, e.deltaY - delta.y, state.options.pan, state.panScales);
+    state.delta = {x: e.deltaX, y: e.deltaY};
+  }
+}
+
+function startPan(chart, state, e) {
+  const panOptions = state.options.pan;
+  if (!panOptions.enabled) {
+    return;
+  }
+  const rect = e.target.getBoundingClientRect();
+  const x = e.center.x - rect.left;
+  const y = e.center.y - rect.top;
+
+  state.panScales = getEnabledScalesByPoint(panOptions, x, y, chart);
+  state.delta = {x: 0, y: 0};
+  handlePan(chart, state, e);
+}
+
+function endPan(chart, state) {
+  state.delta = null;
+  if (state.panning) {
+    setTimeout(() => (state.panning = false), 500);
+    call(state.options.pan.onPanComplete, [chart]);
+  }
 }
 
 const hammers = new WeakMap();
 export function startHammer(chart, options) {
+  const state = getState(chart);
   const canvas = chart.canvas;
   const {pan: panOptions, zoom: zoomOptions} = options;
 
   const mc = new Hammer.Manager(canvas);
   if (zoomOptions && zoomOptions.enabled) {
-    const {start, pinch, end} = createPinchHandlers(chart, zoomOptions);
-
     mc.add(new Hammer.Pinch());
-    mc.on('pinchstart', start);
-    mc.on('pinch', pinch);
-    mc.on('pinchend', end);
+    mc.on('pinchstart', () => startPinch(chart, state));
+    mc.on('pinch', (e) => handlePinch(chart, state, e));
+    mc.on('pinchend', (e) => endPinch(chart, state, e));
   }
 
   if (panOptions && panOptions.enabled) {
-    const {start, move, end} = createPanHandlers(chart, panOptions);
     mc.add(new Hammer.Pan({
       threshold: panOptions.threshold,
-      enable: createEnabler(chart, panOptions)
+      enable: createEnabler(chart)
     }));
-    mc.on('panstart', start);
-    mc.on('panmove', move);
-    mc.on('panend', end);
+    mc.on('panstart', (e) => startPan(chart, state, e));
+    mc.on('panmove', (e) => handlePan(chart, state, e));
+    mc.on('panend', () => endPan(chart, state));
   }
 
   hammers.set(chart, mc);
diff --git a/src/handlers.js b/src/handlers.js
index 4bbe6af..57c9417 100644
--- a/src/handlers.js
+++ b/src/handlers.js
@@ -1,10 +1,10 @@
 import {directionEnabled, debounce} from './utils';
 import {doZoom} from './core';
 import {callback as call} from 'chart.js/helpers';
+import {getState} from './state';
 
-function removeHandler(target, type, chart) {
-  const props = chart.$zoom;
-  const handlers = props._handlers || (props._handlers = {});
+function removeHandler(chart, target, type) {
+  const {handlers} = getState(chart);
   const handler = handlers[type];
   if (handler) {
     target.removeEventListener(type, handler);
@@ -12,24 +12,26 @@ function removeHandler(target, type, chart) {
   }
 }
 
-function addHandler(target, type, handler, {chart, options}) {
-  const props = chart.$zoom;
-  const handlers = props._handlers || (props._handlers = {});
-  removeHandler(target, type, chart);
+function addHandler(chart, target, type, handler) {
+  const {handlers, options} = getState(chart);
+  removeHandler(chart, target, type);
   handlers[type] = (event) => handler(chart, event, options);
   target.addEventListener(type, handlers[type]);
 }
 
 export function mouseMove(chart, event) {
-  if (chart.$zoom._dragZoomStart) {
-    chart.$zoom._dragZoomEnd = event;
+  const state = getState(chart);
+  if (state.dragStart) {
+    state.dragEnd = event;
     chart.update('none');
   }
 }
 
-export function mouseDown(chart, event, options) {
-  addHandler(chart.canvas, 'mousemove', mouseMove, {chart, options});
-  chart.$zoom._dragZoomStart = event;
+export function mouseDown(chart, event) {
+  const state = getState(chart);
+  state.dragStart = event;
+
+  addHandler(chart, chart.canvas, 'mousemove', mouseMove);
 }
 
 export function computeDragRect(chart, mode, beginPoint, endPoint) {
@@ -62,22 +64,20 @@ export function computeDragRect(chart, mode, beginPoint, endPoint) {
   };
 }
 
-export function mouseUp(chart, event, options) {
-  if (!chart.$zoom || !chart.$zoom._dragZoomStart) {
+export function mouseUp(chart, event) {
+  const state = getState(chart);
+  if (!state.dragStart) {
     return;
   }
 
-  const zoomOptions = options.zoom;
   removeHandler(chart.canvas, 'mousemove', chart);
-
-  const props = chart.$zoom;
-  const beginPoint = props._dragZoomStart;
-  const rect = computeDragRect(chart, zoomOptions.mode, beginPoint, event);
+  const zoomOptions = state.options.zoom;
+  const rect = computeDragRect(chart, zoomOptions.mode, state.dragStart, event);
   const {width: dragDistanceX, height: dragDistanceY} = rect;
 
   // Remove drag start and end before chart update to stop drawing selected area
-  props._dragZoomStart = null;
-  props._dragZoomEnd = null;
+  state.dragStart = null;
+  state.dragEnd = null;
 
   const zoomThreshold = zoomOptions.threshold || 0;
   if (dragDistanceX <= zoomThreshold && dragDistanceY <= zoomThreshold) {
@@ -94,8 +94,8 @@ export function mouseUp(chart, event, options) {
   call(zoomOptions.onZoomComplete, [chart]);
 }
 
-export function wheel(chart, event, options) {
-  const zoomOptions = options.zoom;
+export function wheel(chart, event) {
+  const {options: {zoom: zoomOptions}} = getState(chart);
   const {wheelModifierKey, onZoomRejected, onZoomComplete} = zoomOptions;
 
   // Before preventDefault, check if the modifier key required and pressed
@@ -129,7 +129,6 @@ export function wheel(chart, event, options) {
 }
 
 export function addListeners(chart, options) {
-  const props = chart.$zoom;
   const canvas = chart.canvas;
 
   // Install listeners. Do this dynamically based on options so that we can turn zoom on and off
@@ -138,28 +137,28 @@ export function addListeners(chart, options) {
   const zoomEnabled = options.zoom && options.zoom.enabled;
   const dragEnabled = options.zoom.drag;
   if (zoomEnabled && !dragEnabled) {
-    addHandler(canvas, 'wheel', wheel, {chart, options});
-  } else if (props._wheelHandler) {
+    addHandler(chart, canvas, 'wheel', wheel);
+  } else {
     removeHandler(canvas, 'wheel', chart);
   }
   if (zoomEnabled && dragEnabled) {
-    addHandler(canvas, 'mousedown', mouseDown, {chart, options});
-    addHandler(canvas.ownerDocument, 'mouseup', mouseUp, {chart, options});
+    addHandler(chart, canvas, 'mousedown', mouseDown);
+    addHandler(chart, canvas.ownerDocument, 'mouseup', mouseUp);
   } else {
-    removeHandler(canvas, 'mousedown', chart);
-    removeHandler(canvas, 'mousemove', chart);
-    removeHandler(canvas.ownerDocument, 'mouseup', chart);
+    removeHandler(chart, canvas, 'mousedown');
+    removeHandler(chart, canvas, 'mousemove');
+    removeHandler(chart, canvas.ownerDocument, 'mouseup');
   }
 }
 
 export function removeListeners(chart) {
-  const {canvas, $zoom: props} = chart;
-  if (!canvas || !props) {
+  const {canvas} = chart;
+  if (!canvas) {
     return;
   }
-  removeHandler(canvas, 'mousedown', chart);
-  removeHandler(canvas, 'mousemove', chart);
-  removeHandler(canvas.ownerDocument, 'mouseup', chart);
-  removeHandler(canvas, 'wheel', chart);
-  removeHandler(canvas, 'click', chart);
+  removeHandler(chart, canvas, 'mousedown');
+  removeHandler(chart, canvas, 'mousemove');
+  removeHandler(chart, canvas.ownerDocument, 'mouseup');
+  removeHandler(chart, canvas, 'wheel');
+  removeHandler(chart, canvas, 'click');
 }
diff --git a/src/plugin.js b/src/plugin.js
index e1e35ff..a9610b4 100644
--- a/src/plugin.js
+++ b/src/plugin.js
@@ -2,6 +2,7 @@ import Hammer from 'hammerjs';
 import {addListeners, computeDragRect, removeListeners} from './handlers';
 import {startHammer, stopHammer} from './hammer';
 import {resetZoom} from './core';
+import {getState, removeState} from './state';
 
 export default {
   id: 'zoom',
@@ -24,36 +25,38 @@ export default {
   },
 
   start: function(chart, args, options) {
-    chart.$zoom = {
-      _originalOptions: {}
-    };
-    addListeners(chart, options);
+    const state = getState(chart);
+    state.options = options;
 
     if (Hammer) {
       startHammer(chart, options);
     }
+
     chart.resetZoom = () => resetZoom(chart);
   },
 
   beforeEvent(chart, args) {
-    if (args.event.type === 'click' && chart.panning) {
+    const state = getState(chart);
+    if (args.event.type === 'click' && state.panning) {
       // cancel the click event at pan end
       return false;
     }
   },
 
   beforeUpdate: function(chart, args, options) {
+    const state = getState(chart);
+    state.options = options;
     addListeners(chart, options);
   },
 
   beforeDatasetsDraw: function(chart, args, options) {
-    const {$zoom: props = {}, ctx} = chart;
-    const {_dragZoomStart: beginPoint, _dragZoomEnd: endPoint} = props;
+    const {dragStart, dragEnd} = getState(chart);
 
-    if (endPoint) {
-      const {left, top, width, height} = computeDragRect(chart, options.zoom.mode, beginPoint, endPoint);
+    if (dragEnd) {
+      const {left, top, width, height} = computeDragRect(chart, options.zoom.mode, dragStart, dragEnd);
 
       const dragOptions = options.zoom.drag;
+      const ctx = chart.ctx;
 
       ctx.save();
       ctx.beginPath();
@@ -70,16 +73,11 @@ export default {
   },
 
   stop: function(chart) {
-    if (!chart.$zoom) {
-      return;
-    }
-
     removeListeners(chart);
 
-    delete chart.$zoom;
-
     if (Hammer) {
       stopHammer(chart);
     }
+    removeState(chart);
   }
 };
diff --git a/src/scale.types.js b/src/scale.types.js
index 4ce0aac..ee882ef 100644
--- a/src/scale.types.js
+++ b/src/scale.types.js
@@ -1,25 +1,13 @@
 import {isNullOrUndef} from 'chart.js/helpers';
 
-function rangeMaxLimiter(zoomPanOptions, newMax) {
-  const {scaleAxes, rangeMax} = zoomPanOptions;
-  if (scaleAxes && rangeMax && !isNullOrUndef(rangeMax[scaleAxes])) {
-    const limit = rangeMax[scaleAxes];
-    if (newMax > limit) {
-      newMax = limit;
-    }
-  }
-  return newMax;
+function rangeMaxLimiter(rangeMax, axis, newMax) {
+  const limit = rangeMax && rangeMax[axis];
+  return !isNullOrUndef(limit) && newMax > limit ? limit : newMax;
 }
 
-function rangeMinLimiter(zoomPanOptions, newMin) {
-  const {scaleAxes, rangeMin} = zoomPanOptions;
-  if (scaleAxes && rangeMin && !isNullOrUndef(rangeMin[scaleAxes])) {
-    const limit = rangeMin[scaleAxes];
-    if (newMin < limit) {
-      newMin = limit;
-    }
-  }
-  return newMin;
+function rangeMinLimiter(rangeMin, axis, newMin) {
+  const limit = rangeMin && rangeMin[axis];
+  return !isNullOrUndef(limit) && newMin < limit ? limit : newMin;
 }
 
 function zoomDelta(scale, zoom, center) {
@@ -38,8 +26,9 @@ function zoomDelta(scale, zoom, center) {
 
 function zoomNumericalScale(scale, zoom, center, zoomOptions) {
   const delta = zoomDelta(scale, zoom, center);
-  scale.options.min = rangeMinLimiter(zoomOptions, scale.min + delta.min);
-  scale.options.max = rangeMaxLimiter(zoomOptions, scale.max - delta.max);
+  const {axis, options: scaleOpts} = scale;
+  scaleOpts.min = rangeMinLimiter(zoomOptions.rangeMin, axis, scale.min + delta.min);
+  scaleOpts.max = rangeMaxLimiter(zoomOptions.rangeMax, axis, scale.max - delta.max);
 }
 
 const integerChange = (v) => v === 0 || isNaN(v) ? 0 : v < 0 ? Math.min(Math.round(v), -1) : Math.max(Math.round(v), 1);
@@ -55,8 +44,9 @@ function zoomCategoryScale(scale, zoom, center, zoomOptions) {
     }
   }
   const delta = zoomDelta(scale, zoom, center);
-  scale.options.min = labels[Math.max(0, rangeMinLimiter(zoomOptions, scale.min + integerChange(delta.min)))];
-  scale.options.max = labels[Math.min(maxIndex, rangeMaxLimiter(zoomOptions, scale.max - integerChange(delta.max)))];
+  const {axis, options: scaleOpts} = scale;
+  scaleOpts.min = Math.max(0, rangeMinLimiter(zoomOptions.rangeMin, axis, scale.min + integerChange(delta.min)));
+  scaleOpts.max = Math.min(maxIndex, rangeMaxLimiter(zoomOptions.rangeMax, axis, scale.max - integerChange(delta.max)));
 }
 
 const categoryDelta = new WeakMap();
@@ -75,19 +65,17 @@ function panCategoryScale(scale, delta, panOptions) {
 
   categoryDelta.set(scale, minIndex !== scaleMin ? 0 : cumDelta);
 
-
-  scale.options.min = rangeMinLimiter(panOptions, labels[minIndex]);
-  scale.options.max = rangeMaxLimiter(panOptions, labels[maxIndex]);
+  const {axis, options: scaleOpts} = scale;
+  scaleOpts.min = rangeMinLimiter(panOptions.rangeMin, axis, minIndex);
+  scaleOpts.max = rangeMaxLimiter(panOptions.rangeMax, axis, maxIndex);
 }
 
 function panNumericalScale(scale, delta, panOptions) {
-  const scaleOpts = scale.options;
-  const prevStart = scale.min;
-  const prevEnd = scale.max;
+  const {axis, min: prevStart, max: prevEnd, options: scaleOpts} = scale;
   const newMin = scale.getValueForPixel(scale.getPixelForValue(prevStart) - delta);
   const newMax = scale.getValueForPixel(scale.getPixelForValue(prevEnd) - delta);
-  const rangeMin = rangeMinLimiter(panOptions, newMin);
-  const rangeMax = rangeMaxLimiter(panOptions, newMax);
+  const rangeMin = rangeMinLimiter(panOptions.rangeMin, axis, newMin);
+  const rangeMax = rangeMaxLimiter(panOptions.rangeMax, axis, newMax);
   let diff;
 
   if (newMin >= rangeMin && newMax <= rangeMax) {
diff --git a/src/state.js b/src/state.js
new file mode 100644
index 0000000..2223c0c
--- /dev/null
+++ b/src/state.js
@@ -0,0 +1,17 @@
+const chartStates = new WeakMap();
+
+export function getState(chart) {
+  let state = chartStates.get(chart);
+  if (!state) {
+    state = {
+      originalScaleLimits: {},
+      handlers: {},
+    };
+    chartStates.set(chart, state);
+  }
+  return state;
+}
+
+export function removeState(chart) {
+  chartStates.delete(chart);
+}
diff --git a/src/utils.js b/src/utils.js
index 526ae2f..3d7fb7b 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -18,30 +18,6 @@ export function directionEnabled(mode, dir, chart) {
   return false;
 }
 
-export function getXAxis(chartInstance) {
-  const scales = chartInstance.scales;
-  const scaleIds = Object.keys(scales);
-  for (let i = 0; i < scaleIds.length; i++) {
-    const scale = scales[scaleIds[i]];
-
-    if (scale.isHorizontal()) {
-      return scale;
-    }
-  }
-}
-
-export function getYAxis(chartInstance) {
-  const scales = chartInstance.scales;
-  const scaleIds = Object.keys(scales);
-  for (let i = 0; i < scaleIds.length; i++) {
-    const scale = scales[scaleIds[i]];
-
-    if (!scale.isHorizontal()) {
-      return scale;
-    }
-  }
-}
-
 /**
  * Debounces calling `fn` for `delay` ms
  * @param {function} fn - Function to call. No arguments are passed.


Test patch:
diff --git a/test/fixtures/zoom/drag.js b/test/fixtures/zoom/drag.js
new file mode 100644
index 0000000..cf384c0
--- /dev/null
+++ b/test/fixtures/zoom/drag.js
@@ -0,0 +1,54 @@
+const data = [];
+for (let i = 0; i < 100; i++) {
+  data.push({x: i, y: Math.sin(i / 25 * Math.PI) * 10});
+}
+
+module.exports = {
+  tolerance: 0.004,
+  config: {
+    type: 'line',
+    data: {
+      datasets: [{
+        data,
+        borderColor: 'red'
+      }]
+    },
+    options: {
+      scales: {
+        x: {type: 'linear', display: false},
+        y: {display: false}
+      },
+      plugins: {
+        legend: false,
+        zoom: {
+          zoom: {
+            enabled: true,
+            drag: {
+              backgroundColor: 'yellow',
+              borderColor: 'black',
+              borderWidth: 1
+            },
+          }
+        }
+      },
+      layout: {
+        padding: 2
+      }
+    }
+  },
+  options: {
+    run(chart) {
+      const scaleX = chart.scales.x;
+      const scaleY = chart.scales.y;
+      jasmine.triggerMouseEvent(chart, 'mousedown', {
+        x: scaleX.getPixelForValue(5),
+        y: scaleY.getPixelForValue(10)
+      });
+      jasmine.triggerMouseEvent(chart, 'mousemove', {
+        x: scaleX.getPixelForValue(60),
+        y: scaleY.getPixelForValue(0)
+      });
+      chart.render = function() { };
+    }
+  }
+};