Repository: chartjs/chartjs-plugin-zoom (js)

Issue:
logarithmic x axis zoom bug
If I using logarithmic x axis and linear y axis, 
the zooming not working correctly.
If the mouse pointer is neer to x axes 0 and than zooming, everything is correct.
But when I'm moving the mouse to the right, so at the bigger x position, and zooming then.
My rendered line is not correct. 


Pull request:
Fix drag-zoom and pan for non-linear scales
Pan and drag-zoom were not working properly when the scale is of non-linear numeric type.

What is changed: 

* drag-zoom is based on `getValueForPixel` for all scales. I.e. the values at the rectangle corners are resolved and used as new boundaries
* allow range to change when panning non-linear scale
* Many new samples + some re-organization

Some issues left:

* Pan on timeseries scale does not really work (chart.js issue)
* ~Pan non-linear scale against a limit results to unwanted zooming~

Resolves #180

Patch:
diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js
index cd32364..3cef98f 100644
--- a/docs/.vuepress/config.js
+++ b/docs/.vuepress/config.js
@@ -78,13 +78,27 @@ module.exports = {
       ],
       '/samples/': [
         'basic',
-        'over-scale-mode',
-        'bar',
-        'log',
-        'time',
-        'drag',
+        {
+          title: 'Wheel Zoom',
+          children: [
+            'wheel/category',
+            'wheel/log',
+            'wheel/time',
+            'wheel/over-scale-mode',
+            'wheel/click-zoom',
+          ]
+        },
+        {
+          title: 'Drag to Zoom',
+          children: [
+            'drag/category',
+            'drag/linear',
+            'drag/log',
+            'drag/time',
+            'drag/timeseries',
+          ]
+        },
         'api',
-        'click-zoom',
         'pan-region',
       ],
     }
diff --git a/docs/samples/drag/category.md b/docs/samples/drag/category.md
new file mode 100644
index 0000000..fe0388d
--- /dev/null
+++ b/docs/samples/drag/category.md
@@ -0,0 +1,93 @@
+# Category Scale
+
+Zooming is performed by clicking and selecting an area over the chart with the mouse. Pan is activated by keeping `ctrl` pressed.
+
+```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',
+  },
+  y: {
+    type: 'linear',
+    ticks: {
+      callback: (val, index, ticks) => index === 0 || index === ticks.length - 1 ? null : val,
+    },
+  },
+};
+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: {
+      tooltip: false,
+      zoom: {
+        pan: {
+          enabled: true,
+          mode: 'x',
+          modifierKey: 'ctrl',
+        },
+        zoom: {
+          enabled: true,
+          drag: true,
+          mode: 'x',
+        },
+      }
+    },
+  }
+};
+// </block:config>
+
+const actions = [
+  {
+    name: 'Reset zoom',
+    handler(chart) {
+      chart.resetZoom();
+    }
+  }
+];
+
+module.exports = {
+  actions,
+  config,
+};
+```
diff --git a/docs/samples/drag.md b/docs/samples/drag/linear.md
similarity index 95%
rename from docs/samples/drag.md
rename to docs/samples/drag/linear.md
index 5c85dd1..3e0d245 100644
--- a/docs/samples/drag.md
+++ b/docs/samples/drag/linear.md
@@ -1,4 +1,4 @@
-# Drag To Zoom
+# Linear Scales
 
 Zooming is performed by clicking and selecting an area over the chart with the mouse. Pan is activated by keeping `ctrl` pressed.
 
@@ -57,6 +57,7 @@ const dragColor = Utils.randomColor(0.4);
 const zoomOptions = {
   pan: {
     enabled: true,
+    mode: 'xy',
     modifierKey: 'ctrl',
   },
   zoom: {
@@ -87,9 +88,6 @@ const config = {
         text: (ctx) => 'Zoom: ' + zoomStatus()
       }
     },
-    onClick(e) {
-      console.log(e.type);
-    }
   }
 };
 // </block:config>
