Repository: chartjs/chartjs-plugin-zoom
Pull request #487 (2021-05-01 14:13:53)

Add onPanStart and onZoomStart callbacks
Closes: #373
Closes: #276

* add onPanStart / onZoomStart handlers, that can return `false` to cancel the action
* add sample for limiting pan start to middle of chart
* add tests
* fix bug when `delta` is undefined
* fix callback typings


Patch:
diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js
index 6e921fc..cd32364 100644
--- a/docs/.vuepress/config.js
+++ b/docs/.vuepress/config.js
@@ -85,6 +85,7 @@ module.exports = {
         'drag',
         'api',
         'click-zoom',
+        'pan-region',
       ],
     }
   }
diff --git a/docs/guide/options.md b/docs/guide/options.md
index 4c740ae..023200a 100644
--- a/docs/guide/options.md
+++ b/docs/guide/options.md
@@ -45,6 +45,7 @@ const chart = new Chart('id', {
 | `onPan` | `{chart}` | Called while the chart is being panned
 | `onPanComplete` | `{chart}` | Called once panning is completed
 | `onPanRejected` | `{chart,event}` | Called when panning is rejected due to missing modifier key. `event` is the a [hammer event](https://hammerjs.github.io/api#event-object) that failed
+| `onPanStart` | `{chart,event,point}` | Called when panning is about to start. If this callback returns false, panning is aborted and `onPanRejected` is invoked
 
 ## Zoom
 
@@ -67,6 +68,7 @@ const chart = new Chart('id', {
 | `onZoom` | `{chart}` | Called while the chart is being zoomed
 | `onZoomComplete` | `{chart}` | Called once zooming is completed
 | `onZoomRejected` | `{chart,event}` | Called when zoom is rejected due to missing modifier key. `event` is the a [hammer event](https://hammerjs.github.io/api#event-object) that failed
+| `onZoomStart` | `{chart,event,point}` | Called when zooming is about to start. If this callback returns false, zooming is aborted and `onZoomRejected` is invoked
 
 ## Limits
 
diff --git a/docs/samples/click-zoom.md b/docs/samples/click-zoom.md
index 859ee13..7764337 100644
--- a/docs/samples/click-zoom.md
+++ b/docs/samples/click-zoom.md
@@ -70,7 +70,7 @@ const borderPlugin = {
     }
   }
 };
-// </block>
+// </block:border>
 
 const zoomStatus = () => 'Zoom: ' + (zoomOptions.zoom.enabled ? 'enabled' : 'disabled');
 
diff --git a/docs/samples/pan-region.md b/docs/samples/pan-region.md
new file mode 100644
index 0000000..e4a5814
--- /dev/null
+++ b/docs/samples/pan-region.md
@@ -0,0 +1,110 @@
+# Pan Region
+
+In this example pan is only accepted at the middle region (50%) of the chart. This region is highlighted by a red border.
+
+```js chart-editor
+// <block:data:1>
+const NUMBER_CFG = {count: 20, min: -100, 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.points(NUMBER_CFG),
+  }, {
+    label: 'My Second 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.points(NUMBER_CFG),
+  }]
+};
+// </block:data>
+
+// <block:scales:2>
+const scaleOpts = {
+  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',
+  }
+};
+const scales = {
+  x: {
+    position: 'top',
+  },
+  y: {
+    position: 'right',
+  },
+};
+Object.keys(scales).forEach(scale => Object.assign(scales[scale], scaleOpts));
+// </block:scales>
+
+// <block:zoom:0>
+const zoomOptions = {
+  limits: {
+    x: {min: -200, max: 200, minRange: 50},
+    y: {min: -200, max: 200, minRange: 50}
+  },
+  pan: {
+    enabled: true,
+    onPanStart({chart, point}) {
+      const area = chart.chartArea;
+      const w25 = area.width * 0.25;
+      const h25 = area.height * 0.25;
+      if (point.x < area.left + w25 || point.x > area.right - w25
+        || point.y < area.top + h25 || point.y > area.bottom - h25) {
+        return false; // abort
+      }
+    },
+    mode: 'xy',
+  },
+  zoom: {
+    enabled: false,
+  }
+};
+// </block:zoom>
+
+// <block:border:3>
+const borderPlugin = {
+  id: 'panAreaBorder',
+  beforeDraw(chart, args, options) {
+    const {ctx, chartArea: {left, top, width, height}} = chart;
+    ctx.save();
+    ctx.strokeStyle = 'rgba(255, 0, 0, 0.3)';
+    ctx.lineWidth = 1;
+    ctx.strokeRect(left + width * 0.25, top + height * 0.25, width / 2, height / 2);
+    ctx.restore();
+  }
+};
+// </block:border>
+
+// <block:config:1>
+const config = {
+  type: 'scatter',
+  data: data,
+  options: {
+    scales: scales,
+    plugins: {
+      zoom: zoomOptions,
+    },
+  },
+  plugins: [borderPlugin]
+};
+// </block:config>
+
+module.exports = {
+  config,
+};
+```
diff --git a/src/hammer.js b/src/hammer.js
index 504bec7..78f223c 100644
--- a/src/hammer.js
+++ b/src/hammer.js
@@ -82,28 +82,32 @@ function endPinch(chart, state, e) {
 
 function handlePan(chart, state, e) {
   const delta = state.delta;
-  if (delta !== null) {
+  if (delta) {
     state.panning = true;
     pan(chart, {x: e.deltaX - delta.x, y: e.deltaY - delta.y}, state.panScales);
     state.delta = {x: e.deltaX, y: e.deltaY};
   }
 }
 
-function startPan(chart, state, e) {
-  const {enabled, overScaleMode} = state.options.pan;
+function startPan(chart, state, event) {
+  const {enabled, overScaleMode, onPanStart, onPanRejected} = state.options.pan;
   if (!enabled) {
     return;
   }
-  const rect = e.target.getBoundingClientRect();
+  const rect = event.target.getBoundingClientRect();
   const point = {
-    x: e.center.x - rect.left,
-    y: e.center.y - rect.top
+    x: event.center.x - rect.left,
+    y: event.center.y - rect.top
   };
 
+  if (call(onPanStart, [{chart, event, point}]) === false) {
+    return call(onPanRejected, [{chart, event}]);
+  }
+
   state.panScales = overScaleMode && getEnabledScalesByPoint(overScaleMode, point, chart);
   state.delta = {x: 0, y: 0};
   clearTimeout(state.panEndTimeout);
-  handlePan(chart, state, e);
+  handlePan(chart, state, event);
 }
 
 function endPan(chart, state) {
diff --git a/src/handlers.js b/src/handlers.js
index 6e4c4a0..5e2b9cd 100644
--- a/src/handlers.js
+++ b/src/handlers.js
@@ -28,6 +28,21 @@ export function mouseMove(chart, event) {
   }
 }
 
+function zoomStart(chart, event, zoomOptions) {
+  const {onZoomStart, onZoomRejected} = zoomOptions;
+  if (onZoomStart) {
+    const {left: offsetX, top: offsetY} = event.target.getBoundingClientRect();
+    const point = {
+      x: event.clientX - offsetX,
+      y: event.clientY - offsetY
+    };
+    if (call(onZoomStart, [{chart, event, point}]) === false) {
+      call(onZoomRejected, [{chart, event}]);
+      return false;
+    }
+  }
+}
+
 export function mouseDown(chart, event) {
   const state = getState(chart);
   const {pan: panOptions, zoom: zoomOptions} = state.options;
@@ -35,6 +50,10 @@ export function mouseDown(chart, event) {
   if (panKey && event[panKey + 'Key']) {
     return call(zoomOptions.onZoomRejected, [{chart, event}]);
   }
+
+  if (zoomStart(chart, event, zoomOptions) === false) {
+    return;
+  }
   state.dragStart = event;
 
   addHandler(chart, chart.canvas, 'mousemove', mouseMove);
@@ -104,13 +123,16 @@ export function mouseUp(chart, event) {
   call(zoomOptions.onZoomComplete, [chart]);
 }
 
-export function wheel(chart, event) {
-  const {handlers: {onZoomComplete}, options: {zoom: zoomOptions}} = getState(chart);
+function wheelPreconditions(chart, event, zoomOptions) {
   const {wheelModifierKey, onZoomRejected} = zoomOptions;
-
   // Before preventDefault, check if the modifier key required and pressed
   if (wheelModifierKey && !event[wheelModifierKey + 'Key']) {
-    return call(onZoomRejected, [{chart, event}]);
+    call(onZoomRejected, [{chart, event}]);
+    return;
+  }
+
+  if (zoomStart(chart, event, zoomOptions) === false) {
+    return;
   }
 
   // Prevent the event from triggering the default behavior (eg. Content scrolling).
@@ -123,6 +145,15 @@ export function wheel(chart, event) {
   if (event.deltaY === undefined) {
     return;
   }
+  return true;
+}
+
+export function wheel(chart, event) {
+  const {handlers: {onZoomComplete}, options: {zoom: zoomOptions}} = getState(chart);
+
+  if (!wheelPreconditions(chart, event, zoomOptions)) {
+    return;
+  }
 
   const rect = event.target.getBoundingClientRect();
   const speed = 1 + (event.deltaY >= 0 ? -zoomOptions.speed : zoomOptions.speed);
diff --git a/src/plugin.js b/src/plugin.js
index 0a84710..48e1cfd 100644
--- a/src/plugin.js
+++ b/src/plugin.js
@@ -42,8 +42,9 @@ export default {
 
   beforeEvent(chart, args) {
     const state = getState(chart);
-    if (args.event.type === 'click' && (state.panning || state.dragging)) {
-      // cancel the click event at pan/zoom end
+    const type = args.event.type;
+    if ((type === 'click' || type === 'mouseup') && (state.panning || state.dragging)) {
+      // cancel the click/mouseup event at pan/zoom end
       return false;
     }
   },
diff --git a/types/options.d.ts b/types/options.d.ts
index 46cc7af..8d12f2a 100644
--- a/types/options.d.ts
+++ b/types/options.d.ts
@@ -1,4 +1,4 @@
-import { Chart, Color } from 'chart.js';
+import { Chart, Color, Point } from 'chart.js';
 
 
 type Mode = 'x' | 'y' | 'xy';
@@ -51,18 +51,19 @@ export interface ZoomOptions {
   /**
    * Function called while the user is zooming
    */
-  onZoom?: (chart: Chart) => void;
+  onZoom?: (context: { chart: Chart }) => void;
 
   /**
    * Function called once zooming is completed
    */
-  onZoomComplete?: (chart: Chart) => void;
+  onZoomComplete?: (context: { chart: Chart }) => void;
 
   /**
    * Function called when wheel input occurs without modifier key
    */
-  onZoomRejected?: (chart: Chart, event: Event) => void;
+  onZoomRejected?: (context: { chart: Chart, event: Event }) => void;
 
+  onZoomStart?: (context: { chart: Chart, event: Event, point: Point }) => void;
 }
 
 /**
@@ -96,18 +97,20 @@ export interface PanOptions {
   /**
    * Function called while the user is panning
    */
-  onPan?: (chart: Chart) => void;
+  onPan?: (context: { chart: Chart }) => void;
 
   /**
    * Function called once panning is completed
    */
-  onPanComplete?: (chart: Chart) => void;
+  onPanComplete?: (context: { chart: Chart }) => void;
 
   /**
    * Function called when pan fails because modifier key was not detected.
    * event is the a hammer event that failed - see https://hammerjs.github.io/api#event-object
    */
-  onPanRejected?: (chart: Chart, event: Event) => void;
+  onPanRejected?: (context: { chart: Chart, event: Event }) => void;
+
+  onPanStart?: (context: { chart: Chart, event: Event, point: Point }) => boolean | undefined;
 }
 
 export interface LimitOptions {


Test patch:
diff --git a/test/specs/pan.spec.js b/test/specs/pan.spec.js
index 3dd1d0a..6ff2b8d 100644
--- a/test/specs/pan.spec.js
+++ b/test/specs/pan.spec.js
@@ -1,3 +1,93 @@
 describe('pan', function() {
   describe('auto', jasmine.fixture.specs('pan'));
+
+  const data = {
+    datasets: [{
+      data: [{
+        x: 1,
+        y: 3
+      }, {
+        x: 2,
+        y: 2
+      }, {
+        x: 3,
+        y: 1
+      }]
+    }]
+  };
+
+  describe('events', function() {
+    it('should call onPanStart', function(done) {
+      const startSpy = jasmine.createSpy('started');
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              pan: {
+                enabled: true,
+                mode: 'xy',
+                onPanStart: startSpy
+              }
+            }
+          }
+        }
+      });
+
+      Simulator.gestures.pan(chart.canvas, {deltaX: -350, deltaY: 0, duration: 50}, function() {
+        expect(startSpy).toHaveBeenCalled();
+        expect(chart.scales.x.min).not.toBe(1);
+        done();
+      });
+    });
+
+    it('should call onPanRejected when onStartPan returns false', function(done) {
+      const rejectSpy = jasmine.createSpy('rejected');
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              pan: {
+                enabled: true,
+                mode: 'xy',
+                onPanStart: () => false,
+                onPanRejected: rejectSpy
+              }
+            }
+          }
+        }
+      });
+
+      Simulator.gestures.pan(chart.canvas, {deltaX: -350, deltaY: 0, duration: 50}, function() {
+        expect(rejectSpy).toHaveBeenCalled();
+        expect(chart.scales.x.min).toBe(1);
+        done();
+      });
+    });
+
+    it('should call onPanComplete', function(done) {
+      const chart = window.acquireChart({
+        type: 'scatter',
+        data,
+        options: {
+          plugins: {
+            zoom: {
+              pan: {
+                enabled: true,
+                mode: 'xy',
+                onPanComplete(ctx) {
+                  expect(ctx.chart.scales.x.min).not.toBe(1);
+                  done();
+                }
+              }
+            }
+          }
+        }
+      });
+      Simulator.gestures.pan(chart.canvas, {deltaX: -350, deltaY: 0, duration: 50});
+    });
+  });
 });
diff --git a/test/specs/zoom.spec.js b/test/specs/zoom.spec.js
index 95ef3fa..785ec8f 100644
--- a/test/specs/zoom.spec.js
+++ b/test/specs/zoom.spec.js
@@ -470,29 +470,129 @@ describe('zoom', function() {
     });
   });
 
-  describe('onZoomComplete', function() {
-    it('should be called', function(done) {
-      const chart = window.acquireChart({
-        type: 'scatter',
-        data,
-        options: {
-          plugins: {
-            zoom: {
+  describe('events', function() {
+    describe('wheel', function() {
+      it('should call onZoomStart', function() {
+        const startSpy = jasmine.createSpy('started');
+        const chart = window.acquireChart({
+          type: 'scatter',
+          data,
+          options: {
+            plugins: {
               zoom: {
-                enabled: true,
-                mode: 'xy',
-                onZoomComplete: done
+                zoom: {
+                  enabled: true,
+                  drag: false,
+                  mode: 'xy',
+                  onZoomStart: startSpy
+                }
               }
             }
           }
-        }
+        });
+        const wheelEv = {
+          x: chart.scales.x.getPixelForValue(1.5),
+          y: chart.scales.y.getPixelForValue(1.1),
+          deltaY: 1
+        };
+        jasmine.triggerWheelEvent(chart, wheelEv);
+        expect(startSpy).toHaveBeenCalled();
+        expect(chart.scales.x.min).not.toBe(1);
+      });
+
+      it('should call onZoomRejected when onStartZoom returns false', function() {
+        const rejectSpy = jasmine.createSpy('rejected');
+        const chart = window.acquireChart({
+          type: 'scatter',
+          data,
+          options: {
+            plugins: {
+              zoom: {
+                zoom: {
+                  enabled: true,
+                  drag: false,
+                  mode: 'xy',
+                  onZoomStart: () => false,
+                  onZoomRejected: rejectSpy
+                }
+              }
+            }
+          }
+        });
+        const wheelEv = {
+          x: chart.scales.x.getPixelForValue(1.5),
+          y: chart.scales.y.getPixelForValue(1.1),
+          deltaY: 1
+        };
+        jasmine.triggerWheelEvent(chart, wheelEv);
+        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: {
+                  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);
+      });
+    });
+
+    describe('drag', function() {
+      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,
+          options: {
+            plugins: {
+              zoom: {
+                zoom: {
+                  enabled: true,
+                  drag: true,
+                  mode: 'xy',
+                  onZoomStart: startSpy,
+                  onZoom: zoomSpy,
+                  onZoomComplete: done
+                }
+              }
+            }
+          }
+        });
+
+        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(startSpy).toHaveBeenCalled();
+        expect(zoomSpy).toHaveBeenCalled();
       });
-      const wheelEv = {
-        x: chart.scales.x.getPixelForValue(1.5),
-        y: chart.scales.y.getPixelForValue(1.1),
-        deltaY: 1
-      };
-      jasmine.triggerWheelEvent(chart, wheelEv);
     });
   });
 });