diff --git a/src/core.js b/src/core.js index c581a5f4..b3097784 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 d8cb94c4..a86bf0d3 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; diff --git a/src/handlers.js b/src/handlers.js index 97c716f7..6e7e091c 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 274738ab..0b8e0d02 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 + */ 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/test/specs/api.spec.js b/test/specs/api.spec.js index 2b6360f1..1b150590 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 1ec51c49..a5b25019 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.wheel.spec.js b/test/specs/zoom.wheel.spec.js index a8863052..acdab0d0 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); - }); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index db6e9611..3a118c5d 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 & { focalPoint?: Point }; -type PanAmount = number | Partial; -type ScaleRange = { min: number, max: number }; type DistributiveArray = [T] extends [unknown] ? Array : never +export type PanAmount = number | Partial; +export type ScaleRange = { min: number, max: number }; +export type ZoomAmount = number | Partial & { focalPoint?: Point }; + declare module 'chart.js' { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface PluginOptionsByType { diff --git a/types/options.d.ts b/types/options.d.ts index efe2c8b5..88a911b8 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 */