Repository: chartjs/chartjs-plugin-zoom
Pull request #585 (2021-09-17 12:32:20)

Add chart method for getting initial scale bounds
Based on the discussion in #584, this adds a new method, `getInitialScaleBounds` that returns an object mapping scale IDs to {min, max} values.

```js
console.log(chart.getInitialScaleBounds());
// {
//    x: {min: 3, max: 8},
//    y: {min: -10, max: 10}
// }
```

I've added tests and type definitions.  I couldn't see anywhere obvious to document this in the guide, but I think exporting it from `index.d.ts` at least adds some basic type doc stuff, right?

LeeLenaleee: Guess to keep it in line with the main chartjs docs it's best to create a sub cat under the developer section call it API and put it in there

https://www.chartjs.org/docs/master/developers/api.html
kurkle: Added a suggestion, but I did not try this - so it might not make sense.
Looks good to me. Wondering if a (additional?) higher level `isZoomedOrPanned()` (or something) would be a better API for this kind of use cases?
MrJohz: > Guess to keep it in line with the main chartjs docs it's best to create a sub cat under the developer section call it API and put it in there

:+1: Can do

> Looks good to me. Wondering if a (additional?) higher level `isZoomedOrPanned()` (or something) would be a better API for this kind of use cases?

I can imagine getting the original bounds will still be useful, but you're right, having the higher level API there as well would also be useful.  I'll have a go at adding that as well, although I might only get round to that on Monday or something.
life-of-e: A isZoomedOrPanned() might be helpful, it may also address my problem where trying to reset all of the charts on a page at once breaks (see my Discussion #583)
MrJohz: I've added documentation for all methods added on the Chart.js instance.  I also noticed while doing that, that the `zoom` method had the wrong Typescript types, so I fixed that as well.

@kurkle, does that work for you?
kurkle: Looks like there are some linting errors
MrJohz: I think I've fixed that issue, there was also a linting error in the documentation somewhere that I believe I've fixed now as well.

Patch:
diff --git a/docs/guide/developers.md b/docs/guide/developers.md
index ac38938..80f4811 100644
--- a/docs/guide/developers.md
+++ b/docs/guide/developers.md
@@ -1,5 +1,49 @@
 # Developers
 
+## Imperative Zoom/Pan API
+
+Alongside user-driven interactions, it is also possible to imperatively interact with the chart, either to manually zoom into a selected region, or to get information about the current zoom status.
+
+### `chart.pan(delta, scales?, mode = 'none'): void`
+
+Pans the current chart by the specified amount in one or more axes.  The value of `delta` can be a number, in which case all axes are panned by the same amount, or it can be an `{x, y}` object to pan different amounts in the horizontal and vertical directions.  The value of `scales` is a list of scale objects that should be panned - by default, all scales of the chart will be panned.  The value of `mode` should be one of the Chart.js [animation modes](https://www.chartjs.org/docs/latest/configuration/animations.html#default-transitions).
+
+### `chart.zoom(zoomLevel, mode = 'none'): void`
+
+Zooms the current chart by the specified amount in one more axes.  The value of `zoomLevel` can be a number, in which case all axes are zoomed by the same amount, or it can be an `{x, y}` object to zoom different amounts in the horizontal and vertical directions.  The value of `mode` should be one of the Chart.js [animation modes](https://www.chartjs.org/docs/latest/configuration/animations.html#default-transitions).
+
+### `chart.zoomScale(scaleId, newRange, mode = 'none'): void`
+
+Zooms the specified scale to the range given by `newRange`.  This is an object in the form `{min, max}` and represents the new bounds of that scale.  The value of `mode` should be one of the Chart.js [animation modes](https://www.chartjs.org/docs/latest/configuration/animations.html#default-transitions).
+
+### `chart.resetZoom(mode = 'none'): void`
+
+Resets the current chart bounds to the defaults that were used before any zooming or panning occurred.  The value of `mode` should be one of the Chart.js [animation modes](https://www.chartjs.org/docs/latest/configuration/animations.html#default-transitions).
+
+### `chart.getZoomLevel(): number`
+
+Returns the current zoom level.  If this is the same as the chart's initial scales, the value returned will be `1.0`.  Otherwise, the value will be less than one if the chart has been zoomed out, and more than one if it has been zoomed in.  If different axes have been zoomed by different amounts, the returned value will be the zoom level of the most zoomed out axis if any have been zoomed out, otherwise it will be the zoom level of the most zoomed-in axis.
+
+If the chart has been panned but not zoomed, this method will still return `1.0`.
+
+### `chart.getInitialScaleBounds(): Record<string, {min: number, max: number}>`
+
+Returns the initial scale bounds of each scale before any zooming or panning took place.  This is returned in the format of an object, e.g.
+
+```json
+{
+  x: {min: 0, max: 100},
+  y1: {min: 50, max: 80},
+  y2: {min: 0.1, max: 0.8}
+}
+```
+
+### `chart.isZoomedOrPanned(): boolean`
+
+Returns whether the chart has been zoomed or panned - i.e. whether the initial scale of any axis is different to the one used currently.
+
+## Custom Scales
+
 You can extend chartjs-plugin-zoom with support for [custom scales](https://www.chartjs.org/docs/latest/developers/axes.html) by using the zoom plugin's `zoomFunctions` and `panFunctions` members. These objects are indexed by scale types (scales' `id` members) and give optional handlers for zoom and pan functionality.
 
 ```js
@@ -23,7 +67,7 @@ The zoom and pan functions take the following arguments:
 | `scale` | `Scale` | Zoom, Pan | The custom scale instance (usually derived from `Chart.Scale`)
 | `zoom` | `number` | Zoom | The zoom fraction; 1.0 is unzoomed, 0.5 means zoomed in to 50% of the original area, etc.
 | `center` | `{x, y}` | Zoom | Pixel coordinates of the center of the zoom operation. `{x: 0, y: 0}` is the upper left corner of the chart's canvas.
-| `pan` | `number` | Pan | Pixel amount to pan by
+| `delta` | `number` | Pan | Pixel amount to pan by
 | `limits` | [Limits](./options#limits) | Zoom, Pan | Zoom and pan limits (from chart options)
 
 For examples, see chartjs-plugin-zoom's [default zoomFunctions and panFunctions handling for standard Chart.js axes](https://github.com/chartjs/chartjs-plugin-zoom/blob/v1.0.1/src/scale.types.js#L128).
diff --git a/src/core.js b/src/core.js
index 88f82f2..be259a4 100644
--- a/src/core.js
+++ b/src/core.js
@@ -202,3 +202,30 @@ export function pan(chart, delta, enabledScales, transition = 'none') {
   call(onPan, [{chart}]);
 }
 
+export function getInitialScaleBounds(chart) {
+  const state = getState(chart);
+  const scaleBounds = {};
+  for (const scaleId of Object.keys(chart.scales)) {
+    const {min, max} = state.originalScaleLimits[scaleId] || {min: {}, max: {}};
+    scaleBounds[scaleId] = {min: min.scale, max: max.scale};
+  }
+
+  return scaleBounds;
+}
+
+export function isZoomedOrPanned(chart) {
+  const scaleBounds = getInitialScaleBounds(chart);
+  for (const scaleId of Object.keys(chart.scales)) {
+    const {min: originalMin, max: originalMax} = scaleBounds[scaleId];
+
+    if (chart.scales[scaleId].min !== originalMin) {
+      return true;
+    }
+
+    if (chart.scales[scaleId].max !== originalMax) {
+      return true;
+    }
+  }
+
+  return false;
+}
diff --git a/src/plugin.js b/src/plugin.js
index 3338f76..26b13ab 100644
--- a/src/plugin.js
+++ b/src/plugin.js
@@ -1,7 +1,7 @@
 import Hammer from 'hammerjs';
 import {addListeners, computeDragRect, removeListeners} from './handlers';
 import {startHammer, stopHammer} from './hammer';
-import {pan, zoom, resetZoom, zoomScale, getZoomLevel} from './core';
+import {pan, zoom, resetZoom, zoomScale, getZoomLevel, getInitialScaleBounds, isZoomedOrPanned} from './core';
 import {panFunctions, zoomFunctions} from './scale.types';
 import {getState, removeState} from './state';
 import {version} from '../package.json';
@@ -52,6 +52,8 @@ export default {
     chart.zoomScale = (id, range, transition) => zoomScale(chart, id, range, transition);
     chart.resetZoom = (transition) => resetZoom(chart, transition);
     chart.getZoomLevel = () => getZoomLevel(chart);
+    chart.getInitialScaleBounds = () => getInitialScaleBounds(chart);
+    chart.isZoomedOrPanned = () => isZoomedOrPanned(chart);
   },
 
   beforeEvent(chart) {
diff --git a/src/scale.types.js b/src/scale.types.js
index 936ff62..97b14e0 100644
--- a/src/scale.types.js
+++ b/src/scale.types.js
@@ -6,7 +6,12 @@ function zoomDelta(scale, zoom, center) {
   const newRange = range * (zoom - 1);
 
   const centerPoint = scale.isHorizontal() ? center.x : center.y;
-  const minPercent = (scale.getValueForPixel(centerPoint) - scale.min) / range || 0;
+  // `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
+  ));
+
   const maxPercent = 1 - minPercent;
 
   return {
diff --git a/types/index.d.ts b/types/index.d.ts
index 4cbcbdd..0ed7c65 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -20,10 +20,12 @@ declare module 'chart.js' {
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   interface Chart<TType extends keyof ChartTypeRegistry = keyof ChartTypeRegistry, TData = DistributiveArray<ChartTypeRegistry[TType]['defaultDataPoint']>, TLabel = unknown> {
     pan(pan: PanAmount, scales?: Scale[], mode?: UpdateMode): void;
-    zoom(zoom: ZoomAmount, useTransition?: boolean, mode?: UpdateMode): void;
+    zoom(zoom: ZoomAmount, mode?: UpdateMode): void;
     zoomScale(id: string, range: ScaleRange, mode?: UpdateMode): void;
     resetZoom(mode?: UpdateMode): void;
     getZoomLevel(): number;
+    getInitialScaleBounds(): Record<string, {min: number, max: number}>;
+    isZoomedOrPanned(): boolean;
   }
 }
 
@@ -48,3 +50,5 @@ export function zoom(chart: Chart, amount: ZoomAmount, mode?: UpdateMode): void;
 export function zoomScale(chart: Chart, scaleId: string, range: ScaleRange, mode?: UpdateMode): void;
 export function resetZoom(chart: Chart, mode?: UpdateMode): void;
 export function getZoomLevel(chart: Chart): number;
+export function getInitialScaleBounds(chart: Chart): Record<string, {min: number, max: number}>;
+export function isZoomedOrPanned(chart: Chart): boolean;


Test patch:
diff --git a/test/specs/api.spec.js b/test/specs/api.spec.js
index 2debfa9..fd98bf6 100644
--- a/test/specs/api.spec.js
+++ b/test/specs/api.spec.js
@@ -6,6 +6,8 @@ describe('api', function() {
     expect(typeof chart.zoom).toBe('function');
     expect(typeof chart.zoomScale).toBe('function');
     expect(typeof chart.resetZoom).toBe('function');
+    expect(typeof chart.getZoomLevel).toBe('function');
+    expect(typeof chart.getInitialScaleBounds).toBe('function');
   });
 
   describe('zoom and resetZoom', function() {
@@ -89,4 +91,95 @@ describe('api', function() {
       expect(chart.scales.y.max).toBe(100);
     });
   });
+
+  describe('getInitialScaleBounds', function() {
+    it('should provide the correct initial scale bounds regardless of the zoom level', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        options: {
+          scales: {
+            x: {
+              min: 0,
+              max: 100
+            },
+            y: {
+              min: 0,
+              max: 100
+            }
+          }
+        }
+      });
+
+      chart.zoom(1);
+      expect(chart.getInitialScaleBounds().x.min).toBe(0);
+      expect(chart.getInitialScaleBounds().x.max).toBe(100);
+      expect(chart.getInitialScaleBounds().y.min).toBe(0);
+      expect(chart.getInitialScaleBounds().y.max).toBe(100);
+
+      chart.zoom({x: 1.5, y: 1.25});
+      expect(chart.getInitialScaleBounds().x.min).toBe(0);
+      expect(chart.getInitialScaleBounds().x.max).toBe(100);
+      expect(chart.getInitialScaleBounds().y.min).toBe(0);
+      expect(chart.getInitialScaleBounds().y.max).toBe(100);
+    });
+  });
+
+  describe('isZoomedOrPanned', function() {
+    it('should return whether or not the page is currently zoomed', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        options: {
+          scales: {
+            x: {
+              min: 0,
+              max: 100
+            },
+            y: {
+              min: 0,
+              max: 100
+            }
+          }
+        }
+      });
+
+      chart.zoom(1);
+      expect(chart.isZoomedOrPanned()).toBe(false);
+
+      chart.zoom({x: 1.5, y: 1.25});
+      expect(chart.isZoomedOrPanned()).toBe(true);
+
+      chart.zoom({x: 0.25, y: 0.5});
+      expect(chart.isZoomedOrPanned()).toBe(true);
+
+      chart.resetZoom();
+      expect(chart.isZoomedOrPanned()).toBe(false);
+    });
+
+    it('should return whether or not the page is currently panned', function() {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        options: {
+          scales: {
+            x: {
+              min: 0,
+              max: 100
+            },
+            y: {
+              min: 0,
+              max: 100
+            }
+          }
+        }
+      });
+
+      chart.pan({x: 0, y: 0});
+      expect(chart.isZoomedOrPanned()).toBe(false);
+
+      chart.pan({x: 10});
+      expect(chart.isZoomedOrPanned()).toBe(true);
+
+      chart.resetZoom();
+      expect(chart.isZoomedOrPanned()).toBe(false);
+    });
+  });
 });
diff --git a/types/tests/exports.ts b/types/tests/exports.ts
index 4f75d46..e9d9ff7 100644
--- a/types/tests/exports.ts
+++ b/types/tests/exports.ts
@@ -50,7 +50,7 @@ const chart = new Chart('id', {
 
 chart.resetZoom();
 chart.zoom(1.1);
-chart.zoom({ x: 1, y: 1.1, focalPoint: { x: 10, y: 10 } }, true);
+chart.zoom({ x: 1, y: 1.1, focalPoint: { x: 10, y: 10 } }, 'zoom');
 
 chart.pan(10);
 chart.pan({ x: 10, y: 20 }, [chart.scales.x]);