From c5c7d470b2e688e69bf9721405c09404e8943524 Mon Sep 17 00:00:00 2001 From: Jukka Kurkela Date: Fri, 16 Apr 2021 12:49:06 +0300 Subject: [PATCH] Refactor to logical entities (#452) * Refactor to logical entities * Remove monolith * Complete refactoring * Add tolerance * Fix drag-zoom, complete let/const * Better helper names, shorten wheel * Remove duplicate drag rect calculations --- .eslintrc.yml | 2 + samples/.eslintrc.yml | 1 + src/core.js | 115 +++++ src/hammer.js | 153 +++++++ src/handlers.js | 165 ++++++++ src/plugin.js | 693 ++----------------------------- src/scale.types.js | 119 ++++++ src/utils.js | 110 +++++ test/fixtures/pan/category-x.js | 1 + test/fixtures/pan/category-x.png | Bin 20851 -> 20971 bytes test/index.js | 6 +- test/specs/module.spec.js | 2 +- test/specs/zoom.spec.js | 2 +- 13 files changed, 695 insertions(+), 674 deletions(-) create mode 100644 src/core.js create mode 100644 src/hammer.js create mode 100644 src/handlers.js create mode 100644 src/scale.types.js create mode 100644 src/utils.js diff --git a/.eslintrc.yml b/.eslintrc.yml index f72034e80..4494fb971 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -20,3 +20,5 @@ plugins: ['html', 'es'] rules: complexity: ["warn", 10] max-statements: ["warn", 30] + no-var: "warn" + prefer-const: ["warn", {"destructuring": "all"}] diff --git a/samples/.eslintrc.yml b/samples/.eslintrc.yml index 618932b79..b06319766 100644 --- a/samples/.eslintrc.yml +++ b/samples/.eslintrc.yml @@ -9,3 +9,4 @@ globals: rules: indent: ["error", "tab", {flatTernaryExpressions: true}] no-new: "off" + no-var: "off" diff --git a/src/core.js b/src/core.js new file mode 100644 index 000000000..e576a3c4f --- /dev/null +++ b/src/core.js @@ -0,0 +1,115 @@ +import {each, callback as call} from 'chart.js/helpers'; +import {panFunctions, zoomFunctions} from './scale.types'; +import {directionEnabled, getEnabledScalesByPoint} from './utils'; + +function storeOriginalOptions(chart) { + const originalOptions = chart.$zoom._originalOptions; + each(chart.scales, function(scale) { + if (!originalOptions[scale.id]) { + originalOptions[scale.id] = {min: scale.options.min, max: scale.options.max}; + } + }); + each(originalOptions, function(opt, key) { + if (!chart.scales[key]) { + delete originalOptions[key]; + } + }); +} + +function zoomScale(scale, zoom, center, zoomOptions) { + call(zoomFunctions[scale.type], [scale, zoom, center, zoomOptions]); +} + +/** + * @param chart The chart instance + * @param {number} percentZoomX The zoom percentage in the x direction + * @param {number} percentZoomY The zoom percentage in the y direction + * @param {{x: number, y: number}} focalPoint The x and y coordinates of zoom focal point. The point which doesn't change while zooming. E.g. the location of the mouse cursor when "drag: false" + * @param {object} zoomOptions The zoom options + * @param {string} [whichAxes] `xy`, 'x', or 'y' + * @param {boolean} [useTransition] Whether to use `zoom` transition + */ +export function doZoom(chart, percentZoomX, percentZoomY, focalPoint, zoomOptions, whichAxes, useTransition) { + const ca = chart.chartArea; + if (!focalPoint) { + focalPoint = { + x: (ca.left + ca.right) / 2, + y: (ca.top + ca.bottom) / 2, + }; + } + + if (zoomOptions.enabled) { + storeOriginalOptions(chart); + // Do the zoom here + const zoomMode = typeof zoomOptions.mode === 'function' ? zoomOptions.mode({chart: chart}) : zoomOptions.mode; + + // Which axes should be modified when fingers were used. + let _whichAxes; + if (zoomMode === 'xy' && whichAxes !== undefined) { + // based on fingers positions + _whichAxes = whichAxes; + } else { + // no effect + _whichAxes = 'xy'; + } + + const enabledScales = getEnabledScalesByPoint(zoomOptions, focalPoint.x, focalPoint.y, chart); + each(enabledScales || chart.scales, function(scale) { + if (scale.isHorizontal() && directionEnabled(zoomMode, 'x', chart) && directionEnabled(_whichAxes, 'x', chart)) { + zoomOptions.scaleAxes = 'x'; + zoomScale(scale, percentZoomX, focalPoint, zoomOptions); + } else if (!scale.isHorizontal() && directionEnabled(zoomMode, 'y', chart) && directionEnabled(_whichAxes, 'y', chart)) { + // Do Y zoom + zoomOptions.scaleAxes = 'y'; + zoomScale(scale, percentZoomY, focalPoint, zoomOptions); + } + }); + + chart.update(useTransition ? 'zoom' : 'none'); + + call(zoomOptions.onZoom, [chart]); + } +} + +export function resetZoom(chart) { + storeOriginalOptions(chart); + const originalOptions = chart.$zoom._originalOptions; + each(chart.scales, function(scale) { + + const scaleOptions = scale.options; + if (originalOptions[scale.id]) { + scaleOptions.min = originalOptions[scale.id].min; + scaleOptions.max = originalOptions[scale.id].max; + } else { + delete scaleOptions.min; + delete scaleOptions.max; + } + }); + chart.update(); +} + +function panScale(scale, delta, panOptions) { + call(panFunctions[scale.type], [scale, delta, panOptions]); +} + +export function doPan(chart, deltaX, deltaY, panOptions, panningScales) { + storeOriginalOptions(chart); + if (panOptions.enabled) { + const panMode = typeof panOptions.mode === 'function' ? panOptions.mode({chart}) : panOptions.mode; + + each(panningScales || chart.scales, function(scale) { + if (scale.isHorizontal() && directionEnabled(panMode, 'x', chart) && deltaX !== 0) { + panOptions.scaleAxes = 'x'; + panScale(scale, deltaX, panOptions); + } else if (!scale.isHorizontal() && directionEnabled(panMode, 'y', chart) && deltaY !== 0) { + panOptions.scaleAxes = 'y'; + panScale(scale, deltaY, panOptions); + } + }); + + chart.update('none'); + + call(panOptions.onPan, [chart]); + } +} + diff --git a/src/hammer.js b/src/hammer.js new file mode 100644 index 000000000..a1f110016 --- /dev/null +++ b/src/hammer.js @@ -0,0 +1,153 @@ +import Hammer from 'hammerjs'; +import {doPan, doZoom} from './core'; +import {getEnabledScalesByPoint} from './utils'; + +function createEnabler(chart, panOptions) { + return function(recognizer, event) { + if (!panOptions || !panOptions.enabled) { + return false; + } + if (!event || !event.srcEvent) { // Sometimes Hammer queries this with a null event. + return true; + } + const requireModifier = panOptions.modifierKey + && (event.pointerType === 'mouse'); + if (requireModifier && !event.srcEvent[panOptions.modifierKey + 'Key']) { + if (typeof panOptions.onPanRejected === 'function') { + panOptions.onPanRejected({ + chart: chart, + event: event + }); + } + return false; + } + return true; + }; +} + +export function startHammer(chart, options) { + const node = chart.canvas; + const {pan: panOptions, zoom: zoomOptions} = options; + + const mc = new Hammer.Manager(node); + if (zoomOptions && zoomOptions.enabled) { + mc.add(new Hammer.Pinch()); + } + if (panOptions && panOptions.enabled) { + mc.add(new Hammer.Pan({ + threshold: panOptions.threshold, + enable: createEnabler(chart, panOptions) + })); + } + + // Hammer reports the total scaling. We need the incremental amount + let currentPinchScaling; + const handlePinch = function(e) { + const diff = 1 / (currentPinchScaling) * e.scale; + const rect = e.target.getBoundingClientRect(); + const offsetX = e.center.x - rect.left; + const offsetY = e.center.y - rect.top; + const center = { + x: offsetX, + y: offsetY + }; + + // fingers position difference + const x = Math.abs(e.pointers[0].clientX - e.pointers[1].clientX); + const y = Math.abs(e.pointers[0].clientY - e.pointers[1].clientY); + + // diagonal fingers will change both (xy) axes + const p = x / y; + let xy; + if (p > 0.3 && p < 1.7) { + xy = 'xy'; + } else if (x > y) { + xy = 'x'; // x axis + } else { + xy = 'y'; // y axis + } + + doZoom(chart, diff, diff, center, zoomOptions, xy); + + if (typeof zoomOptions.onZoom === 'function') { + zoomOptions.onZoom({chart: chart}); + } + + // Keep track of overall scale + currentPinchScaling = e.scale; + }; + + mc.on('pinchstart', function() { + currentPinchScaling = 1; // reset tracker + }); + mc.on('pinch', handlePinch); + mc.on('pinchend', function(e) { + handlePinch(e); + currentPinchScaling = null; // reset + if (typeof zoomOptions.onZoomComplete === 'function') { + zoomOptions.onZoomComplete({chart: chart}); + } + }); + + let currentDeltaX = null; + let currentDeltaY = null; + let panning = false; + let panningScales = null; + const handlePan = function(e) { + if (currentDeltaX !== null && currentDeltaY !== null) { + panning = true; + const deltaX = e.deltaX - currentDeltaX; + const deltaY = e.deltaY - currentDeltaY; + currentDeltaX = e.deltaX; + currentDeltaY = e.deltaY; + doPan(chart, deltaX, deltaY, panOptions, panningScales); + } + }; + + mc.on('panstart', function(e) { + if (panOptions.enabled) { + const rect = e.target.getBoundingClientRect(); + const x = e.center.x - rect.left; + const y = e.center.y - rect.top; + panningScales = getEnabledScalesByPoint(panOptions, x, y, chart); + } + + currentDeltaX = 0; + currentDeltaY = 0; + handlePan(e); + }); + mc.on('panmove', handlePan); + mc.on('panend', function() { + currentDeltaX = null; + currentDeltaY = null; + setTimeout(function() { + panning = false; + }, 500); + if (typeof panOptions.onPanComplete === 'function') { + panOptions.onPanComplete({chart: chart}); + } + }); + + chart.$zoom._ghostClickHandler = function(e) { + if (panning && e.cancelable) { + e.stopImmediatePropagation(); + e.preventDefault(); + } + }; + node.addEventListener('click', chart.$zoom._ghostClickHandler); + + chart._mc = mc; +} + +export function stopHammer(chart) { + const mc = chart._mc; + if (mc) { + mc.remove('pinchstart'); + mc.remove('pinch'); + mc.remove('pinchend'); + mc.remove('panstart'); + mc.remove('pan'); + mc.remove('panend'); + mc.destroy(); + } +} diff --git a/src/handlers.js b/src/handlers.js new file mode 100644 index 000000000..4bbe6af2a --- /dev/null +++ b/src/handlers.js @@ -0,0 +1,165 @@ +import {directionEnabled, debounce} from './utils'; +import {doZoom} from './core'; +import {callback as call} from 'chart.js/helpers'; + +function removeHandler(target, type, chart) { + const props = chart.$zoom; + const handlers = props._handlers || (props._handlers = {}); + const handler = handlers[type]; + if (handler) { + target.removeEventListener(type, handler); + delete handlers[type]; + } +} + +function addHandler(target, type, handler, {chart, options}) { + const props = chart.$zoom; + const handlers = props._handlers || (props._handlers = {}); + removeHandler(target, type, chart); + handlers[type] = (event) => handler(chart, event, options); + target.addEventListener(type, handlers[type]); +} + +export function mouseMove(chart, event) { + if (chart.$zoom._dragZoomStart) { + chart.$zoom._dragZoomEnd = event; + chart.update('none'); + } +} + +export function mouseDown(chart, event, options) { + addHandler(chart.canvas, 'mousemove', mouseMove, {chart, options}); + chart.$zoom._dragZoomStart = event; +} + +export function computeDragRect(chart, mode, beginPoint, endPoint) { + const {left: offsetX, top: offsetY} = beginPoint.target.getBoundingClientRect(); + const xEnabled = directionEnabled(mode, 'x', chart); + const yEnabled = directionEnabled(mode, 'y', chart); + let {top, left, right, bottom, width: chartWidth, height: chartHeight} = chart.chartArea; + + if (xEnabled) { + left = Math.min(beginPoint.clientX, endPoint.clientX) - offsetX; + right = Math.max(beginPoint.clientX, endPoint.clientX) - offsetX; + } + + if (yEnabled) { + top = Math.min(beginPoint.clientY, endPoint.clientY) - offsetY; + bottom = Math.max(beginPoint.clientY, endPoint.clientY) - offsetY; + } + const width = right - left; + const height = bottom - top; + + return { + left, + top, + right, + bottom, + width, + height, + zoomX: xEnabled && width ? 1 + ((chartWidth - width) / chartWidth) : 1, + zoomY: yEnabled && height ? 1 + ((chartHeight - height) / chartHeight) : 1 + }; +} + +export function mouseUp(chart, event, options) { + if (!chart.$zoom || !chart.$zoom._dragZoomStart) { + return; + } + + const zoomOptions = options.zoom; + removeHandler(chart.canvas, 'mousemove', chart); + + const props = chart.$zoom; + const beginPoint = props._dragZoomStart; + const rect = computeDragRect(chart, zoomOptions.mode, beginPoint, event); + const {width: dragDistanceX, height: dragDistanceY} = rect; + + // Remove drag start and end before chart update to stop drawing selected area + props._dragZoomStart = null; + props._dragZoomEnd = null; + + const zoomThreshold = zoomOptions.threshold || 0; + if (dragDistanceX <= zoomThreshold && dragDistanceY <= zoomThreshold) { + return; + } + + const {top, left, width, height} = chart.chartArea; + const focalPoint = { + x: (rect.left - left) / (1 - dragDistanceX / width) + left, + y: (rect.top - top) / (1 - dragDistanceY / height) + top + }; + doZoom(chart, rect.zoomX, rect.zoomY, focalPoint, zoomOptions, undefined, true); + + call(zoomOptions.onZoomComplete, [chart]); +} + +export function wheel(chart, event, options) { + const zoomOptions = options.zoom; + const {wheelModifierKey, onZoomRejected, onZoomComplete} = zoomOptions; + + // Before preventDefault, check if the modifier key required and pressed + if (wheelModifierKey && !event[wheelModifierKey + 'Key']) { + return call(onZoomRejected, [{chart, event}]); + } + + // Prevent the event from triggering the default behavior (eg. Content scrolling). + if (event.cancelable) { + event.preventDefault(); + } + + // Firefox always fires the wheel event twice: + // First without the delta and right after that once with the delta properties. + if (typeof event.deltaY === 'undefined') { + return; + } + + const rect = event.target.getBoundingClientRect(); + const speed = 1 + (event.deltaY >= 0 ? -zoomOptions.speed : zoomOptions.speed); + const center = { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }; + + doZoom(chart, speed, speed, center, zoomOptions); + + if (onZoomComplete) { + debounce(() => call(onZoomComplete, [{chart}]), 250); + } +} + +export function addListeners(chart, options) { + const props = chart.$zoom; + const canvas = chart.canvas; + + // Install listeners. Do this dynamically based on options so that we can turn zoom on and off + // We also want to make sure listeners aren't always on. E.g. if you're scrolling down a page + // and the mouse goes over a chart you don't want it intercepted unless the plugin is enabled + const zoomEnabled = options.zoom && options.zoom.enabled; + const dragEnabled = options.zoom.drag; + if (zoomEnabled && !dragEnabled) { + addHandler(canvas, 'wheel', wheel, {chart, options}); + } else if (props._wheelHandler) { + removeHandler(canvas, 'wheel', chart); + } + if (zoomEnabled && dragEnabled) { + addHandler(canvas, 'mousedown', mouseDown, {chart, options}); + addHandler(canvas.ownerDocument, 'mouseup', mouseUp, {chart, options}); + } else { + removeHandler(canvas, 'mousedown', chart); + removeHandler(canvas, 'mousemove', chart); + removeHandler(canvas.ownerDocument, 'mouseup', chart); + } +} + +export function removeListeners(chart) { + const {canvas, $zoom: props} = chart; + if (!canvas || !props) { + return; + } + removeHandler(canvas, 'mousedown', chart); + removeHandler(canvas, 'mousemove', chart); + removeHandler(canvas.ownerDocument, 'mouseup', chart); + removeHandler(canvas, 'wheel', chart); + removeHandler(canvas, 'click', chart); +} diff --git a/src/plugin.js b/src/plugin.js index 0061c45cb..475e05530 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -1,351 +1,9 @@ -import {clone, each, isNullOrUndef} from 'chart.js/helpers'; import Hammer from 'hammerjs'; +import {addListeners, computeDragRect, removeListeners} from './handlers'; +import {startHammer, stopHammer} from './hammer'; +import {resetZoom} from './core'; -// Zoom namespace (kept under Chart prior to Chart.js 3) -var zoomNS = {}; - -// Where we store functions to handle different scale types -var zoomFunctions = zoomNS.zoomFunctions = zoomNS.zoomFunctions || {}; -var panFunctions = zoomNS.panFunctions = zoomNS.panFunctions || {}; - -function resolveOptions(chart, options) { - var props = chart.$zoom; - props._options = options; - - // Install listeners. Do this dynamically based on options so that we can turn zoom on and off - // We also want to make sure listeners aren't always on. E.g. if you're scrolling down a page - // and the mouse goes over a chart you don't want it intercepted unless the plugin is enabled - var node = props._node; - var zoomEnabled = options.zoom && options.zoom.enabled; - var dragEnabled = options.zoom.drag; - if (zoomEnabled && !dragEnabled) { - node.addEventListener('wheel', props._wheelHandler); - } else { - node.removeEventListener('wheel', props._wheelHandler); - } - if (zoomEnabled && dragEnabled) { - node.addEventListener('mousedown', props._mouseDownHandler); - node.ownerDocument.addEventListener('mouseup', props._mouseUpHandler); - } else { - node.removeEventListener('mousedown', props._mouseDownHandler); - node.removeEventListener('mousemove', props._mouseMoveHandler); - node.ownerDocument.removeEventListener('mouseup', props._mouseUpHandler); - } -} - -function storeOriginalOptions(chart) { - var originalOptions = chart.$zoom._originalOptions; - each(chart.scales, function(scale) { - if (!originalOptions[scale.id]) { - originalOptions[scale.id] = clone(scale.options); - } - }); - each(originalOptions, function(opt, key) { - if (!chart.scales[key]) { - delete originalOptions[key]; - } - }); -} - -/** - * @param {string|function} [mode] can be 'x', 'y' or 'xy' - * @param {string} [dir] can be 'x' or 'y' - * @param {import('chart.js').Chart} [chart] instance of the chart in question - */ -function directionEnabled(mode, dir, chart) { - if (mode === undefined) { - return true; - } else if (typeof mode === 'string') { - return mode.indexOf(dir) !== -1; - } else if (typeof mode === 'function') { - return mode({chart: chart}).indexOf(dir) !== -1; - } - - return false; -} - -/** This function use for check what axis now under mouse cursor. - * @param {number} [x] X position - * @param {number} [y] Y position - * @param {import('chart.js').Chart} [chart] instance of the chart in question - */ -function getScaleUnderPoint(x, y, chart) { - var scales = chart.scales; - var scaleIds = Object.keys(scales); - for (var i = 0; i < scaleIds.length; i++) { - var scale = scales[scaleIds[i]]; - if (y >= scale.top && y <= scale.bottom && x >= scale.left && x <= scale.right) { - return scale; - } - } - return null; -} - -/** This function return only one scale whose position is under mouse cursor and which direction is enabled. - * If under mouse hasn't scale, then return all other scales which 'mode' is diffrent with overScaleMode. - * So 'overScaleMode' works as a limiter to scale the user-selected scale (in 'mode') only when the cursor is under the scale, - * and other directions in 'mode' works as before. - * Example: mode = 'xy', overScaleMode = 'y' -> it's means 'x' - works as before, and 'y' only works for one scale when cursor is under it. - * options.overScaleMode can be a function if user want zoom only one scale of many for example. - * @param {any} [options] pan or zoom options - * @param {number} [x] X position - * @param {number} [y] Y position - * @param {import('chart.js').Chart} [chart] instance of the chart in question - */ -function getEnabledScalesByPoint(options, x, y, chart) { - if (options.enabled && options.overScaleMode) { - var scale = getScaleUnderPoint(x, y, chart); - var mode = typeof options.overScaleMode === 'function' ? options.overScaleMode({chart: chart}, scale) : options.overScaleMode; - - if (scale && directionEnabled(mode, scale.axis, chart)) { - return [scale]; - } - - var enabledScales = []; - each(chart.scales, function(scaleItem) { - if (!directionEnabled(mode, scaleItem.axis, chart)) { - enabledScales.push(scaleItem); - } - }); - return enabledScales; - } - return null; -} - -function rangeMaxLimiter(zoomPanOptions, newMax) { - if (zoomPanOptions.scaleAxes && zoomPanOptions.rangeMax && - !isNullOrUndef(zoomPanOptions.rangeMax[zoomPanOptions.scaleAxes])) { - const rangeMax = zoomPanOptions.rangeMax[zoomPanOptions.scaleAxes]; - if (newMax > rangeMax) { - newMax = rangeMax; - } - } - return newMax; -} - -function rangeMinLimiter(zoomPanOptions, newMin) { - if (zoomPanOptions.scaleAxes && zoomPanOptions.rangeMin && - !isNullOrUndef(zoomPanOptions.rangeMin[zoomPanOptions.scaleAxes])) { - const rangeMin = zoomPanOptions.rangeMin[zoomPanOptions.scaleAxes]; - if (newMin < rangeMin) { - newMin = rangeMin; - } - } - return newMin; -} - -function zoomDelta(scale, zoom, center) { - const range = scale.max - scale.min; - const newDiff = range * (zoom - 1); - - const centerPoint = scale.isHorizontal() ? center.x : center.y; - const minPercent = (scale.getValueForPixel(centerPoint) - scale.min) / range; - const maxPercent = 1 - minPercent; - - return { - min: newDiff * minPercent, - max: newDiff * maxPercent - }; -} - -function zoomNumericalScale(scale, zoom, center, zoomOptions) { - const delta = zoomDelta(scale, zoom, center); - scale.options.min = rangeMinLimiter(zoomOptions, scale.min + delta.min); - scale.options.max = rangeMaxLimiter(zoomOptions, scale.max - delta.max); -} - -const integerChange = (v) => v === 0 || isNaN(v) ? 0 : v < 0 ? Math.min(Math.round(v), -1) : Math.max(Math.round(v), 1); - -function zoomCategoryScale(scale, zoom, center, zoomOptions) { - const labels = scale.getLabels(); - const maxIndex = labels.length - 1; - if (scale.min === scale.max && zoom < 1) { - if (scale.min > 0) { - scale.min--; - } else if (scale.max < maxIndex) { - scale.max++; - } - } - const delta = zoomDelta(scale, zoom, center); - scale.options.min = labels[Math.max(0, rangeMinLimiter(zoomOptions, scale.min + integerChange(delta.min)))]; - scale.options.max = labels[Math.min(maxIndex, rangeMaxLimiter(zoomOptions, scale.max - integerChange(delta.max)))]; -} - -function zoomScale(scale, zoom, center, zoomOptions) { - var fn = zoomFunctions[scale.type]; - if (fn) { - fn(scale, zoom, center, zoomOptions); - } -} - -/** - * @param chart The chart instance - * @param {number} percentZoomX The zoom percentage in the x direction - * @param {number} percentZoomY The zoom percentage in the y direction - * @param {{x: number, y: number}} [focalPoint] The x and y coordinates of zoom focal point. The point which doesn't change while zooming. E.g. the location of the mouse cursor when "drag: false" - * @param {string} [whichAxes] `xy`, 'x', or 'y' - * @param {boolean} [useTransition] Whether to use `zoom` transition - */ -function doZoom(chart, percentZoomX, percentZoomY, focalPoint, whichAxes, useTransition) { - var ca = chart.chartArea; - if (!focalPoint) { - focalPoint = { - x: (ca.left + ca.right) / 2, - y: (ca.top + ca.bottom) / 2, - }; - } - - var zoomOptions = chart.$zoom._options.zoom; - - if (zoomOptions.enabled) { - storeOriginalOptions(chart); - // Do the zoom here - var zoomMode = typeof zoomOptions.mode === 'function' ? zoomOptions.mode({chart: chart}) : zoomOptions.mode; - - // Which axes should be modified when fingers were used. - var _whichAxes; - if (zoomMode === 'xy' && whichAxes !== undefined) { - // based on fingers positions - _whichAxes = whichAxes; - } else { - // no effect - _whichAxes = 'xy'; - } - - var enabledScales = getEnabledScalesByPoint(zoomOptions, focalPoint.x, focalPoint.y, chart); - - each(enabledScales || chart.scales, function(scale) { - if (scale.isHorizontal() && directionEnabled(zoomMode, 'x', chart) && directionEnabled(_whichAxes, 'x', chart)) { - zoomOptions.scaleAxes = 'x'; - zoomScale(scale, percentZoomX, focalPoint, zoomOptions); - } else if (!scale.isHorizontal() && directionEnabled(zoomMode, 'y', chart) && directionEnabled(_whichAxes, 'y', chart)) { - // Do Y zoom - zoomOptions.scaleAxes = 'y'; - zoomScale(scale, percentZoomY, focalPoint, zoomOptions); - } - }); - - chart.update(useTransition ? 'zoom' : 'none'); - - if (typeof zoomOptions.onZoom === 'function') { - zoomOptions.onZoom({chart: chart}); - } - } -} - -function panCategoryScale(scale, delta, panOptions) { - const labels = scale.getLabels(); - const lastLabelIndex = labels.length - 1; - const offsetAmt = Math.max(scale.ticks.length, 1); - const panSpeed = panOptions.speed; - const step = Math.round(scale.width / (offsetAmt * panSpeed)); - let minIndex = scale.min; - let maxIndex; - - zoomNS.panCumulativeDelta += delta; - - minIndex = zoomNS.panCumulativeDelta > step ? Math.max(0, minIndex - 1) : zoomNS.panCumulativeDelta < -step ? Math.min(lastLabelIndex - offsetAmt + 1, minIndex + 1) : minIndex; - zoomNS.panCumulativeDelta = minIndex !== scale.min ? 0 : zoomNS.panCumulativeDelta; - - maxIndex = Math.min(lastLabelIndex, minIndex + offsetAmt - 1); - - scale.options.min = rangeMinLimiter(panOptions, labels[minIndex]); - scale.options.max = rangeMaxLimiter(panOptions, labels[maxIndex]); -} - -function panNumericalScale(scale, delta, panOptions) { - const scaleOpts = scale.options; - const prevStart = scale.min; - const prevEnd = scale.max; - const newMin = scale.getValueForPixel(scale.getPixelForValue(prevStart) - delta); - const newMax = scale.getValueForPixel(scale.getPixelForValue(prevEnd) - delta); - const rangeMin = rangeMinLimiter(panOptions, newMin); - const rangeMax = rangeMaxLimiter(panOptions, newMax); - let diff; - - if (newMin >= rangeMin && newMax <= rangeMax) { - scaleOpts.min = newMin; - scaleOpts.max = newMax; - } else if (newMin < rangeMin) { - diff = prevStart - rangeMin; - scaleOpts.min = rangeMin; - scaleOpts.max = prevEnd - diff; - } else if (newMax > rangeMax) { - diff = rangeMax - prevEnd; - scaleOpts.max = rangeMax; - scaleOpts.min = prevStart + diff; - } -} - -function panScale(scale, delta, panOptions) { - const fn = panFunctions[scale.type]; - if (fn) { - fn(scale, delta, panOptions); - } -} - -function doPan(chartInstance, deltaX, deltaY, panningScales) { - storeOriginalOptions(chartInstance); - var panOptions = chartInstance.$zoom._options.pan; - if (panOptions.enabled) { - var panMode = typeof panOptions.mode === 'function' ? panOptions.mode({chart: chartInstance}) : panOptions.mode; - - each(panningScales || chartInstance.scales, function(scale) { - if (scale.isHorizontal() && directionEnabled(panMode, 'x', chartInstance) && deltaX !== 0) { - panOptions.scaleAxes = 'x'; - panScale(scale, deltaX, panOptions); - } else if (!scale.isHorizontal() && directionEnabled(panMode, 'y', chartInstance) && deltaY !== 0) { - panOptions.scaleAxes = 'y'; - panScale(scale, deltaY, panOptions); - } - }); - - chartInstance.update('none'); - - if (typeof panOptions.onPan === 'function') { - panOptions.onPan({chart: chartInstance}); - } - } -} - -function getXAxis(chartInstance) { - var scales = chartInstance.scales; - var scaleIds = Object.keys(scales); - for (var i = 0; i < scaleIds.length; i++) { - var scale = scales[scaleIds[i]]; - - if (scale.isHorizontal()) { - return scale; - } - } -} - -function getYAxis(chartInstance) { - var scales = chartInstance.scales; - var scaleIds = Object.keys(scales); - for (var i = 0; i < scaleIds.length; i++) { - var scale = scales[scaleIds[i]]; - - if (!scale.isHorizontal()) { - return scale; - } - } -} - -// Store these for later -zoomNS.zoomFunctions.category = zoomCategoryScale; -zoomNS.zoomFunctions.time = zoomNumericalScale; -zoomNS.zoomFunctions.linear = zoomNumericalScale; -zoomNS.zoomFunctions.logarithmic = zoomNumericalScale; -zoomNS.panFunctions.category = panCategoryScale; -zoomNS.panFunctions.time = panNumericalScale; -zoomNS.panFunctions.linear = panNumericalScale; -zoomNS.panFunctions.logarithmic = panNumericalScale; -// Globals for category pan -zoomNS.panCumulativeDelta = 0; - -// Chartjs Zoom Plugin -var zoomPlugin = { +export default { id: 'zoom', defaults: { @@ -365,359 +23,56 @@ var zoomPlugin = { } }, - start: function(chartInstance, args, pluginOptions) { - chartInstance.$zoom = { + start: function(chart, args, options) { + chart.$zoom = { _originalOptions: {} }; - var node = chartInstance.$zoom._node = chartInstance.ctx.canvas; - resolveOptions(chartInstance, pluginOptions); - - var options = chartInstance.$zoom._options; - var panThreshold = options.pan && options.pan.threshold; - - chartInstance.$zoom._mouseDownHandler = function(event) { - node.addEventListener('mousemove', chartInstance.$zoom._mouseMoveHandler); - chartInstance.$zoom._dragZoomStart = event; - }; - - chartInstance.$zoom._mouseMoveHandler = function(event) { - if (chartInstance.$zoom._dragZoomStart) { - chartInstance.$zoom._dragZoomEnd = event; - chartInstance.update('none'); - } - }; - - chartInstance.$zoom._mouseUpHandler = function(event) { - if (!chartInstance.$zoom._dragZoomStart) { - return; - } - - node.removeEventListener('mousemove', chartInstance.$zoom._mouseMoveHandler); - - var beginPoint = chartInstance.$zoom._dragZoomStart; - - var offsetX = beginPoint.target.getBoundingClientRect().left; - var startX = Math.min(beginPoint.clientX, event.clientX) - offsetX; - var endX = Math.max(beginPoint.clientX, event.clientX) - offsetX; - - var offsetY = beginPoint.target.getBoundingClientRect().top; - var startY = Math.min(beginPoint.clientY, event.clientY) - offsetY; - var endY = Math.max(beginPoint.clientY, event.clientY) - offsetY; - - var dragDistanceX = endX - startX; - var dragDistanceY = endY - startY; - - // Remove drag start and end before chart update to stop drawing selected area - chartInstance.$zoom._dragZoomStart = null; - chartInstance.$zoom._dragZoomEnd = null; - - var zoomThreshold = (options.zoom && options.zoom.threshold) || 0; - if (dragDistanceX <= zoomThreshold && dragDistanceY <= zoomThreshold) { - return; - } - - var chartArea = chartInstance.chartArea; - - var zoomOptions = chartInstance.$zoom._options.zoom; - var chartDistanceX = chartArea.right - chartArea.left; - var xEnabled = directionEnabled(zoomOptions.mode, 'x', chartInstance); - var zoomX = xEnabled && dragDistanceX ? 1 + ((chartDistanceX - dragDistanceX) / chartDistanceX) : 1; - - var chartDistanceY = chartArea.bottom - chartArea.top; - var yEnabled = directionEnabled(zoomOptions.mode, 'y', chartInstance); - var zoomY = yEnabled && dragDistanceY ? 1 + ((chartDistanceY - dragDistanceY) / chartDistanceY) : 1; - - doZoom(chartInstance, zoomX, zoomY, { - x: (startX - chartArea.left) / (1 - dragDistanceX / chartDistanceX) + chartArea.left, - y: (startY - chartArea.top) / (1 - dragDistanceY / chartDistanceY) + chartArea.top - }, undefined, true); - - if (typeof zoomOptions.onZoomComplete === 'function') { - zoomOptions.onZoomComplete({chart: chartInstance}); - } - }; - - var _scrollTimeout = null; - chartInstance.$zoom._wheelHandler = function(event) { - var zoomOptions = chartInstance.$zoom._options.zoom; - - // Before preventDefault, check if the modifier key required and pressed - if (zoomOptions - && zoomOptions.wheelModifierKey - && !event[zoomOptions.wheelModifierKey + 'Key']) { - if (typeof zoomOptions.onZoomRejected === 'function') { - zoomOptions.onZoomRejected({ - chart: chartInstance, - event: event - }); - } - return; - } - - // Prevent the event from triggering the default behavior (eg. Content scrolling). - if (event.cancelable) { - event.preventDefault(); - } - - // Firefox always fires the wheel event twice: - // First without the delta and right after that once with the delta properties. - if (typeof event.deltaY === 'undefined') { - return; - } - - var rect = event.target.getBoundingClientRect(); - var offsetX = event.clientX - rect.left; - var offsetY = event.clientY - rect.top; - - var center = { - x: offsetX, - y: offsetY - }; - - var speedPercent = zoomOptions.speed; - - if (event.deltaY >= 0) { - speedPercent = -speedPercent; - } - doZoom(chartInstance, 1 + speedPercent, 1 + speedPercent, center); - - clearTimeout(_scrollTimeout); - _scrollTimeout = setTimeout(function() { - if (typeof zoomOptions.onZoomComplete === 'function') { - zoomOptions.onZoomComplete({chart: chartInstance}); - } - }, 250); - }; + addListeners(chart, options); if (Hammer) { - var panEnabler = function(recognizer, event) { - const panOptions = chartInstance.$zoom._options.pan; - if (!panOptions || !panOptions.enabled) { - return false; - } - if (!event || !event.srcEvent) { // Sometimes Hammer queries this with a null event. - return true; - } - const requireModifier = panOptions.modifierKey - && (event.pointerType === 'mouse'); - if (requireModifier && !event.srcEvent[panOptions.modifierKey + 'Key']) { - if (typeof panOptions.onPanRejected === 'function') { - panOptions.onPanRejected({ - chart: chartInstance, - event: event - }); - } - return false; - } - return true; - }; - - var zoomOptions = chartInstance.$zoom._options.zoom; - var panOptions = chartInstance.$zoom._options.pan; - var mc = new Hammer.Manager(node); - if (zoomOptions && zoomOptions.enabled) { - mc.add(new Hammer.Pinch()); - } - if (panOptions && panOptions.enabled) { - mc.add(new Hammer.Pan({ - threshold: panThreshold, - enable: panEnabler - })); - } - - // Hammer reports the total scaling. We need the incremental amount - var currentPinchScaling; - var handlePinch = function(e) { - var diff = 1 / (currentPinchScaling) * e.scale; - var rect = e.target.getBoundingClientRect(); - var offsetX = e.center.x - rect.left; - var offsetY = e.center.y - rect.top; - var center = { - x: offsetX, - y: offsetY - }; - - // fingers position difference - var x = Math.abs(e.pointers[0].clientX - e.pointers[1].clientX); - var y = Math.abs(e.pointers[0].clientY - e.pointers[1].clientY); - - // diagonal fingers will change both (xy) axes - var p = x / y; - var xy; - if (p > 0.3 && p < 1.7) { - xy = 'xy'; - } else if (x > y) { - xy = 'x'; // x axis - } else { - xy = 'y'; // y axis - } - - doZoom(chartInstance, diff, diff, center, xy); - - if (typeof zoomOptions.onZoom === 'function') { - zoomOptions.onZoom({chart: chartInstance}); - } - - // Keep track of overall scale - currentPinchScaling = e.scale; - }; - - mc.on('pinchstart', function() { - currentPinchScaling = 1; // reset tracker - }); - mc.on('pinch', handlePinch); - mc.on('pinchend', function(e) { - handlePinch(e); - currentPinchScaling = null; // reset - if (typeof zoomOptions.onZoomComplete === 'function') { - zoomOptions.onZoomComplete({chart: chartInstance}); - } - }); - - var currentDeltaX = null; - var currentDeltaY = null; - var panning = false; - var panningScales = null; - var handlePan = function(e) { - if (currentDeltaX !== null && currentDeltaY !== null) { - panning = true; - var deltaX = e.deltaX - currentDeltaX; - var deltaY = e.deltaY - currentDeltaY; - currentDeltaX = e.deltaX; - currentDeltaY = e.deltaY; - doPan(chartInstance, deltaX, deltaY, panningScales); - } - }; - - mc.on('panstart', function(e) { - if (panOptions.enabled) { - var rect = e.target.getBoundingClientRect(); - var x = e.center.x - rect.left; - var y = e.center.y - rect.top; - panningScales = getEnabledScalesByPoint(panOptions, x, y, chartInstance); - } - - currentDeltaX = 0; - currentDeltaY = 0; - handlePan(e); - }); - mc.on('panmove', handlePan); - mc.on('panend', function() { - panningScales = null; - currentDeltaX = null; - currentDeltaY = null; - setTimeout(function() { - panning = false; - }, 500); - if (typeof panOptions.onPanComplete === 'function') { - panOptions.onPanComplete({chart: chartInstance}); - } - }); - - chartInstance.$zoom._ghostClickHandler = function(e) { - if (panning && e.cancelable) { - e.stopImmediatePropagation(); - e.preventDefault(); - } - }; - node.addEventListener('click', chartInstance.$zoom._ghostClickHandler); - - chartInstance._mc = mc; + startHammer(chart, options); } - - chartInstance.resetZoom = function() { - storeOriginalOptions(chartInstance); - var originalOptions = chartInstance.$zoom._originalOptions; - each(chartInstance.scales, function(scale) { - - var scaleOptions = scale.options; - if (originalOptions[scale.id]) { - scaleOptions.min = originalOptions[scale.id].min; - scaleOptions.max = originalOptions[scale.id].max; - } else { - delete scaleOptions.min; - delete scaleOptions.max; - } - }); - - chartInstance.update('none'); - }; + chart.resetZoom = () => resetZoom(chart); }, beforeUpdate: function(chart, args, options) { - resolveOptions(chart, options); + addListeners(chart, options); }, - beforeDatasetsDraw: function(chartInstance) { - var ctx = chartInstance.ctx; - - if (chartInstance.$zoom._dragZoomEnd) { - var xAxis = getXAxis(chartInstance); - var yAxis = getYAxis(chartInstance); - var beginPoint = chartInstance.$zoom._dragZoomStart; - var endPoint = chartInstance.$zoom._dragZoomEnd; - - var startX = xAxis.left; - var endX = xAxis.right; - var startY = yAxis.top; - var endY = yAxis.bottom; - - if (directionEnabled(chartInstance.$zoom._options.zoom.mode, 'x', chartInstance)) { - var offsetX = beginPoint.target.getBoundingClientRect().left; - startX = Math.min(beginPoint.clientX, endPoint.clientX) - offsetX; - endX = Math.max(beginPoint.clientX, endPoint.clientX) - offsetX; - } + beforeDatasetsDraw: function(chart, args, options) { + const {$zoom: props = {}, ctx} = chart; + const {_dragZoomStart: beginPoint, _dragZoomEnd: endPoint} = props; - if (directionEnabled(chartInstance.$zoom._options.zoom.mode, 'y', chartInstance)) { - var offsetY = beginPoint.target.getBoundingClientRect().top; - startY = Math.min(beginPoint.clientY, endPoint.clientY) - offsetY; - endY = Math.max(beginPoint.clientY, endPoint.clientY) - offsetY; - } + if (endPoint) { + const {left, top, width, height} = computeDragRect(chart, options.zoom.mode, beginPoint, endPoint); - var rectWidth = endX - startX; - var rectHeight = endY - startY; - var dragOptions = chartInstance.$zoom._options.zoom.drag; + const dragOptions = options.zoom.drag; ctx.save(); ctx.beginPath(); ctx.fillStyle = dragOptions.backgroundColor || 'rgba(225,225,225,0.3)'; - ctx.fillRect(startX, startY, rectWidth, rectHeight); + ctx.fillRect(left, top, width, height); if (dragOptions.borderWidth > 0) { ctx.lineWidth = dragOptions.borderWidth; ctx.strokeStyle = dragOptions.borderColor || 'rgba(225,225,225)'; - ctx.strokeRect(startX, startY, rectWidth, rectHeight); + ctx.strokeRect(left, top, width, height); } ctx.restore(); } }, - stop: function(chartInstance) { - if (!chartInstance.$zoom) { + stop: function(chart) { + if (!chart.$zoom) { return; } - var props = chartInstance.$zoom; - var node = props._node; - node.removeEventListener('mousedown', props._mouseDownHandler); - node.removeEventListener('mousemove', props._mouseMoveHandler); - node.ownerDocument.removeEventListener('mouseup', props._mouseUpHandler); - node.removeEventListener('wheel', props._wheelHandler); - node.removeEventListener('click', props._ghostClickHandler); + removeListeners(chart); - delete chartInstance.$zoom; + delete chart.$zoom; - var mc = chartInstance._mc; - if (mc) { - mc.remove('pinchstart'); - mc.remove('pinch'); - mc.remove('pinchend'); - mc.remove('panstart'); - mc.remove('pan'); - mc.remove('panend'); - mc.destroy(); + if (Hammer) { + stopHammer(chart); } } }; - -export default zoomPlugin; diff --git a/src/scale.types.js b/src/scale.types.js new file mode 100644 index 000000000..62e9a6879 --- /dev/null +++ b/src/scale.types.js @@ -0,0 +1,119 @@ +import {isNullOrUndef} from 'chart.js/helpers'; + +function rangeMaxLimiter(zoomPanOptions, newMax) { + const {scaleAxes, rangeMax} = zoomPanOptions; + if (scaleAxes && rangeMax && !isNullOrUndef(rangeMax[scaleAxes])) { + const limit = rangeMax[scaleAxes]; + if (newMax > limit) { + newMax = limit; + } + } + return newMax; +} + +function rangeMinLimiter(zoomPanOptions, newMin) { + const {scaleAxes, rangeMin} = zoomPanOptions; + if (scaleAxes && rangeMin && !isNullOrUndef(rangeMin[scaleAxes])) { + const limit = rangeMin[scaleAxes]; + if (newMin < limit) { + newMin = limit; + } + } + return newMin; +} + +function zoomDelta(scale, zoom, center) { + const range = scale.max - scale.min; + const newDiff = range * (zoom - 1); + + const centerPoint = scale.isHorizontal() ? center.x : center.y; + const minPercent = (scale.getValueForPixel(centerPoint) - scale.min) / range; + const maxPercent = 1 - minPercent; + + return { + min: newDiff * minPercent, + max: newDiff * maxPercent + }; +} + +function zoomNumericalScale(scale, zoom, center, zoomOptions) { + const delta = zoomDelta(scale, zoom, center); + scale.options.min = rangeMinLimiter(zoomOptions, scale.min + delta.min); + scale.options.max = rangeMaxLimiter(zoomOptions, scale.max - delta.max); +} + +const integerChange = (v) => v === 0 || isNaN(v) ? 0 : v < 0 ? Math.min(Math.round(v), -1) : Math.max(Math.round(v), 1); + +function zoomCategoryScale(scale, zoom, center, zoomOptions) { + const labels = scale.getLabels(); + const maxIndex = labels.length - 1; + if (scale.min === scale.max && zoom < 1) { + if (scale.min > 0) { + scale.min--; + } else if (scale.max < maxIndex) { + scale.max++; + } + } + const delta = zoomDelta(scale, zoom, center); + scale.options.min = labels[Math.max(0, rangeMinLimiter(zoomOptions, scale.min + integerChange(delta.min)))]; + scale.options.max = labels[Math.min(maxIndex, rangeMaxLimiter(zoomOptions, scale.max - integerChange(delta.max)))]; +} + +const categoryDelta = new WeakMap(); +function panCategoryScale(scale, delta, panOptions) { + const labels = scale.getLabels(); + const lastLabelIndex = labels.length - 1; + const offsetAmt = Math.max(scale.ticks.length, 1); + const panSpeed = panOptions.speed; + const step = Math.round(scale.width / (offsetAmt * panSpeed)); + const cumDelta = (categoryDelta.get(scale) || 0) + delta; + const scaleMin = scale.min; + const minIndex = cumDelta > step ? Math.max(0, scaleMin - 1) + : cumDelta < -step ? Math.min(lastLabelIndex - offsetAmt + 1, scaleMin + 1) + : scaleMin; + const maxIndex = Math.min(lastLabelIndex, minIndex + offsetAmt - 1); + + categoryDelta.set(scale, minIndex !== scaleMin ? 0 : cumDelta); + + + scale.options.min = rangeMinLimiter(panOptions, labels[minIndex]); + scale.options.max = rangeMaxLimiter(panOptions, labels[maxIndex]); +} + +function panNumericalScale(scale, delta, panOptions) { + const scaleOpts = scale.options; + const prevStart = scale.min; + const prevEnd = scale.max; + const newMin = scale.getValueForPixel(scale.getPixelForValue(prevStart) - delta); + const newMax = scale.getValueForPixel(scale.getPixelForValue(prevEnd) - delta); + const rangeMin = rangeMinLimiter(panOptions, newMin); + const rangeMax = rangeMaxLimiter(panOptions, newMax); + let diff; + + if (newMin >= rangeMin && newMax <= rangeMax) { + scaleOpts.min = newMin; + scaleOpts.max = newMax; + } else if (newMin < rangeMin) { + diff = prevStart - rangeMin; + scaleOpts.min = rangeMin; + scaleOpts.max = prevEnd - diff; + } else if (newMax > rangeMax) { + diff = rangeMax - prevEnd; + scaleOpts.max = rangeMax; + scaleOpts.min = prevStart + diff; + } +} + +export const zoomFunctions = { + category: zoomCategoryScale, + time: zoomNumericalScale, + linear: zoomNumericalScale, + logarithmic: zoomNumericalScale, +}; + +export const panFunctions = { + category: panCategoryScale, + time: panNumericalScale, + linear: panNumericalScale, + logarithmic: panNumericalScale, +}; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 000000000..526ae2fbe --- /dev/null +++ b/src/utils.js @@ -0,0 +1,110 @@ +import {each} from 'chart.js/helpers'; + +/** + * @param {string|function} mode can be 'x', 'y' or 'xy' + * @param {string} dir can be 'x' or 'y' + * @param {import('chart.js').Chart} chart instance of the chart in question + * @returns {boolean} + */ +export function directionEnabled(mode, dir, chart) { + if (mode === undefined) { + return true; + } else if (typeof mode === 'string') { + return mode.indexOf(dir) !== -1; + } else if (typeof mode === 'function') { + return mode({chart}).indexOf(dir) !== -1; + } + + return false; +} + +export function getXAxis(chartInstance) { + const scales = chartInstance.scales; + const scaleIds = Object.keys(scales); + for (let i = 0; i < scaleIds.length; i++) { + const scale = scales[scaleIds[i]]; + + if (scale.isHorizontal()) { + return scale; + } + } +} + +export function getYAxis(chartInstance) { + const scales = chartInstance.scales; + const scaleIds = Object.keys(scales); + for (let i = 0; i < scaleIds.length; i++) { + const scale = scales[scaleIds[i]]; + + if (!scale.isHorizontal()) { + return scale; + } + } +} + +/** + * Debounces calling `fn` for `delay` ms + * @param {function} fn - Function to call. No arguments are passed. + * @param {number} delay - Delay in ms. 0 = immediate invocation. + * @returns {function} + */ +export function debounce(fn, delay) { + let timeout; + return function() { + if (delay) { + clearTimeout(timeout); + timeout = setTimeout(fn, delay); + } else { + fn(); + } + return delay; + }; +} + +/** This function use for check what axis now under mouse cursor. + * @param {number} [x] X position + * @param {number} [y] Y position + * @param {import('chart.js').Chart} [chart] instance of the chart in question + */ +function getScaleUnderPoint(x, y, chart) { + const scales = chart.scales; + const scaleIds = Object.keys(scales); + for (let i = 0; i < scaleIds.length; i++) { + const scale = scales[scaleIds[i]]; + if (y >= scale.top && y <= scale.bottom && x >= scale.left && x <= scale.right) { + return scale; + } + } + return null; +} + +/** This function return only one scale whose position is under mouse cursor and which direction is enabled. + * If under mouse hasn't scale, then return all other scales which 'mode' is diffrent with overScaleMode. + * So 'overScaleMode' works as a limiter to scale the user-selected scale (in 'mode') only when the cursor is under the scale, + * and other directions in 'mode' works as before. + * Example: mode = 'xy', overScaleMode = 'y' -> it's means 'x' - works as before, and 'y' only works for one scale when cursor is under it. + * options.overScaleMode can be a function if user want zoom only one scale of many for example. + * @param {any} [options] pan or zoom options + * @param {number} [x] X position + * @param {number} [y] Y position + * @param {import('chart.js').Chart} [chart] instance of the chart in question + */ +export function getEnabledScalesByPoint(options, x, y, chart) { + if (options.enabled && options.overScaleMode) { + const scale = getScaleUnderPoint(x, y, chart); + const mode = typeof options.overScaleMode === 'function' ? options.overScaleMode({chart: chart}, scale) : options.overScaleMode; + + if (scale && directionEnabled(mode, scale.axis, chart)) { + return [scale]; + } + + const enabledScales = []; + each(chart.scales, function(scaleItem) { + if (!directionEnabled(mode, scaleItem.axis, chart)) { + enabledScales.push(scaleItem); + } + }); + return enabledScales; + } + return null; +} diff --git a/test/fixtures/pan/category-x.js b/test/fixtures/pan/category-x.js index 6c97d8be0..f1e660dd9 100644 --- a/test/fixtures/pan/category-x.js +++ b/test/fixtures/pan/category-x.js @@ -11,6 +11,7 @@ canvas.height = 512; const ctx = canvas.getContext('2d'); module.exports = { + tolerance: 0.02, config: { type: 'bar', data: { diff --git a/test/fixtures/pan/category-x.png b/test/fixtures/pan/category-x.png index 2e33292e2408b47609df3a4d2ffce0ec7fc3b303..3ee78c568ad7901706d68f5321b304059f1537dd 100644 GIT binary patch literal 20971 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 E3r(YrCjbBd literal 20851 zcmeI42{=`2-|+8k%RUWAQk0?LR7#PQA!X~3&Pk?Bp^yxfOi4)WU7ZRkohPCYd>XpLh7a?|QHA`mU$z>YSsq*1Ffd?rGis-}?Op z8S3v7T(DvRLP+qJpS2Gn#0Nj}p&ta`Ka)Nm*a)pczi96<@v$C?ng1l#!FOupR?M%P z?F|ksag0{DL4AKPS*p}N<=M!*jl?6>X4UTvSMC3;=3UU5$DIiTiPt*~@AE%j{Fx!U zF*PUisBaoXd>1y`&rl}jDuQO3_}kiVjl4XU?-{f4mUNYW?5sbffw`-S zNvWzA713JLdHwC!qnzbMi^5lQTbD*L-Sk`7Dozj6OpeD#-tX*TR{fIjp0;6)j!vOv z<$as8vcr27wdQ5F==x@GD}t=Bo|F&a3tW~enY83{)lLL)V*-U}+v}gM>7=Nmt&R^Z z?NES)sZ<+MRc2J(dAxIJldPkx@60?Dzon*~Kn;3tp1OFSVtgU_7ry8viVX(nX5)|S zc)28nv*`0+3fbw=fFaalbu-4{r&=k66C}|S!jJ5(( z{abyZ=}Bp?2vpAuZuFfbt^3#QuAIVHqR6$v1V2IG30M41${zfGFNh?r+OVa@hN;&f zg#2=Nbv)>T>S>gLKg!U}E=bB1Afd`Fb!>0+@r_^y5v^QR zCt%B&P2f%^4Xltvs@W!tc7m<23RM`LqMEFvGDl*+J^H~^3W>VA1!+Ko{LkDho69|B zTd~`nQrLXshEJ03J}h-ImY7ysOwyE6*&FfG%A!S$ZhW4Ip7k7Wiu+zJlE~iDOz>nY z61gAwi5Ry(AV|5MroZybx#`%>?B{9MiJCE6lhW!1J5by|o}IkGBz-Jht4SsxdVodJ zXe1Hc-W&hr9QmS7lk!mk!(Q>r%E1!#?QHLX#oI?tqf?bh>F)_tCF8TQ%1%X#WRO>y z5e$)|oKzYSu{0UZwFK(52ZP63Q2>8pm}u2AF1lkw;5$UyWa?8+c(VWQ6m3@*GTac7 zr2AA=Oevr6K}`qmMP@cPfRDbrn%#5;bv%$&w^X6VBH8Z3j1;nF&jy5U$0CzgOH?uo zpeGB1Uwfpkq!ZAl2V1ha)NCT6b|b@ix-B2r(3y+n7L;VVQWdlhaJ374M{`e>`Z?eI zIaC<^#E8h02@#*o4D=q?Zy8%POckt`;hS~vW%wPCc;C;p$#biZo$x{( zMyoC;`B_qmbq}|GDY&#aGmVnP9HGslFIv{P2uAoKTaJW+hZloHB~lL=DW`dHGGJsN z$E_svR`>9*AVRFQwuoW=Fx9WzhA;8`-tle;67{6ZkJrE4K@|1qGOB!nlC5m8j5X^I zOL;_h6u7$h>13hlYk}lDE!&>&5II(?UGC-^^0{_4onU4=MKC1jHN0mNY&l+bW(S~w zEwj)QHM=5u;4g?E-@4-IIn6@VmXE?KCDA7BG2YpoMD(QcERrqI7-0%8S}*T~Z0S-+ zJl!=@g}UH)?lKahH^CriG8$5kRC4kNmc z8ZsGOlC6pm%sF(Mh_0?1cK=eQ0*Si7x)Vx7El}cV)bXTHN(e2=A)+qmq*uBvod{9Y z5jrGJwxoiI6*7SVo|}S>mmVwnE_{5k$>-3Le*p2n{1-tA^YU zbqbM?KnsC7b{ZvitaJ512%ZIPCLtl+mQRQdg>SVMLSp%aTcB=mUl<(pqe1HMP*C{) z%tJMH3Q}t-mmy{C^T&=C%IaJ&bqoHuUA6P)`Z{uQINN)T^r@)V^?{d2+97V9H?S49 zQD?j{&snimwuvo>?qC|R6Ky?HA3L_Gu2G1(QN(kt-IfincJ36Bs(+O%25a`HdOBlN zl)Au`>uf7ltql*h=sB-J>Ap-aRrDdlIk^@(D+StWyAiY%afsJP?$8d*@}}W4C~?&w zf?@Na6VWxlv`;iN?Cy*#_1bwz29BIxv(O88ri-LBcZ%!xhEM_IUAYKb4{{IQfQN`Ix^7|&4lA|;s4^ti%4|Jv*fNi zt2H`8A>t+KQQr1eSl5~W4b{$zJ^V>>3Fh9)r|`FX&FES0ymCSu-%(}av&bc)e#fZ|BcB~a0=e>w(yR}XTOxf-UuQ}h<4$qBTZp@B`Wbt^(4`N&&FJ+c{kLY zfX=Oof>(beA%%>uGbOq;FzYuuSz*smb&$`^E=MV=wL;R174LaH9l%R}!VPZcAF$ys zJpTwrIW*!BR*I@W4ESC1MU!|W~Je--MGt(*sUYW;SnIex0dswghk z*}cAZ>D~GR%Fzb?)pR}6z=iAUB6W^PNDZ#k;e(e9KU3SL=s&SnSE3WfqNJXYX_+Q%G6LtKafscs4 z%%4B{ ze6;t}^M4s%Cfx}^cm!8?3LzyrkI`1xN1kJ?&;-P<1$|d7v&?{aDG3*0!nlx(Y>=Z3-D>yM-9BeKIcG_z0tDl~66ccGS?K%|PBbuTo-5#S!*b@v%ep3FvGXnNZv;Kse0z(+&8;Q6yYfVlV`X&XOr>{$D0#NA>cN~}t)5Cz z0-4jV+81}pZf3lDtg$K}YnB#qeehQ7oc;N+Yc_?fgNvRxR@`kPKiv!}4-h3D+D{f%!InD2@oy{qhE+1uz8J9JvXEg?4G9IMGZ?XZQ$va2!y zK4GEDU0i9LDe+xC?sd_R*b&Y?Reo)vp}WR^#{arJMrdbWToN(vF}LjYmTM6<0cvfm z0@f$L6G_-a6DnCzB)5hX&?A#>n({Uk5O^-<) zf4jCU#EeP)^sDFt_o$s-n+hTxvB^^(kBvpqW}-}2U+Tl4?j`E&GnMsB1qE*9ZdOY@ z3zJLBc`*1mb$~L}=u~1(W7iEVr|PjVVgEiY4@PRXU8o4B?1gWk$iBjnxyq5j)B5q~ ziT%5O_;dzsu=|i`)JiADVuUGiZq<*`iNa@ynnR*-1;mcj z$@VGxL{knnSB2#>MqYUK+Bxbm6#}M9%?p-5>z9lzdVp1^w72-*^@1icuQ-uJQA0P> zdLj=!v;PI}L2P)0>pIs#A|E%U)Uhc8j}z>s1{+QD+14n(j;)}uh>M?)O{80;Vg~0s zMO&BeEQ?S`&y9V=maE53B*fIWsw`Kq4jFma4`(l?OuvySR&N^<+SP@z*p%9ZePOre z<<}Lwr+OwSBh~nmHA{)w+HS`7YphLK2Z#5(O0l~EfQNCgTZ!Su8H=Eb`8B3@ac#mi z?@GI;DG@}s^8WP2^6y9D2Mb0XQ>>*6HsLE<$IA3mvaEw`DHKmCtZ#B&4|)3^m2`^! z&T#HJ^gH{7&a0~DPF<$AsT5(&iR<*g0={-49S9$fGtsk}z0vXEOE_wBlb_4l&j|hP z-3lDpB~q^g6-hM4x8}-JX31N7K_2v@!#4Dm{zDp(F6|0?!f%uc;>|*Hft3MEJXn)~ zw6O>zl7q7>bfSHWz5{IHKB9V=wYPT2!X_?IKMZqbrOxf3Zxrd?H!!!3-jleFtwM`# zO`42An?@gQgU#XDKko^n{0v`B<9WpuP0Gg9Dk>L0Je|(kChRbVAq*kz~6_cUK<62GapA9c}0TayWMHGJ^h@cafZ^&88-nd|AtZjMfxK@$!!UoR zdHKyDjJpvH0~#FlCLH?2v$^=YmVF?cteShPn7nYvrCB8i&e^4uk;s7H{x9(Pz7x{irXx-7)`3^?ei3g54qe2BTDWl(a7e< z8?WuZkXZ{#$btImTxB7&ZHu!&W*V(8R$`7plDVA=oye>3Vh*G0yl?5;)tu15*o-|>(X5PIZn0h zHhff}3OszMMt5zLyA=pWEpi1l5K_UGE~aLtZg1v(+qhMui(17te6R0L?f|@W<||%e zlB+)nB2AV6vgIT__X>q*sK0XqS`0e~{$(PK)`&fy7llG6@?zJpx~R2gi$Wql^pvNH zBm;JF_PjFS2~qI-zXzg7+b1u_D2A%w+W*0-) z-2o4%sM8`xITGmyV}^7!A%ff8fzX8$Wk93cWDu&)dVh+f`Dg=Gh+19+eIH_;-2~zO z-83L-PtN{d6E&Y)WJ`1>+Ot>0QN#xCrP3N`OxDPSKSd?qA=7G<6$+J568-|8j> zD~ZKbVRXgv1>uQYa&i_oBr z7b$)S!<_U)`*B#lXV};KBvj+&DRxBy}K7lnohw{P!IS{{)rP7o5lD^3?fUocur&^j* zvrPZk2%lTuWt{XJmz~Sn2Fz+BkEA9>0z!F@*XXZ2>-(|q?ecw0{K9o}|o2#I<${d#e@Hm$Dv2-oI!y{4n3*pA@=@ql-RvH|KgWcKQuvHcW-4+t4)5 zP6f=g+xT{eM{aW{jS46@B50X+*2QV$C9945slnQB%h@Szl4-Kt-1OMX-Me>tK6Xtu zPah7EQa|h0tgG;Hsk$GfC|FF<{_Rb16<5iE2>k>lCq{khOQOJ{j~e(-V>az0{ry3} zcwU^;nJJVrPa8ZCx%KF5$a0(Wig5S#-BV!~#M}TsuyR~mbH&w8+w%~S%X))*ElJ!W zoO)b)b&1pkaELPYCON%eMf|?dVx_w$+x5JxvI>C^9s?R<3hd5i&fGEcC0C41+Bw)b!1*Ihjabp4GE=3vorE}gU_bxvkSyZ zWGLs$?MO~+epUKh;LBYin!k8ya#k#a@pCW(rXV z7`ui8PfCfDx3-$3W7WOuPLn4vD7WO6fri9#YoDVnzP&LHzj-&5;3%lBASDK{UJ@hJ zG}(+c<=gw%)%Ko&VYx23WJyo&o99F^XFk{$F$*D5L*kJb#>451^52WsDF%@Q~ zB4~5nn{edCp#7RblJ-HZ3tO@5ms<;2+ng$+^&8dqW5CdC`1V0}$LG1Voo#j*Yw@fK z_g}Yci{;XyW8JAf9q}?8*<6_i7|sue)pAcYZrgrofE+M4JzWzT@WJon(W^seI4TQ} z?(NM%8}9k6(Yd~&PVb!P2K~VT+DO6dS)!)eha0=6J_1skpL+*8NqzQnsgBu_j|7tB zV()f;Z`R^1SFPrwy*a!rCZNU_Nh*HTsk(^S=6d# z!+syp*?CQ&xIex8;~0{Sn)nJ2H@{O|w)xOvhqH~-73nN;*QqqhCnv&S5`NS<`CE}F*= z6|T0<%}iL;VL4qlu8nKsmB4$PY*!eV!yZUZo*b#={(eh;z}2k#lfK1gfa^m76{km+ z50zQXWT!m5zEWjM`t;k?bp&9Ml%6|Ib^{+CcZVxEW*tL5avU)RZP1~cF0Xb=V>?3~ zm0Bv0em-h5Rod)E+b#d%^FM|cUVmm*N^zjgy%G*E)VR`P$SJ#bwof# z+3~*;r+$jKOPwFd`!jirZyj0`6sW=;+wl)wRlBDOfk}7 zLHT9PN3$npt$Au)KrR9J+EGcgRf&W`K%MG%YcCc2#scKWl-a1}gRM7LRqV$tq*Ma_ zjx$xUsriiP_rLju@#i__1*GJ^b`CTCt`vd~k+b!o*6JH}SBO+*#4f%w zs8Hut3gx`1mmp{iQQ8o=%7+ks`j9)n$raVo7b_#JUakQ`)L*iKl4as#Nr0W)=M+<(dTvY zK=P=w&U93tz8^Rzjo73h|cmNd|B zdL(XRGn=xu^XV&*F2r{@SFJO1j|i!&IN;;W;S!{>FGXBj@v&aL)mk&*kfbf>HWrmb@01q`rVK9FzUPV{&u+f&Xpy?BS`g1|-4bgD^nz!@zS3 z$@(`;>?}`hQ&n)d3NY=9lVvjc1*QR6gxOQ6+xUrD#lw%dvKF?b4T%q(BGB0^8;%&Q z>Cl*9Q?SH=BKAqJ>drPUy4l}L_XnuP!<9{pSS2~Qr&mkUATXCWh9JPj#3BOq!q?lT z7YLE4=gzu#?I%(Nt8a%eV;e}wsTaNFDSi?sy4fsl6;BQ_G2~f=A3BND3&8OPO9zrt z$z6{=v=jcYz;J!6!iJb~ss41tI66LKmK)nfpc`rG80zo8qnlrFqV|#6 zB>};KAx3`}ErZpNVo}T7TjMm^@v1K%k-Jx>G-QD0(Nzpw^YHUj%Gj$!qxi)NNfe6+ zpcyVlFmU9wU&M;6)rp&3%@!8ly2>ow8ZwXCpPw)u1Z?xlf_NJ{&xOeVk3z(jL)__~^(VyM*bsYun|%K0 z>zs*7^|ICNt%myQL(gSpXgCT@jK47;UCL-kABj;sCeVbyo!i-4ttC9Pg@koS*tFGk zkCn)Tt_|I_O=IFt)tUBK@J7SlU_)*czX%IOCJmdV#Y5lNcod0wjJ_h3jCYsSECPu9 zT2G~Qsm#pVebtgln1oZbQT`1_Lk^ksqN?b+N?RZi)nNn4>BH=iV=}YixiU%E5${&h zDM`S4l>Ubi#pJm+7siq&id>wML=**4Z~jzC2mJHnrxjDx;T?w?$4d+?J5*C(Dr06ac4 zMKiszGNASH=+F2(34Xi2X$9xLm8q_kf+##l*f$G$TP(_*%uBpZ(} zt|3b(1y!Yxn~#}D8&^E#*2fM$4R^9=6-B3yrtSDcO`K+~HoV89egXdSGkWPJymeQt z^gsE^^Ee>QXjEya(I`&I}7giN_#HKz8htp()4#ndK$O zMlGbJzGok^e!Nu^qYt1RE7!l|DoltXeai=w4SfDY4IB?AfVCv;V?D+UCf%yLC?&ZKvaGmwr3A_Fww>r1p9{1F_Z-v>^TG;dUX z1CarIUsIODvqPKDBU}6af)n*1*J(cEnx-S5m^LC&{koQ^ZDkx0H}##J8y)X(Er#6q zQfl`;BYZfrV)!(ozlEpSW^vWdWs~DuYXYS3xxqiy11Is+Swjz8yiffjg&H!Xdn4;J zJy4>kPF-CtVF|a5pUmetY~mAg#K=FBxXNim4Ons=uVtqkNz_e8?Dcp?B_CKmx3a!v zK*ypFYLDfQb1_6#Z_-tYzT^ znULwT6E_A*`|Dx^SC*+K{Pu<(^>1t;ZQJO7~#fuB)g z-u9O64PaRIL?O-(kqhI0=xwwnI4(e6oH~>4=qq|Wx~68oJwL2f-V-Hi^+C>+Ud==J ziufj!%4m6yJBD7bWg$OH9GodgY|CZpJsN(NlnXlLG2o&b005H-w&l{x1L+8*2?Nf>C+v3LdiOgc@fg?L)hgj?ObHrvF>jSR} zO#7h~vz3H6#Uw1D8c(^~m}}WkoQ}k{nZKF{Mo450{(AxHD@Nrsi}fUtCB`@#>{e}D zZo8@8TpfLQ>dd*hF`|1P-L#XJR2?q=kM$&U1++GtFG8N(vv?*^bIeNzcxKu2G!T|D zl~Yw6Leh0xE! zC!P|~n?I5|2S9G4=A0&?2-712MCyIxf%NHn%ZeJD#FQd~nRR=ycOuSfA7pbgCS&z^ zbMygsJ$=FZfO&`hk^w4VgO6BYP_1CZk_o5~5;t>F1`fhw=s%{#@?1L@SUj7q6f$l# zt$Y!+ED28??)$^VgQ|i9==5k1Kr+~Yf4QFL%Rzm=%6Ql&!z-5O(Q|I{bxyM83oc9N z{7k&aB?~80F9uM~r)qMJCN-(YA)B%KS%2(6C}BoymC|!nG;Mxio3Ca16oD=R32bwE z)IVghgjv&@#%p>uam8=S5VYdwgB16-L^g2L%xzwPrIxd-8RlurzsYj@-xk;+=27*v z8SV4(*V%oh2YMVhXa<>>U$Q#+bnYivjv1q5(jlz4Qx$F3$$YsLH%WO;+%`0wN59kX z^%%Z$hur#YIMk%?!qdvXCDKtByyv-YcknFA zZ>}4G4z3$+*8U|g^S|$GjPz@l2L)Q+cz}7y7Q(LkH>Lpn7prf-uA!(5On^#U>BDn7 z|FmkqL(1=f_B*%+4f?BP{(U}tpAX;X!}s~%l>Z$>{%e8ANtfD3dY%G;D?T){UTpzw zkbc2GAn4;52VA*Apon(WaeEYfMmvrKRQ?krITB^=I0Q`ZcyUvvvpPU5)|5p0%PC99 zW*&c95E?3}wxq%WvYQj!`}~@LMn;rT{3$>_pMpSH1Tx>n5r^*5@EAxr=pI&xpE%}8Tg25rJU*=T1=c?hH*~Osf0f3w`_%3T{W4$ zfJPJSwEm41JCgFO*Xu{;=yJS$FX=|e$9VSLzdE(d2+ADip~ingMGdtcd^)!zOf}wC|5X+?Wu(q~Wlq9teVJ9<5#tMSGVY!LzDIxKFTe=#K@-G9g#?^_2f0M0%qtUmYz zQldZK)XHUPu@1m+z^*C7(+k?^KnirZ#^?p%!ucDbsXaHDHd8&_Wf3K>FE6YKfh27g z22{mlt}&KlF`w{qB!HFYorGBlP*2k%^nTx*uhQnT_KL8Kw0DcgzG#nPiB=x+BhP&j z&a37E&=hPnj3t!5I<&;d*Wo!pXzw@0gN+$o^;T{}I7BQA672#dFgO;Q>piX$OZdah zi~oJ3Ky97T?H{fAa4?9c>$hI&0o!sfP|GK_uP*-MVEm8o{$!FzUX}UZ#(3r=q)yM5 z!pmB4b%|N2#13H7!@H#@b7QTSX#CW1Gwv2r+zMuThD9>~?Z-)&VB<+Az-@ksTatBzY7-1kOYbXCJ?_WB zI+xSvE0yXf{qG-+U3WVlzY}c-S$Hp<1#)VA1EujQD6lq@c%FLdfQ@xz>Y=B6G}W~X zEb`cmAXq=L^Q*O;uMDIgf@_-REkC>%Z`<66M+{6D8x_v$t%TWPdas;`JCd8xnVm6e z-Mjb|sJRa(O^p-sTQ0f