Repository: chartjs/chartjs-plugin-zoom
Pull request #901 (2024-11-22 19:13:33)

feat: add trigger to onZoom context
Add `trigger` property to the onZoom context object, indication what triggeder the zoom. Possible values: 'api', 'drag', 'wheel', 'pinch'.


github-actions[bot]: **Size Change:** +89 B (+0.43%) 

**Total Size:** 20.8 kB

| Filename | Size | Change |
| :--- | :---: | :---: |
| `dist/chartjs-plugin-zoom.esm.js` | 7.54 kB | +30 B (+0.4%) |
| `dist/chartjs-plugin-zoom.js` | 7.68 kB | +29 B (+0.38%) |
| `dist/chartjs-plugin-zoom.min.js` | 5.61 kB | +30 B (+0.54%) |

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

Patch:
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..ec01434
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,7 @@
+FROM node:23-alpine3.19
+RUN apk add --no-cache \
+                git \
+                nano\
+                openssh
+
+USER node
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..33cb276
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,22 @@
+{
+  "name": "chartjs-plugin-zoom devcontainer",
+  "dockerFile": "Dockerfile",
+    // Use 'postCreateCommand' to run commands after the container is created.
+    "postCreateCommand": "npm install",
+    "customizations": {
+      // Configure properties specific to VS Code.
+        "vscode": {
+          "settings": {},
+          "extensions": [
+            "eamodio.gitlens",
+            "EditorConfig.EditorConfig"
+          ]
+        }
+    },
+  "containerEnv": {
+    "GIT_EDITOR": "nano"
+  },
+  "mounts": [
+    "type=bind,source=/home/${localEnv:USER}/.ssh,target=/home/node/.ssh,readonly"
+  ]
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 193d1ec..88c3397 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,18 +41,18 @@ jobs:
       shell: bash
 
     - name: Coveralls Parallel - Chrome
-      uses: coverallsapp/github-action@master
+      uses: coverallsapp/github-action@v2
       with:
         github-token: ${{ secrets.github_token }}
-        path-to-lcov: './coverage/chrome/lcov.info'
+        file: './coverage/chrome/lcov.info'
         flag-name: ${{ matrix.os }}-chrome
         parallel: true
 
     - name: Coveralls Parallel - Firefox
-      uses: coverallsapp/github-action@master
+      uses: coverallsapp/github-action@v2
       with:
         github-token: ${{ secrets.github_token }}
-        path-to-lcov: './coverage/firefox/lcov.info'
+        file: './coverage/firefox/lcov.info'
         flag-name: ${{ matrix.os }}-firefox
         parallel: true
 
@@ -61,7 +61,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Coveralls Finished
-      uses: coverallsapp/github-action@master
+      uses: coverallsapp/github-action@v2
       with:
         github-token: ${{ secrets.github_token }}
         parallel-finished: true
diff --git a/src/core.js b/src/core.js
index c581a5f..b309778 100644
--- a/src/core.js
+++ b/src/core.js
@@ -57,11 +57,12 @@ function getCenter(chart) {
 }
 
 /**
- * @param chart The chart instance
- * @param {number | {x?: number, y?: number, focalPoint?: {x: number, y: number}}} amount The zoom percentage or percentages and focal point
- * @param {string} [transition] Which transition mode to use. Defaults to 'none'
+ * @param {import('chart.js').Chart} chart The Chart instance
+ * @param {import('../types').ZoomAmount} amount The zoom percentage or percentages and focal point
+ * @param {import('chart.js').UpdateMode} [transition] Which transition mode to use. Defaults to 'none'
+ * @param {import('../types/options').ZoomTrigger} [trigger] What triggered the zoom. Defaults to 'api'
  */
-export function zoom(chart, amount, transition = 'none') {
+export function zoom(chart, amount, transition = 'none', trigger = 'api') {
   const {x = 1, y = 1, focalPoint = getCenter(chart)} = typeof amount === 'number' ? {x: amount, y: amount} : amount;
   const state = getState(chart);
   const {options: {limits, zoom: zoomOptions}} = state;
@@ -72,6 +73,7 @@ export function zoom(chart, amount, transition = 'none') {
   const yEnabled = y !== 1;
   const enabledScales = getEnabledScalesByPoint(zoomOptions, focalPoint, chart);
 
+  // @ts-expect-error No overload matches this call
   each(enabledScales || chart.scales, function(scale) {
     if (scale.isHorizontal() && xEnabled) {
       doZoom(scale, x, focalPoint, limits);
@@ -82,10 +84,18 @@ export function zoom(chart, amount, transition = 'none') {
 
   chart.update(transition);
 
-  call(zoomOptions.onZoom, [{chart}]);
+  // @ts-expect-error args not assignable to unknown[]
+  call(zoomOptions.onZoom, [{chart, trigger}]);
 }
 
-export function zoomRect(chart, p0, p1, transition = 'none') {
+/**
+ * @param {import('chart.js').Chart} chart The Chart instance
+ * @param {import('chart.js').Point} p0 First corner of the rect
+ * @param {import('chart.js').Point} p1 Opposite corner of the rect
+ * @param {import('chart.js').UpdateMode} [transition]
+ * @param {import('../types/options').ZoomTrigger} [trigger] What triggered the zoom. Defaults to 'api'
+ */
+export function zoomRect(chart, p0, p1, transition = 'none', trigger = 'api') {
   const state = getState(chart);
   const {options: {limits, zoom: zoomOptions}} = state;
   const {mode = 'xy'} = zoomOptions;
@@ -104,19 +114,32 @@ export function zoomRect(chart, p0, p1, transition = 'none') {
 
   chart.update(transition);
 
-  call(zoomOptions.onZoom, [{chart}]);
+  // @ts-expect-error args not assignable to unknown[]
+  call(zoomOptions.onZoom, [{chart, trigger}]);
 }
 
-export function zoomScale(chart, scaleId, range, transition = 'none') {
+/**
+ * @param {import('chart.js').Chart} chart The Chart instance
+ * @param {string} scaleId
+ * @param {import('../types').ScaleRange} range
+ * @param {import('chart.js').UpdateMode} [transition]
+ * @param {import('../types/options').ZoomTrigger} [trigger] What triggered the zoom. Defaults to 'api'
+ */
+export function zoomScale(chart, scaleId, range, transition = 'none', trigger = 'api') {
   const state = getState(chart);
   storeOriginalScaleLimits(chart, state);
   const scale = chart.scales[scaleId];
   updateRange(scale, range, undefined, true);
   chart.update(transition);
 
-  call(state.options.zoom?.onZoom, [{chart}]);
+  // @ts-expect-error args not assignable to unknown[]
+  call(state.options.zoom?.onZoom, [{chart, trigger}]);
 }
 
+/**
+ * @param {import('chart.js').Chart} chart The Chart instance
+ * @param {import('chart.js').UpdateMode} transition
+ */
 export function resetZoom(chart, transition = 'default') {
   const state = getState(chart);
   const originalScaleLimits = storeOriginalScaleLimits(chart, state);
@@ -134,6 +157,7 @@ export function resetZoom(chart, transition = 'default') {
   });
   chart.update(transition);
 
+  // @ts-expect-error args not assignable to unknown[]
   call(state.options.zoom.onZoomComplete, [{chart}]);
 }
 
@@ -146,6 +170,9 @@ function getOriginalRange(state, scaleId) {
   return valueOrDefault(max.options, max.scale) - valueOrDefault(min.options, min.scale);
 }
 
+/**
+ * @param {import('chart.js').Chart} chart The Chart instance
+ */
 export function getZoomLevel(chart) {
   const state = getState(chart);
   let min = 1;
@@ -178,6 +205,12 @@ function panScale(scale, delta, limits, state) {
   }
 }
 
+/**
+ * @param {import('chart.js').Chart} chart The Chart instance
+ * @param {import('../types').PanAmount} delta
+ * @param {import('chart.js').Scale[]} [enabledScales]
+ * @param {import('chart.js').UpdateMode} [transition]
+ */
 export function pan(chart, delta, enabledScales, transition = 'none') {
   const {x = 0, y = 0} = typeof delta === 'number' ? {x: delta, y: delta} : delta;
   const state = getState(chart);
@@ -189,6 +222,7 @@ export function pan(chart, delta, enabledScales, transition = 'none') {
   const xEnabled = x !== 0;
   const yEnabled = y !== 0;
 
+  // @ts-expect-error No overload matches this call
   each(enabledScales || chart.scales, function(scale) {
     if (scale.isHorizontal() && xEnabled) {
       panScale(scale, x, limits, state);
@@ -199,9 +233,13 @@ export function pan(chart, delta, enabledScales, transition = 'none') {
 
   chart.update(transition);
 
+  // @ts-expect-error args not assignable to unknown[]
   call(onPan, [{chart}]);
 }
 
+/**
+ * @param {import('chart.js').Chart} chart The Chart instance
+ */
 export function getInitialScaleBounds(chart) {
   const state = getState(chart);
   storeOriginalScaleLimits(chart, state);
@@ -214,6 +252,9 @@ export function getInitialScaleBounds(chart) {
   return scaleBounds;
 }
 
+/**
+ * @param {import('chart.js').Chart} chart The Chart instance
+ */
 export function getZoomedScaleBounds(chart) {
   const state = getState(chart);
   const scaleBounds = {};
@@ -224,6 +265,9 @@ export function getZoomedScaleBounds(chart) {
   return scaleBounds;
 }
 
+/**
+ * @param {import('chart.js').Chart} chart The Chart instance
+ */
 export function isZoomedOrPanned(chart) {
   const scaleBounds = getInitialScaleBounds(chart);
   for (const scaleId of Object.keys(chart.scales)) {
@@ -241,6 +285,9 @@ export function isZoomedOrPanned(chart) {
   return false;
 }
 
+/**
+ * @param {import('chart.js').Chart} chart The Chart instance
+ */
 export function isZoomingOrPanning(chart) {
   const state = getState(chart);
   return state.panning || state.dragging;
diff --git a/src/hammer.js b/src/hammer.js
index d8cb94c..428e41c 100644
--- a/src/hammer.js
+++ b/src/hammer.js
@@ -59,7 +59,7 @@ function handlePinch(chart, state, e) {
       }
     };
 
-    zoom(chart, amount);
+    zoom(chart, amount, 'zoom', 'pinch');
 
     // Keep track of overall scale
     state.scale = e.scale;
@@ -69,8 +69,12 @@ function handlePinch(chart, state, e) {
 function startPinch(chart, state, event) {
   if (state.options.zoom.pinch.enabled) {
     const point = getRelativePosition(event, chart);
-    call(state.options.zoom.onZoomStart, [{chart, event, point}]);
-    state.scale = 1;
+    if (call(state.options.zoom.onZoomStart, [{chart, event, point}]) === false) {
+      state.scale = null;
+      call(state.options.zoom.onZoomRejected, [{chart, event}]);
+    } else {
+      state.scale = 1;
+    }
   }
 }
 
diff --git a/src/handlers.js b/src/handlers.js
index 97c716f..6e7e091 100644
--- a/src/handlers.js
+++ b/src/handlers.js
@@ -68,11 +68,18 @@ function getPointPosition(event, chart) {
   return getRelativePosition(event, chart);
 }
 
+/**
+ * @param {import('chart.js').Chart} chart
+ * @param {*} event
+ * @param {import('../types/options').ZoomOptions} zoomOptions
+ */
 function zoomStart(chart, event, zoomOptions) {
   const {onZoomStart, onZoomRejected} = zoomOptions;
   if (onZoomStart) {
     const point = getPointPosition(event, chart);
+    // @ts-expect-error args not assignable to unknown[]
     if (call(onZoomStart, [{chart, event, point}]) === false) {
+      // @ts-expect-error args not assignable to unknown[]
       call(onZoomRejected, [{chart, event}]);
       return false;
     }
@@ -93,6 +100,7 @@ export function mouseDown(chart, event) {
     keyPressed(getModifierKey(panOptions), event) ||
     keyNotPressed(getModifierKey(zoomOptions.drag), event)
   ) {
+    // @ts-expect-error args not assignable to unknown[]
     return call(zoomOptions.onZoomRejected, [{chart, event}]);
   }
 
@@ -189,16 +197,23 @@ export function mouseUp(chart, event) {
     return;
   }
 
-  zoomRect(chart, {x: rect.left, y: rect.top}, {x: rect.right, y: rect.bottom}, 'zoom');
+  zoomRect(chart, {x: rect.left, y: rect.top}, {x: rect.right, y: rect.bottom}, 'zoom', 'drag');
 
   state.dragging = false;
   state.filterNextClick = true;
+  // @ts-expect-error args not assignable to unknown[]
   call(onZoomComplete, [{chart}]);
 }
 
+/**
+ * @param {import('chart.js').Chart} chart
+ * @param {*} event
+ * @param {import('../types/options').ZoomOptions} zoomOptions
+ */
 function wheelPreconditions(chart, event, zoomOptions) {
   // Before preventDefault, check if the modifier key required and pressed
   if (keyNotPressed(getModifierKey(zoomOptions.wheel), event)) {
+    // @ts-expect-error args not assignable to unknown[]
     call(zoomOptions.onZoomRejected, [{chart, event}]);
     return;
   }
@@ -239,7 +254,7 @@ export function wheel(chart, event) {
     }
   };
 
-  zoom(chart, amount);
+  zoom(chart, amount, 'zoom', 'wheel');
 
   call(onZoomComplete, [{chart}]);
 }
diff --git a/src/state.js b/src/state.js
index 274738a..0b8e0d0 100644
--- a/src/state.js
+++ b/src/state.js
@@ -1,5 +1,17 @@
+/**
+ * @typedef {import("chart.js").Chart} Chart
+ * @typedef {{originalScaleLimits: any; updatedScaleLimits: any; handlers: any; panDelta: any; dragging: boolean; panning: boolean; options?: import("../types/options").ZoomPluginOptions, dragStart?: any, dragEnd?: any, filterNextClick?: boolean}} ZoomPluginState
+ */
+
+/**
+ * @type WeakMap<Chart, ZoomPluginState>
+ */
 const chartStates = new WeakMap();
 
+/**
+ * @param {import("chart.js").Chart} chart
+ * @returns {ZoomPluginState}
+ */
 export function getState(chart) {
   let state = chartStates.get(chart);
   if (!state) {
diff --git a/types/index.d.ts b/types/index.d.ts
index db6e961..3a118c5 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -2,11 +2,12 @@ import { Plugin, ChartType, Chart, Scale, UpdateMode, ScaleTypeRegistry, ChartTy
 import { LimitOptions, ZoomPluginOptions } from './options';
 
 type Point = { x: number, y: number };
-type ZoomAmount = number | Partial<Point> & { focalPoint?: Point };
-type PanAmount = number | Partial<Point>;
-type ScaleRange = { min: number, max: number };
 type DistributiveArray<T> = [T] extends [unknown] ? Array<T> : never
 
+export type PanAmount = number | Partial<Point>;
+export type ScaleRange = { min: number, max: number };
+export type ZoomAmount = number | Partial<Point> & { focalPoint?: Point };
+
 declare module 'chart.js' {
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   interface PluginOptionsByType<TType extends ChartType> {
diff --git a/types/options.d.ts b/types/options.d.ts
index efe2c8b..88a911b 100644
--- a/types/options.d.ts
+++ b/types/options.d.ts
@@ -1,9 +1,10 @@
 import { Chart, Color, Point } from 'chart.js';
 import { Input as HammerInput } from 'hammerjs';
 
-type Mode = 'x' | 'y' | 'xy';
-type Key = 'ctrl' | 'alt' | 'shift' | 'meta';
-type DrawTime = 'afterDraw' | 'afterDatasetsDraw' | 'beforeDraw' | 'beforeDatasetsDraw';
+export type Mode = 'x' | 'y' | 'xy';
+export type ModifierKey = 'ctrl' | 'alt' | 'shift' | 'meta';
+export type DrawTime = 'afterDraw' | 'afterDatasetsDraw' | 'beforeDraw' | 'beforeDatasetsDraw';
+export type ZoomTrigger = 'api' | 'drag' | 'wheel' | 'pinch'
 
 export interface WheelOptions {
   /**
@@ -20,7 +21,7 @@ export interface WheelOptions {
   /**
    * Modifier key required for zooming with mouse
    */
-  modifierKey?: Key;
+  modifierKey?: ModifierKey;
 }
 
 export interface DragOptions {
@@ -52,12 +53,17 @@ export interface DragOptions {
   /**
    * Modifier key required for drag-to-zoom
    */
-  modifierKey?: Key;
+  modifierKey?: ModifierKey;
 
   /**
    * Draw time required for drag-to-zoom
    */
   drawTime?: DrawTime;
+
+  /**
+   * Maintain aspect ratio of the drag rectangle
+   */
+  maintainAspectRatio?: boolean;
 }
 
 export interface PinchOptions {
@@ -104,7 +110,7 @@ export interface ZoomOptions {
   /**
    * Function called while the user is zooming
    */
-  onZoom?: (context: { chart: Chart }) => void;
+  onZoom?: (context: { chart: Chart, trigger: ZoomTrigger }) => void;
 
   /**
    * Function called once zooming is completed
@@ -142,7 +148,7 @@ export interface PanOptions {
   /**
    * Modifier key required for panning with mouse
    */
-  modifierKey?: Key;
+  modifierKey?: ModifierKey;
 
   scaleMode?: Mode | { (chart: Chart): Mode };
   /** @deprecated Use scaleMode instead */


Test patch:
diff --git a/test/specs/api.spec.js b/test/specs/api.spec.js
index 2b6360f..1b15059 100644
--- a/test/specs/api.spec.js
+++ b/test/specs/api.spec.js
@@ -265,7 +265,7 @@ describe('api', function() {
 
       chart.zoomScale('x', {min: 2, max: 10}, 'default');
 
-      expect(zoomSpy).toHaveBeenCalledWith({chart});
+      expect(zoomSpy).toHaveBeenCalledWith({chart, trigger: 'api'});
     });
   });
 
diff --git a/test/specs/zoom.drag.spec.js b/test/specs/zoom.drag.spec.js
index 1ec51c4..a5b2501 100644
--- a/test/specs/zoom.drag.spec.js
+++ b/test/specs/zoom.drag.spec.js
@@ -553,7 +553,7 @@ describe('zoom with drag', function() {
       // expect(chart.isZoomingOrPanning()).toBe(false);
 
       expect(startSpy).toHaveBeenCalled();
-      expect(zoomSpy).toHaveBeenCalled();
+      expect(zoomSpy).toHaveBeenCalledWith({chart, trigger: 'drag'});
     });
 
     it('should call onZoomRejected when onZoomStart returns false', function() {
diff --git a/test/specs/zoom.pinch.spec.js b/test/specs/zoom.pinch.spec.js
new file mode 100644
index 0000000..fab012a
--- /dev/null
+++ b/test/specs/zoom.pinch.spec.js
@@ -0,0 +1,98 @@
+describe('pinch', () => {
+  const data = {
+    datasets: [{
+      data: [{
+        x: 1,
+        y: 3
+      }, {
+        x: 2,
+        y: 2
+      }, {
+        x: 3,
+        y: 1
+      }]
+    }]
+  };
+
+  describe('events', () => {
+    it('should call onZoomStart', function(done) {
+      const startSpy = jasmine.createSpy('started');
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              zoom: {
+                mode: 'xy',
+                onZoomStart: startSpy,
+                pinch: {
+                  enabled: true,
+                },
+              },
+            }
+          }
+        }
+      });
+
+      Simulator.gestures.pinch(chart.canvas, {pos: [chart.width / 2, chart.height / 2]}, function() {
+        expect(startSpy).toHaveBeenCalled();
+        expect(chart.scales.x.min).not.toBe(1);
+        done();
+      });
+    });
+
+    it('should call onZoomRejected when onStartZoom returns false', function(done) {
+      const rejectSpy = jasmine.createSpy('rejected');
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              zoom: {
+                mode: 'xy',
+                onZoomStart: () => false,
+                onZoomRejected: rejectSpy,
+                pinch: {
+                  enabled: true,
+                }
+              }
+            }
+          }
+        }
+      });
+
+      Simulator.gestures.pinch(chart.canvas, {}, function() {
+        expect(rejectSpy).toHaveBeenCalled();
+        expect(chart.scales.x.min).toBe(1);
+        done();
+      });
+    });
+
+    it('should call onZoomComplete', function(done) {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              zoom: {
+                mode: 'xy',
+                onZoomComplete(ctx) {
+                  expect(ctx.chart.scales.x.min).not.toBe(1);
+                  done();
+                },
+                pinch: {
+                  enabled: true,
+                },
+              },
+            }
+          }
+        }
+      });
+      Simulator.gestures.pinch(chart.canvas, {});
+    });
+
+  });
+});
diff --git a/test/specs/zoom.wheel.spec.js b/test/specs/zoom.wheel.spec.js
index a886305..acdab0d 100644
--- a/test/specs/zoom.wheel.spec.js
+++ b/test/specs/zoom.wheel.spec.js
@@ -373,8 +373,9 @@ describe('zoom with wheel', function() {
   });
 
   describe('events', function() {
-    it('should call onZoomStart', function() {
-      const startSpy = jasmine.createSpy('started');
+    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,
@@ -386,7 +387,9 @@ describe('zoom with wheel', function() {
                   enabled: true,
                 },
                 mode: 'xy',
-                onZoomStart: startSpy
+                onZoomStart: startSpy,
+                onZoom: zoomSpy,
+                onZoomComplete: () => done()
               }
             }
           }
@@ -397,8 +400,11 @@ describe('zoom with wheel', function() {
         y: chart.scales.y.getPixelForValue(1.1),
         deltaY: 1
       };
+
       jasmine.triggerWheelEvent(chart, wheelEv);
+
       expect(startSpy).toHaveBeenCalled();
+      expect(zoomSpy).toHaveBeenCalledWith({chart, trigger: 'wheel'});
       expect(chart.scales.x.min).not.toBe(1);
     });
 
@@ -467,34 +473,5 @@ describe('zoom with wheel', function() {
       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);
-    });
   });
 });