@@ -112,6 +110,5 @@ const actions = [
 module.exports = {
   actions,
   config,
-  output: 'Clicks are logged here'
 };
 ```
diff --git a/docs/samples/drag/log.md b/docs/samples/drag/log.md
new file mode 100644
index 0000000..0a02b8a
--- /dev/null
+++ b/docs/samples/drag/log.md
@@ -0,0 +1,172 @@
+# Logarithmic Scale
+
+Zooming is performed by clicking and selecting an area over the chart with the mouse. Pan is activated by keeping `ctrl` pressed.
+
+```js chart-editor
+// <block:data:2>
+const data = {
+  datasets: [{
+    label: 'V(node2)',
+    borderColor: Utils.randomColor(0.4),
+    backgroundColor: Utils.randomColor(0.1),
+    pointBorderColor: Utils.randomColor(0.7),
+    pointBackgroundColor: Utils.randomColor(0.5),
+    pointBorderWidth: 1,
+    data: [{
+      x: 1,
+      y: -1.711e-2,
+    }, {
+      x: 1.26,
+      y: -2.708e-2,
+    }, {
+      x: 1.58,
+      y: -4.285e-2,
+    }, {
+      x: 2.0,
+      y: -6.772e-2,
+    }, {
+      x: 2.51,
+      y: -1.068e-1,
+    }, {
+      x: 3.16,
+      y: -1.681e-1,
+    }, {
+      x: 3.98,
+      y: -2.635e-1,
+    }, {
+      x: 5.01,
+      y: -4.106e-1,
+    }, {
+      x: 6.31,
+      y: -6.339e-1,
+    }, {
+      x: 7.94,
+      y: -9.659e-1,
+    }, {
+      x: 10.00,
+      y: -1.445,
+    }, {
+      x: 12.6,
+      y: -2.110,
+    }, {
+      x: 15.8,
+      y: -2.992,
+    }, {
+      x: 20.0,
+      y: -4.102,
+    }, {
+      x: 25.1,
+      y: -5.429,
+    }, {
+      x: 31.6,
+      y: -6.944,
+    }, {
+      x: 39.8,
+      y: -8.607,
+    }, {
+      x: 50.1,
+      y: -1.038e1,
+    }, {
+      x: 63.1,
+      y: -1.223e1,
+    }, {
+      x: 79.4,
+      y: -1.413e1,
+    }, {
+      x: 100.00,
+      y: -1.607e1,
+    }, {
+      x: 126,
+      y: -1.803e1,
+    }, {
+      x: 158,
+      y: -2e1,
+    }, {
+      x: 200,
+      y: -2.199e1,
+    }, {
+      x: 251,
+      y: -2.398e1,
+    }, {
+      x: 316,
+      y: -2.597e1,
+    }, {
+      x: 398,
+      y: -2.797e1,
+    }, {
+      x: 501,
+      y: -2.996e1,
+    }, {
+      x: 631,
+      y: -3.196e1,
+    }, {
+      x: 794,
+      y: -3.396e1,
+    }, {
+      x: 1000,
+      y: -3.596e1
+    }]
+  }]
+};
+// </block:data>
+
+// <block:scales:1>
+const scales = {
+  x: {
+    type: 'logarithmic',
+    ticks: {
+      callback: function(tick) {
+        const remain = tick / (Math.pow(10, Math.floor(Math.log10(tick))));
+        if (remain === 1 || remain === 2 || remain === 5) {
+          return tick.toString() + 'Hz';
+        }
+        return '';
+      },
+      maxRotation: 0
+    },
+    title: {
+      display: true,
+      text: 'Frequency',
+    },
+  }
+};
+// </block:scales>
+
+// <block:config:0>
+const config = {
+  type: 'scatter',
+  data: data,
+  options: {
+    scales: scales,
+    plugins: {
+      zoom: {
+        pan: {
+          enabled: true,
+          mode: 'xy',
+          modifierKey: 'ctrl',
+        },
+        zoom: {
+          enabled: true,
+          drag: true,
+          mode: 'xy',
+        },
+      }
+    },
+  }
+};
+// </block:config>
+
+const actions = [
+  {
+    name: 'Reset zoom',
+    handler(chart) {
+      chart.resetZoom();
+    }
+  }
+];
+
+module.exports = {
+  actions,
+  config,
+};
+```
diff --git a/docs/samples/drag/time.md b/docs/samples/drag/time.md
new file mode 100644
index 0000000..0714faf
--- /dev/null
+++ b/docs/samples/drag/time.md
@@ -0,0 +1,129 @@
+# Time Scale
+
+Zooming is performed by clicking and selecting an area over the chart with the mouse. Pan is activated by keeping `ctrl` pressed.
+
+```js chart-editor
+// <block:data:1>
+const NUMBER_CFG = {count: 500, min: 0, max: 1000};
+const data = {
+  datasets: [{
+    label: 'My First dataset',
+    borderColor: Utils.randomColor(0.4),
+    backgroundColor: Utils.randomColor(0.1),
+    pointBorderColor: Utils.randomColor(0.7),
+    pointBackgroundColor: Utils.randomColor(0.5),
+    pointBorderWidth: 1,
+    data: Utils.hourlyPoints(NUMBER_CFG),
+  }]
+};
+// </block:data>
+
+// <block:scales:2>
+const scales = {
+  x: {
+    position: 'bottom',
+    type: 'time',
+    ticks: {
+      autoSkip: true,
+      autoSkipPadding: 50,
+      maxRotation: 0
+    },
+    time: {
+      displayFormats: {
+        hour: 'HH:mm',
+        minute: 'HH:mm',
+        second: 'HH:mm:ss'
+      }
+    }
+  },
+  y: {
+    position: 'right',
+    ticks: {
+      callback: (val, index, ticks) => index === 0 || index === ticks.length - 1 ? null : val,
+    },
+    grid: {
+      borderColor: Utils.randomColor(1),
+      color: 'rgba( 0, 0, 0, 0.1)',
+    },
+    title: {
+      display: true,
+      text: (ctx) => ctx.scale.axis + ' axis',
+    }
+  },
+};
+// </block:scales>
+
+// <block:zoom:0>
+const zoomOptions = {
+  pan: {
+    enabled: true,
+    modifierKey: 'ctrl',
+  },
+  zoom: {
+    enabled: true,
+    drag: true,
+    mode: 'xy',
+  },
+};
+// </block>
+
+const panStatus = () => zoomOptions.pan.enabled ? 'enabled' : 'disabled';
+const zoomStatus = () => zoomOptions.zoom.enabled ? 'enabled' : 'disabled';
+
+// <block:config:1>
+const config = {
+  type: 'scatter',
+  data: data,
+  options: {
+    scales: scales,
+    plugins: {
+      zoom: zoomOptions,
+      title: {
+        display: true,
+        position: 'bottom',
+        text: (ctx) => 'Zoom: ' + zoomStatus() + ', Pan: ' + panStatus()
+      }
+    },
+  }
+};
+// </block:config>
+
+const actions = [
+  {
+    name: 'Toggle zoom',
+    handler(chart) {
+      zoomOptions.zoom.enabled = !zoomOptions.zoom.enabled;
+      chart.update();
+    }
+  }, {
+    name: 'Toggle pan',
+    handler(chart) {
+      zoomOptions.pan.enabled = !zoomOptions.pan.enabled;
+      chart.update();
+    },
+  }, {
+    name: 'Reset zoom',
+    handler(chart) {
+      chart.resetZoom();
+    }
+  }, {
+    name: 'Zoom to next week',
+    handler(chart) {
+      chart.zoomScale('x', Utils.nextWeek(), 'default');
+      chart.update();
+    }
+  }, {
+    name: 'Zoom to 400-600',
+    handler(chart) {
+      chart.zoomScale('y', {min: 400, max: 600}, 'default');
+      chart.update();
+    }
+  }
+
+];
+
+module.exports = {
+  actions,
+  config,
+};
+```
diff --git a/docs/samples/drag/timeseries.md b/docs/samples/drag/timeseries.md
new file mode 100644
index 0000000..43b1766
--- /dev/null
+++ b/docs/samples/drag/timeseries.md
@@ -0,0 +1,120 @@
+# Timeseries Scale
+
+Zooming is performed by clicking and selecting an area over the chart with the mouse. Pan is activated by keeping `ctrl` pressed.
+
+```js chart-editor
+// <block:data:1>
+const NUMBER_CFG = {count: 200, min: 0, max: 100};
+const data = {
+  datasets: [{
+    label: 'My First dataset',
+    borderColor: Utils.randomColor(0.4),
+    backgroundColor: Utils.randomColor(0.1),
+    pointBorderColor: Utils.randomColor(0.7),
+    pointBackgroundColor: Utils.randomColor(0.5),
+    pointBorderWidth: 1,
+    data: Utils.officeHourPoints(NUMBER_CFG),
+  }]
+};
+// </block:data>
+
+// <block:scales:2>
+const scales = {
+  x: {
+    position: 'bottom',
+    type: 'timeseries',
+    ticks: {
+      autoSkip: true,
+      autoSkipPadding: 50,
+      maxRotation: 0,
+      major: {
+        enabled: true
+      }
+    },
+    time: {
+      displayFormats: {
+        hour: 'HH:mm',
+        minute: 'HH:mm',
+        second: 'HH:mm:ss'
+      }
+    }
+  },
+  y: {
+    position: 'right',
+    ticks: {
+      callback: (val, index, ticks) => index === 0 || index === ticks.length - 1 ? null : val,
+    },
+    grid: {
+      borderColor: Utils.randomColor(1),
+      color: 'rgba( 0, 0, 0, 0.1)',
+    },
+    title: {
+      display: true,
+      text: (ctx) => ctx.scale.axis + ' axis',
+    }
+  },
+};
+// </block:scales>
+
+// <block:zoom:0>
+const zoomOptions = {
+  pan: {
+    enabled: true,
+    modifierKey: 'ctrl',
+  },
+  zoom: {
+    enabled: true,
+    drag: true,
+    mode: 'xy',
+  },
+};
+// </block>
+
+const panStatus = () => zoomOptions.pan.enabled ? 'enabled' : 'disabled';
+const zoomStatus = () => zoomOptions.zoom.enabled ? 'enabled' : 'disabled';
+
+// <block:config:1>
+const config = {
+  type: 'scatter',
+  data: data,
+  options: {
+    scales: scales,
+    plugins: {
+      zoom: zoomOptions,
+      title: {
+        display: true,
+        position: 'bottom',
+        text: (ctx) => 'Zoom: ' + zoomStatus() + ', Pan: ' + panStatus()
+      }
+    },
+  }
+};
+// </block:config>
+
+const actions = [
+  {
+    name: 'Toggle zoom',
+    handler(chart) {
+      zoomOptions.zoom.enabled = !zoomOptions.zoom.enabled;
+      chart.update();
+    }
+  }, {
+    name: 'Toggle pan',
+    handler(chart) {
+      zoomOptions.pan.enabled = !zoomOptions.pan.enabled;
+      chart.update();
+    },
+  }, {
+    name: 'Reset zoom',
+    handler(chart) {
+      chart.resetZoom();
+    }
+  }
+
+];
+
+module.exports = {
+  actions,
+  config,
+};
+```
diff --git a/docs/samples/bar.md b/docs/samples/wheel/category.md
similarity index 98%
rename from docs/samples/bar.md
rename to docs/samples/wheel/category.md
index 83e638c..7d7dae3 100644
--- a/docs/samples/bar.md
+++ b/docs/samples/wheel/category.md
@@ -1,4 +1,4 @@
-# Bar Chart
+# Category Scale
 
 ```js chart-editor
 // <block:data:1>
diff --git a/docs/samples/click-zoom.md b/docs/samples/wheel/click-zoom.md
similarity index 100%
rename from docs/samples/click-zoom.md
rename to docs/samples/wheel/click-zoom.md
diff --git a/docs/samples/log.md b/docs/samples/wheel/log.md
similarity index 100%
rename from docs/samples/log.md
rename to docs/samples/wheel/log.md
diff --git a/docs/samples/over-scale-mode.md b/docs/samples/wheel/over-scale-mode.md
similarity index 100%
rename from docs/samples/over-scale-mode.md
rename to docs/samples/wheel/over-scale-mode.md
diff --git a/docs/samples/time.md b/docs/samples/wheel/time.md
similarity index 100%
rename from docs/samples/time.md
rename to docs/samples/wheel/time.md
diff --git a/docs/scripts/utils.js b/docs/scripts/utils.js
index 51d3949..0059b59 100644
--- a/docs/scripts/utils.js
+++ b/docs/scripts/utils.js
@@ -1,5 +1,6 @@
 import {valueOrDefault} from 'chart.js/helpers';
-import {addHours, startOfWeek, endOfWeek} from 'date-fns';
+import {addHours, startOfWeek, endOfWeek, isWeekend, nextMonday, getHours, setHours} from 'date-fns';
+import addDays from 'date-fns/addDays';
 
 // Adapted from http://indiegamr.com/generate-repeatable-random-numbers-in-js/
 let _seed = Date.now();
@@ -87,6 +88,30 @@ export function hourlyPoints(config) {
   return ys.map((y, i) => ({x: addHours(start, i), y}));
 }
 
+function nextOfficeHour(time) {
+  if (getHours(time) > 17) {
+    time = setHours(addDays(time, 1), 8);
+  }
+  if (getHours(time) < 9) {
+    time = setHours(time, 9);
+  } else {
+    time = addHours(time, 1);
+  }
+  if (isWeekend(time)) {
+    time = setHours(nextMonday(time), 9);
+  }
+  return time;
+}
+
+export function officeHourPoints(config) {
+  const ys = this.numbers(config);
+  let time = new Date().valueOf();
+  return ys.map(y => {
+    time = nextOfficeHour(time);
+    return {x: +time, y};
+  });
+}
+
 export function nextWeek() {
   const now = new Date().valueOf();
   const min = startOfWeek(addHours(endOfWeek(now), 24));
diff --git a/package-lock.json b/package-lock.json
index 7d24c87..a971b6a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4465,14 +4465,14 @@
       }
     },
     "node_modules/browserslist": {
-      "version": "4.16.5",
-      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.5.tgz",
-      "integrity": "sha512-C2HAjrM1AI/djrpAUU/tr4pml1DqLIzJKSLDBXBrNErl9ZCCTXdhwxdJjYc16953+mBWf7Lw+uUJgpgb8cN71A==",
+      "version": "4.16.6",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz",
+      "integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==",
       "dev": true,
       "dependencies": {
-        "caniuse-lite": "^1.0.30001214",
+        "caniuse-lite": "^1.0.30001219",
         "colorette": "^1.2.2",
-        "electron-to-chromium": "^1.3.719",
+        "electron-to-chromium": "^1.3.723",
         "escalade": "^3.1.1",
         "node-releases": "^1.1.71"
       },
@@ -4988,9 +4988,9 @@
       }
     },
     "node_modules/chart.js": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.2.0.tgz",
