Repository: chartjs/chartjs-plugin-zoom
Pull request #536 (2021-06-06 07:01:25)

Add getZoomLevel function
Changes:
 * new `getZoomLevel` function
 * `onZoomComplete` call after `resetZoom`
 * rename `chartState` to `state` for consistency
 * utilize `valueOrDefault` - this could break something, it is not checking for `null`. @joshkel was there a reason for checking `null`?


joshkel: I don't think checking for null is needed; I was mostly being paranoid / overly defensive.

If I'm following the Chart.js code correctly, null can show up for a scale's min and max (see [here](https://github.com/chartjs/Chart.js/blob/6a46cc39bbf51f4ae2f7d716efe9ec450390a0bf/src/scales/scale.logarithmic.js#L74)), however, even if it did, Math.min and Max.max will ignore it, which seems like the desired behavior.

Patch:
diff --git a/docs/samples/basic.md b/docs/samples/basic.md
index f2b076e..3a2ccd3 100644
--- a/docs/samples/basic.md
+++ b/docs/samples/basic.md
@@ -68,12 +68,18 @@ const zoomOptions = {
       enabled: true
     },
     mode: 'xy',
+    onZoomComplete({chart}) {
+      // This update is needed to display up to date zoom level in the title.
+      // Without this, previous zoom level is displayed.
+      // The reason is: title uses the same beforeUpdate hook, and is evaluated before zoom.
+      chart.update('none');
+    }
   }
 };
 // </block:zoom>
 
 const panStatus = () => zoomOptions.pan.enabled ? 'enabled' : 'disabled';
