diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 6e921fc6..cd323648 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 4c740ae0..023200a2 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 859ee139..77643376 100644 --- a/docs/samples/click-zoom.md +++ b/docs/samples/click-zoom.md @@ -70,7 +70,7 @@ const borderPlugin = { } } }; -// +// 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 00000000..e4a58148 --- /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 +// +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), + }] +}; +// + +// +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)); +// + +// +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, + } +}; +// + +// +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(); + } +}; +// + +// +const config = { + type: 'scatter', + data: data, + options: { + scales: scales, + plugins: { + zoom: zoomOptions, + }, + }, + plugins: [borderPlugin] +}; +// + +module.exports = { + config, +}; +``` diff --git a/src/hammer.js b/src/hammer.js index 504bec79..78f223c7 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 6e4c4a09..5e2b9cd3 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 0a84710b..48e1cfd4 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/test/specs/pan.spec.js b/test/specs/pan.spec.js index 3dd1d0a8..6ff2b8d6 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 95ef3fa7..785ec8f8 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); }); }); }); diff --git a/types/options.d.ts b/types/options.d.ts index 46cc7afc..8d12f2aa 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 {