-      "integrity": "sha512-Ml3R47TvOPW6gQ6T8mg/uPvyOASPpPVVF6xb7ZyHkek1c6kJIT5ScT559afXoDf6uwtpDR2BpCommkA5KT8ODg==",
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.2.1.tgz",
+      "integrity": "sha512-XsNDf3854RGZkLCt+5vWAXGAtUdKP2nhfikLGZqud6G4CvRE2ts64TIxTTfspOin2kEZvPgomE29E6oU02dYjQ==",
       "dev": true
     },
     "node_modules/chartjs-adapter-date-fns": {
@@ -6940,9 +6940,9 @@
       "dev": true
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.3.723",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.723.tgz",
-      "integrity": "sha512-L+WXyXI7c7+G1V8ANzRsPI5giiimLAUDC6Zs1ojHHPhYXb3k/iTABFmWjivEtsWrRQymjnO66/rO2ZTABGdmWg==",
+      "version": "1.3.725",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.725.tgz",
+      "integrity": "sha512-2BbeAESz7kc6KBzs7WVrMc1BY5waUphk4D4DX5dSQXJhsc3tP5ZFaiyuL0AB7vUKzDYpIeYwTYlEfxyjsGUrhw==",
       "dev": true
     },
     "node_modules/elliptic": {
@@ -22460,14 +22460,14 @@
       }
     },
     "browserslist": {
-      "version": "4.16.5",
-      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.5.tgz",
-      "integrity": "sha512-C2HAjrM1AI/djrpAUU/tr4pml1DqLIzJKSLDBXBrNErl9ZCCTXdhwxdJjYc16953+mBWf7Lw+uUJgpgb8cN71A==",
+      "version": "4.16.6",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz",
+      "integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==",
       "dev": true,
       "requires": {
-        "caniuse-lite": "^1.0.30001214",
+        "caniuse-lite": "^1.0.30001219",
         "colorette": "^1.2.2",
-        "electron-to-chromium": "^1.3.719",
+        "electron-to-chromium": "^1.3.723",
         "escalade": "^3.1.1",
         "node-releases": "^1.1.71"
       }
@@ -22874,9 +22874,9 @@
       "dev": true
     },
     "chart.js": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.2.0.tgz",