-const zoomStatus = () => zoomOptions.zoom.wheel.enabled ? 'enabled' : 'disabled';
+const zoomStatus = (chart) => (zoomOptions.zoom.wheel.enabled ? 'enabled' : 'disabled') + ' (' + chart.getZoomLevel() + 'x)';
 
 // <block:config:1>
 const config = {
@@ -86,7 +92,7 @@ const config = {
       title: {
         display: true,
         position: 'bottom',
-        text: (ctx) => 'Zoom: ' + zoomStatus() + ', Pan: ' + panStatus()
+        text: (ctx) => 'Zoom: ' + zoomStatus(ctx.chart) + ', Pan: ' + panStatus()
       }
     },
     onClick(e) {
diff --git a/docs/samples/wheel/click-zoom.md b/docs/samples/wheel/click-zoom.md
index 512e3de..20e0ba1 100644
--- a/docs/samples/wheel/click-zoom.md
+++ b/docs/samples/wheel/click-zoom.md
@@ -73,7 +73,7 @@ const borderPlugin = {
   id: 'chartAreaBorder',
   beforeDraw(chart, args, options) {
     const {ctx, chartArea: {left, top, width, height}} = chart;
-    if (chart.options.plugins.zoom.zoom.enabled) {
+    if (chart.options.plugins.zoom.zoom.wheel.enabled) {
       ctx.save();
       ctx.strokeStyle = 'red';
       ctx.lineWidth = 1;
@@ -84,7 +84,7 @@ const borderPlugin = {
 };
 // </block:border>
 
-const zoomStatus = () => 'Zoom: ' + (zoomOptions.zoom.enabled ? 'enabled' : 'disabled');
+const zoomStatus = () => 'Zoom: ' + (zoomOptions.zoom.wheel.enabled ? 'enabled' : 'disabled');
 
 // <block:config:1>
 const config = {
diff --git a/src/core.js b/src/core.js
index e8fa2a3..4f978b2 100644
--- a/src/core.js
+++ b/src/core.js
@@ -1,4 +1,4 @@
-import {each, callback as call, sign} from 'chart.js/helpers';
+import {each, callback as call, sign, valueOrDefault} from 'chart.js/helpers';
 import {panFunctions, updateRange, zoomFunctions} from './scale.types';
 import {getState} from './state';
 import {directionEnabled, getEnabledScalesByPoint} from './utils';
@@ -102,9 +102,9 @@ export function zoomScale(chart, scaleId, range, transition = 'none') {
   chart.update(transition);
 }
 
-
 export function resetZoom(chart, transition = 'default') {
-  const originalScaleLimits = storeOriginalScaleLimits(chart, getState(chart));
+  const state = getState(chart);
+  const originalScaleLimits = storeOriginalScaleLimits(chart, state);
 
   each(chart.scales, function(scale) {
     const scaleOptions = scale.options;
@@ -117,6 +117,29 @@ export function resetZoom(chart, transition = 'default') {
     }
   });
   chart.update(transition);
+  call(state.options.zoom.onZoomComplete, [{chart}]);
+}
+
+function getOriginalRange(state, scaleId) {
+  const original = state.originalScaleLimits[scaleId];
+  if (!original) {
+    return;
+  }
+  const {min, max} = original;
+  return valueOrDefault(max.options, max.scale) - valueOrDefault(min.options, min.scale);
+}
+
+export function getZoomLevel(chart) {
+  const state = getState(chart);
+  let min = 1;
+  let max = 1;
+  each(chart.scales, function(scale) {
+    const origRange = getOriginalRange(state, scale.id);
+    const level = Math.round(origRange / (scale.max - scale.min) * 100) / 100;
+    min = Math.min(min, level);
+    max = Math.max(max, level);
+  });
+  return min < 1 ? min : max;
 }
 
 function panScale(scale, delta, limits, state) {
diff --git a/src/handlers.js b/src/handlers.js
index 8836e95..ec49d63 100644
--- a/src/handlers.js
+++ b/src/handlers.js
@@ -3,19 +3,20 @@ import {zoom, zoomRect} from './core';
 import {callback as call} from 'chart.js/helpers';
 import {getState} from './state';
 
-function removeHandler(chart, target, type) {
+function removeHandler(chart, type) {
   const {handlers} = getState(chart);
   const handler = handlers[type];
-  if (handler) {
-    target.removeEventListener(type, handler);
+  if (handler && handler.target) {
+    handler.target.removeEventListener(type, handler);
     delete handlers[type];
   }
 }
 
 function addHandler(chart, target, type, handler) {
   const {handlers, options} = getState(chart);
-  removeHandler(chart, target, type);
+  removeHandler(chart, type);
   handlers[type] = (event) => handler(chart, event, options);
+  handlers[type].target = target;
   target.addEventListener(type, handlers[type]);
 }
 
@@ -95,7 +96,7 @@ export function mouseUp(chart, event) {
     return;
   }
 
-  removeHandler(chart.canvas, 'mousemove', chart);
+  removeHandler(chart, 'mousemove');
   const {mode, onZoomComplete, drag: {threshold = 0}} = state.options.zoom;
   const rect = computeDragRect(chart, mode, state.dragStart, event);
   const distanceX = directionEnabled(mode, 'x', chart) ? rect.width : 0;
@@ -184,26 +185,22 @@ export function addListeners(chart, options) {
     addHandler(chart, canvas, 'wheel', wheel);
     addDebouncedHandler(chart, 'onZoomComplete', onZoomComplete, 250);
   } else {
-    removeHandler(chart, canvas, 'wheel');
+    removeHandler(chart, 'wheel');
   }
   if (dragOptions.enabled) {
     addHandler(chart, canvas, 'mousedown', mouseDown);
     addHandler(chart, canvas.ownerDocument, 'mouseup', mouseUp);
   } else {
-    removeHandler(chart, canvas, 'mousedown');
-    removeHandler(chart, canvas, 'mousemove');
-    removeHandler(chart, canvas.ownerDocument, 'mouseup');
+    removeHandler(chart, 'mousedown');
+    removeHandler(chart, 'mousemove');
+    removeHandler(chart, 'mouseup');
   }
 }
 
 export function removeListeners(chart) {
-  const {canvas} = chart;
-  if (!canvas) {
-    return;
-  }
-  removeHandler(chart, canvas, 'mousedown');
-  removeHandler(chart, canvas, 'mousemove');
-  removeHandler(chart, canvas.ownerDocument, 'mouseup');
-  removeHandler(chart, canvas, 'wheel');
-  removeHandler(chart, canvas, 'click');
+  removeHandler(chart, 'mousedown');
+  removeHandler(chart, 'mousemove');
+  removeHandler(chart, 'mouseup');
+  removeHandler(chart, 'wheel');
+  removeHandler(chart, 'click');
 }
diff --git a/src/plugin.js b/src/plugin.js
index bbabfbb..5d98b1d 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} from './core';
+import {pan, zoom, resetZoom, zoomScale, getZoomLevel} from './core';
 import {panFunctions, zoomFunctions} from './scale.types';
 import {getState, removeState} from './state';
 import {version} from '../package.json';
@@ -50,6 +50,7 @@ export default {
     chart.zoom = (args, transition) => zoom(chart, args, transition);
     chart.zoomScale = (id, range, transition) => zoomScale(chart, id, range, transition);
     chart.resetZoom = (transition) => resetZoom(chart, transition);
+    chart.getZoomLevel = () => getZoomLevel(chart);
   },
 
   beforeEvent(chart) {
diff --git a/src/scale.types.js b/src/scale.types.js
index 55994e1..42d1623 100644
--- a/src/scale.types.js
+++ b/src/scale.types.js
@@ -1,3 +1,4 @@
+import {valueOrDefault} from 'chart.js/helpers';
 import {getState} from './state';
 
 function zoomDelta(scale, zoom, center) {
@@ -14,26 +15,23 @@ function zoomDelta(scale, zoom, center) {
   };
 }
 
-function getLimit(chartState, scale, scaleLimits, prop, fallback) {
+function getLimit(state, scale, scaleLimits, prop, fallback) {
   let limit = scaleLimits[prop];
   if (limit === 'original') {
-    const original = chartState.originalScaleLimits[scale.id][prop];
-    limit = original.options !== null && original.options !== undefined ? original.options : original.scale;
+    const original = state.originalScaleLimits[scale.id][prop];
+    limit = valueOrDefault(original.options, original.scale);
   }
-  if (limit === null || limit === undefined) {
-    limit = fallback;
-  }
-  return limit;
+  return valueOrDefault(limit, fallback);
 }
 
 export function updateRange(scale, {min, max}, limits, zoom = false) {
-  const chartState = getState(scale.chart);
+  const state = getState(scale.chart);
   const {id, axis, options: scaleOpts} = scale;
 
   const scaleLimits = limits && (limits[id] || limits[axis]) || {};
   const {minRange = 0} = scaleLimits;
-  const minLimit = getLimit(chartState, scale, scaleLimits, 'min', -Infinity);
-  const maxLimit = getLimit(chartState, scale, scaleLimits, 'max', Infinity);
+  const minLimit = getLimit(state, scale, scaleLimits, 'min', -Infinity);
+  const maxLimit = getLimit(state, scale, scaleLimits, 'max', Infinity);
 
   const cmin = Math.max(min, minLimit);
   const cmax = Math.min(max, maxLimit);
@@ -134,8 +132,11 @@ function panNumericalScale(scale, delta, limits, canZoom = false) {
   const newMin = scale.getValueForPixel(scale.getPixelForValue(prevStart + offset) - delta);
   const newMax = scale.getValueForPixel(scale.getPixelForValue(prevEnd + offset) - delta);
   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.
+  if (isNaN(newMin) || isNaN(newMax) || newMin < minLimit || newMax > maxLimit) {
+    // At limit: No change but return true to indicate no need to store the delta.
+    // NaN can happen for 0-dimension scales (either because they were configured
+    // with min === max or because the chart has 0 plottable area).
+    return true;
   }
   return updateRange(scale, {min: newMin, max: newMax}, limits, canZoom);
 }
diff --git a/types/index.d.ts b/types/index.d.ts
index c0911f9..57cf5ff 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -23,6 +23,7 @@ declare module 'chart.js' {
     zoom(zoom: ZoomAmount, useTransition?: boolean, mode?: UpdateMode): void;
     zoomScale(id: string, range: ScaleRange, mode?: UpdateMode): void;
     resetZoom(mode?: UpdateMode): void;
+    getZoomLevel(): number;
   }
 }
 
@@ -46,3 +47,4 @@ export function pan(chart: Chart, amount: PanAmount, scales?: Scale[], mode?: Up
 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): number;


Test patch:
diff --git a/test/specs/pan.spec.js b/test/specs/pan.spec.js
index d5e475c..7772575 100644
--- a/test/specs/pan.spec.js
+++ b/test/specs/pan.spec.js
@@ -92,6 +92,38 @@ describe('pan', function() {
       expect(scale.min).toBeLessThan(2);
       expect(scale.max).toBe(scale.min + 2);
     });
+
+    it('should handle zero-dimension scales', function() {
+      const chart = window.acquireChart({
+        type: 'line',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              pan: {
+                enabled: true,
+                mode: 'y',
+              }
+            }
+          },
+          scales: {
+            y: {
+              type: 'linear',
+              min: 2,
+              max: 2
+            }
+          }
+        }
+      });
+      const scale = chart.scales.y;
+      expect(scale.min).toBe(2);
+      expect(scale.max).toBe(2);
+      chart.pan(50);
+      expect(scale.min).toBe(2);
+      expect(scale.max).toBe(2);
+      expect(scale.options.min).toBe(2);
+      expect(scale.options.max).toBe(2);
+    });
   });
 
   describe('events', function() {