-      "integrity": "sha512-Ml3R47TvOPW6gQ6T8mg/uPvyOASPpPVVF6xb7ZyHkek1c6kJIT5ScT559afXoDf6uwtpDR2BpCommkA5KT8ODg==",
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.2.1.tgz",
+      "integrity": "sha512-XsNDf3854RGZkLCt+5vWAXGAtUdKP2nhfikLGZqud6G4CvRE2ts64TIxTTfspOin2kEZvPgomE29E6oU02dYjQ==",
       "dev": true
     },
     "chartjs-adapter-date-fns": {
@@ -24485,9 +24485,9 @@
       "dev": true
     },
     "electron-to-chromium": {
-      "version": "1.3.723",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.723.tgz",
-      "integrity": "sha512-L+WXyXI7c7+G1V8ANzRsPI5giiimLAUDC6Zs1ojHHPhYXb3k/iTABFmWjivEtsWrRQymjnO66/rO2ZTABGdmWg==",
+      "version": "1.3.725",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.725.tgz",
+      "integrity": "sha512-2BbeAESz7kc6KBzs7WVrMc1BY5waUphk4D4DX5dSQXJhsc3tP5ZFaiyuL0AB7vUKzDYpIeYwTYlEfxyjsGUrhw==",
       "dev": true
     },
     "elliptic": {
diff --git a/src/core.js b/src/core.js
index 8ee79f8..b4b44df 100644
--- a/src/core.js
+++ b/src/core.js
@@ -60,6 +60,35 @@ export function zoom(chart, amount, transition = 'none') {
   call(zoomOptions.onZoom, [{chart}]);
 }
 
+function getRange(scale, pixel0, pixel1) {
+  const v0 = scale.getValueForPixel(pixel0);
+  const v1 = scale.getValueForPixel(pixel1);
+  return {
+    min: Math.min(v0, v1),
+    max: Math.max(v0, v1)
+  };
+}
+
+export function zoomRect(chart, p0, p1, transition = 'none') {
+  const {options: {limits, zoom: zoomOptions}} = getState(chart);
+  const {mode = 'xy'} = zoomOptions;
+
+  storeOriginalScaleLimits(chart);
+  const xEnabled = directionEnabled(mode, 'x', chart);
+  const yEnabled = directionEnabled(mode, 'y', chart);
+
+  each(chart.scales, function(scale) {
+    if (scale.isHorizontal() && xEnabled) {
+      updateRange(scale, getRange(scale, p0.x, p1.x), limits, true);
+    } else if (!scale.isHorizontal() && yEnabled) {
+      updateRange(scale, getRange(scale, p0.y, p1.y), limits, true);
+    }
+  });
+
+  chart.update(transition);
+
+  call(zoomOptions.onZoom, [{chart}]);
+}
 
 export function zoomScale(chart, scaleId, range, transition = 'none') {
   storeOriginalScaleLimits(chart);
diff --git a/src/handlers.js b/src/handlers.js
index 5e2b9cd..54ce100 100644
--- a/src/handlers.js
+++ b/src/handlers.js
@@ -1,5 +1,5 @@
 import {directionEnabled, debounce} from './utils';
-import {zoom} from './core';
+import {zoom, zoomRect} from './core';
 import {callback as call} from 'chart.js/helpers';
 import {getState} from './state';
 
@@ -108,16 +108,7 @@ export function mouseUp(chart, event) {
     return;
   }
 
-  const {top, left, width, height} = chart.chartArea;
-  const amount = {
-    x: rect.zoomX,
-    y: rect.zoomY,
-    focalPoint: {
-      x: (rect.left - left) / (1 - dragDistanceX / width) + left,
-      y: (rect.top - top) / (1 - dragDistanceY / height) + top
-    }
-  };
-  zoom(chart, amount, 'zoom');
+  zoomRect(chart, {x: rect.left, y: rect.top}, {x: rect.right, y: rect.bottom}, 'zoom');
 
   setTimeout(() => (state.dragging = false), 500);
   call(zoomOptions.onZoomComplete, [chart]);
diff --git a/src/scale.types.js b/src/scale.types.js
index 64ce141..063691c 100644
--- a/src/scale.types.js
+++ b/src/scale.types.js
@@ -25,9 +25,6 @@ export function updateRange(scale, {min, max}, limits, zoom = false) {
     } else if (maxLimit < cmin + range) {
       max = cmax;
       min = cmax - range;
-    } else if (zoom && range === minRange) {
-      min = scale.min;
-      max = scale.max;
     } else {
       const offset = (range - cmax + cmin) / 2;
       min = cmin - offset;
@@ -51,17 +48,24 @@ function zoomNumericalScale(scale, zoom, center, limits) {
 
 const integerChange = (v) => v === 0 || isNaN(v) ? 0 : v < 0 ? Math.min(Math.round(v), -1) : Math.max(Math.round(v), 1);
 
-function zoomCategoryScale(scale, zoom, center, limits) {
+function existCategoryFromMaxZoom(scale) {
   const labels = scale.getLabels();
   const maxIndex = labels.length - 1;
-  if (scale.min === scale.max && zoom < 1) {
-    if (scale.min > 0) {
-      scale.min--;
-    } else if (scale.max < maxIndex) {
-      scale.max++;
-    }
+
+  if (scale.min > 0) {
+    scale.min -= 1;
   }
+  if (scale.max < maxIndex) {
+    scale.max += 1;
+  }
+
+}
+
+function zoomCategoryScale(scale, zoom, center, limits) {
   const delta = zoomDelta(scale, zoom, center);
+  if (scale.min === scale.max && zoom < 1) {
+    existCategoryFromMaxZoom(scale);
+  }
   const newRange = {min: scale.min + integerChange(delta.min), max: scale.max - integerChange(delta.max)};
   return updateRange(scale, newRange, limits, true);
 }
@@ -104,13 +108,21 @@ const OFFSETS = {
   year: 182 * 24 * 60 * 60 * 1000 // 182 d
 };
 
-function panNumericalScale(scale, delta, limits) {
+function panNumericalScale(scale, delta, limits, canZoom = false) {
   const {min: prevStart, max: prevEnd, options} = scale;
   const round = options.time && options.time.round;
   const offset = OFFSETS[round] || 0;
   const newMin = scale.getValueForPixel(scale.getPixelForValue(prevStart + offset) - delta);
   const newMax = scale.getValueForPixel(scale.getPixelForValue(prevEnd + offset) - delta);
-  return updateRange(scale, {min: newMin, max: newMax}, limits);
+  const {min: minLimit = -Infinity, max: maxLimit = Infinity} = canZoom && limits && limits[scale.axis] || {};
+  if ((newMin < minLimit || newMax > maxLimit)) {
+    return true; // At limit: No change but return true to indicate no need to store the delta.
+  }
+  return updateRange(scale, {min: newMin, max: newMax}, limits, canZoom);
+}
+
+function panNonLinearScale(scale, delta, limits) {
+  return panNumericalScale(scale, delta, limits, true);
 }
 
 export const zoomFunctions = {
@@ -121,4 +133,6 @@ export const zoomFunctions = {
 export const panFunctions = {
   category: panCategoryScale,
   default: panNumericalScale,
+  logarithmic: panNonLinearScale,
+  timeseries: panNonLinearScale,
 };


Test patch:
diff --git a/test/specs/api.spec.js b/test/specs/api.spec.js
new file mode 100644
index 0000000..2debfa9
--- /dev/null
+++ b/test/specs/api.spec.js
@@ -0,0 +1,92 @@
+describe('api', function() {
+  it('should add methods to chart instance', function() {
+    const chart = window.acquireChart({type: 'line'});
+
+    expect(typeof chart.pan).toBe('function');
+    expect(typeof chart.zoom).toBe('function');
+    expect(typeof chart.zoomScale).toBe('function');
+    expect(typeof chart.resetZoom).toBe('function');
+  });
+
+  describe('zoom and resetZoom', function() {
+    it('should accept zoom percentage as single parameter', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        options: {
+          scales: {
+            x: {
+              min: 0,
+              max: 100
+            },
+            y: {
+              min: 0,
+              max: 100
+            }
+          }
+        }
+      });
+      expect(chart.scales.x.min).toBe(0);
+      expect(chart.scales.x.max).toBe(100);
+      expect(chart.scales.y.min).toBe(0);
+      expect(chart.scales.y.max).toBe(100);
+
+      chart.zoom(1.5);
+      expect(chart.scales.x.min).toBe(25);
+      expect(chart.scales.x.max).toBe(75);
+      expect(chart.scales.y.min).toBe(25);
+      expect(chart.scales.y.max).toBe(75);
+
+      chart.zoom(0.5);
+      expect(chart.scales.x.min).toBe(12.5);
+      expect(chart.scales.x.max).toBe(87.5);
+      expect(chart.scales.y.min).toBe(12.5);
+      expect(chart.scales.y.max).toBe(87.5);
+
+      chart.resetZoom();
+      expect(chart.scales.x.min).toBe(0);
+      expect(chart.scales.x.max).toBe(100);
+      expect(chart.scales.y.min).toBe(0);
+      expect(chart.scales.y.max).toBe(100);
+    });
+
+    it('should accept different percentages per axis', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        options: {
+          scales: {
+            x: {
+              min: 0,
+              max: 100
+            },
+            y: {
+              min: 0,
+              max: 100
+            }
+          }
+        }
+      });
+      expect(chart.scales.x.min).toBe(0);
+      expect(chart.scales.x.max).toBe(100);
+      expect(chart.scales.y.min).toBe(0);
+      expect(chart.scales.y.max).toBe(100);
+
+      chart.zoom({x: 1.5, y: 1.25});
+      expect(chart.scales.x.min).toBe(25);
+      expect(chart.scales.x.max).toBe(75);
+      expect(chart.scales.y.min).toBe(12.5);
+      expect(chart.scales.y.max).toBe(87.5);
+
+      chart.zoom({x: 0.75, y: 0.3});
+      expect(chart.scales.x.min).toBe(18.75);
+      expect(chart.scales.x.max).toBe(81.25);
+      expect(chart.scales.y.min).toBe(-13.75);
+      expect(chart.scales.y.max).toBe(113.75);
+
+      chart.resetZoom();
+      expect(chart.scales.x.min).toBe(0);
+      expect(chart.scales.x.max).toBe(100);
+      expect(chart.scales.y.min).toBe(0);
+      expect(chart.scales.y.max).toBe(100);
+    });
+  });
+});
diff --git a/test/specs/zoom.spec.js b/test/specs/zoom.spec.js
index 785ec8f..cabe6d0 100644
--- a/test/specs/zoom.spec.js
+++ b/test/specs/zoom.spec.js
@@ -500,7 +500,7 @@ describe('zoom', function() {
         expect(chart.scales.x.min).not.toBe(1);
       });
 
-      it('should call onZoomRejected when onStartZoom returns false', function() {
+      it('should call onZoomRejected when onZoomStart returns false', function() {
         const rejectSpy = jasmine.createSpy('rejected');
         const chart = window.acquireChart({
           type: 'scatter',
@@ -593,6 +593,147 @@ describe('zoom', function() {
         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: {
+                  enabled: true,
+                  drag: 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};
+
+        jasmine.dispatchEvent(chart, 'mousedown', pt);
+        jasmine.dispatchEvent(chart, 'mousemove', pt2);
+        jasmine.dispatchEvent(chart, 'mouseup', pt2);
+
+        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: {
+                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: {
+                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(2);
+      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/types/tests/exports.ts b/types/tests/exports.ts
index e0a05e4..ce92307 100644
--- a/types/tests/exports.ts
+++ b/types/tests/exports.ts
@@ -1,5 +1,5 @@
 import { Chart } from 'chart.js';
-import Zoom, {pan, zoom, resetZoom } from '../index';
+import Zoom, { pan, zoom, resetZoom } from '../index';
 
 Chart.register(Zoom);
 Chart.unregister(Zoom);