diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index c0bf6181..3bffe7d7 100755 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -145,6 +145,7 @@ module.exports = { children: [ 'drag/category', 'drag/linear', + 'drag/linear-ratio', 'drag/log', 'drag/time', 'drag/timeseries', diff --git a/docs/guide/options.md b/docs/guide/options.md index 74c6cd93..74afe03b 100644 --- a/docs/guide/options.md +++ b/docs/guide/options.md @@ -80,7 +80,7 @@ const chart = new Chart('id', { | [`drawTime`](#draw-time) | `string` | `beforeDatasetsDraw` | When the dragging box is drawn on the chart | `threshold` | `number` | `0` | Minimal zoom distance required before actually applying zoom | `modifierKey` | `'ctrl'`\|`'alt'`\|`'shift'`\|`'meta'` | `null` | Modifier key required for drag-to-zoom - +| `maintainAspectRatio` | `boolean` | `undefined` | Maintain aspect ratio of the chart ## Draw Time diff --git a/docs/samples/drag/linear-ratio.md b/docs/samples/drag/linear-ratio.md new file mode 100644 index 00000000..54094b36 --- /dev/null +++ b/docs/samples/drag/linear-ratio.md @@ -0,0 +1,112 @@ +# Linear Scales + maintainAspectRatio + +Zooming is performed by clicking and selecting an area over the chart with the mouse. Pan is activated by keeping `shift` pressed. + +```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 = { + reverse: true, + 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 dragColor = Utils.randomColor(0.4); +const zoomOptions = { + pan: { + enabled: true, + mode: 'xy', + modifierKey: 'shift', + }, + zoom: { + mode: 'xy', + drag: { + enabled: true, + borderColor: 'rgb(54, 162, 235)', + borderWidth: 1, + backgroundColor: 'rgba(54, 162, 235, 0.3)', + maintainAspectRatio: true, + } + } +}; +// + +const zoomStatus = () => zoomOptions.zoom.drag.enabled ? 'enabled' : 'disabled'; + +// +const config = { + type: 'scatter', + data: data, + options: { + scales: scales, + plugins: { + zoom: zoomOptions, + title: { + display: true, + position: 'bottom', + text: (ctx) => 'Zoom: ' + zoomStatus() + } + }, + } +}; +// + +const actions = [ + { + name: 'Toggle zoom', + handler(chart) { + zoomOptions.zoom.drag.enabled = !zoomOptions.zoom.drag.enabled; + chart.update(); + } + }, { + name: 'Reset zoom', + handler(chart) { + chart.resetZoom(); + } + } +]; + +module.exports = { + actions, + config, +}; +``` diff --git a/src/core.js b/src/core.js index d0b3d335..93a36c05 100644 --- a/src/core.js +++ b/src/core.js @@ -1,7 +1,7 @@ import {each, callback as call, sign, valueOrDefault} from 'chart.js/helpers'; import {panFunctions, updateRange, zoomFunctions, zoomRectFunctions} from './scale.types'; import {getState} from './state'; -import {directionEnabled, getEnabledScalesByPoint} from './utils'; +import {directionEnabled, getEnabledScalesByPoint, mathMax, mathMin, objectKeys, mathRound} from './utils'; function shouldUpdateScaleLimits(scale, originalScaleLimits, updatedScaleLimits) { const {id, options: {min, max}} = scale; @@ -149,9 +149,9 @@ export function getZoomLevel(chart) { each(chart.scales, function(scale) { const origRange = getOriginalRange(state, scale.id); if (origRange) { - const level = Math.round(origRange / (scale.max - scale.min) * 100) / 100; - min = Math.min(min, level); - max = Math.max(max, level); + const level = mathRound(origRange / (scale.max - scale.min) * 100) / 100; + min = mathMin(min, level); + max = mathMax(max, level); } }); return min < 1 ? min : max; @@ -202,7 +202,7 @@ export function getInitialScaleBounds(chart) { const state = getState(chart); storeOriginalScaleLimits(chart, state); const scaleBounds = {}; - for (const scaleId of Object.keys(chart.scales)) { + for (const scaleId of objectKeys(chart.scales)) { const {min, max} = state.originalScaleLimits[scaleId] || {min: {}, max: {}}; scaleBounds[scaleId] = {min: min.scale, max: max.scale}; } @@ -213,7 +213,7 @@ export function getInitialScaleBounds(chart) { export function getZoomedScaleBounds(chart) { const state = getState(chart); const scaleBounds = {}; - for (const scaleId of Object.keys(chart.scales)) { + for (const scaleId of objectKeys(chart.scales)) { scaleBounds[scaleId] = state.updatedScaleLimits[scaleId]; } @@ -222,7 +222,7 @@ export function getZoomedScaleBounds(chart) { export function isZoomedOrPanned(chart) { const scaleBounds = getInitialScaleBounds(chart); - for (const scaleId of Object.keys(chart.scales)) { + for (const scaleId of objectKeys(chart.scales)) { const {min: originalMin, max: originalMax} = scaleBounds[scaleId]; if (originalMin !== undefined && chart.scales[scaleId].min !== originalMin) { diff --git a/src/hammer.js b/src/hammer.js index bc8f544f..a52460ae 100644 --- a/src/hammer.js +++ b/src/hammer.js @@ -2,7 +2,7 @@ import {callback as call} from 'chart.js/helpers'; import Hammer from 'hammerjs'; import {pan, zoom} from './core'; import {getState} from './state'; -import {directionEnabled, getEnabledScalesByPoint, getModifierKey, keyNotPressed, keyPressed} from './utils'; +import {mathAbs, directionEnabled, getEnabledScalesByPoint, getModifierKey, keyNotPressed, keyPressed} from './utils'; function createEnabler(chart, state) { return function(recognizer, event) { @@ -26,8 +26,8 @@ function createEnabler(chart, state) { function pinchAxes(p0, p1) { // fingers position difference - const pinchX = Math.abs(p0.clientX - p1.clientX); - const pinchY = Math.abs(p0.clientY - p1.clientY); + const pinchX = mathAbs(p0.clientX - p1.clientX); + const pinchY = mathAbs(p0.clientY - p1.clientY); // diagonal fingers will change both (xy) axes const p = pinchX / pinchY; diff --git a/src/handlers.js b/src/handlers.js index 9c11851c..c60076b6 100644 --- a/src/handlers.js +++ b/src/handlers.js @@ -1,6 +1,6 @@ -import {directionEnabled, debounce, keyNotPressed, getModifierKey, keyPressed} from './utils'; +import {directionEnabled, debounce, keyNotPressed, getModifierKey, keyPressed, mathMin, mathMax, mathAbs} from './utils'; import {zoom, zoomRect} from './core'; -import {callback as call, getRelativePosition, _isPointInArea} from 'chart.js/helpers'; +import {callback as call, getRelativePosition, _isPointInArea, sign} from 'chart.js/helpers'; import {getState} from './state'; function removeHandler(chart, type) { @@ -86,31 +86,59 @@ export function mouseDown(chart, event) { addHandler(chart, window.document, 'keydown', keyDown); } -export function computeDragRect(chart, mode, beginPointEvent, endPointEvent) { +function applyAspectRatio(endPoint, beginPoint, aspectRatio) { + let width = endPoint.x - beginPoint.x; + let height = endPoint.y - beginPoint.y; + const ratio = mathAbs(width / height); + + if (ratio > aspectRatio) { + width = sign(width) * mathAbs(height * aspectRatio); + } else if (ratio < aspectRatio) { + height = sign(height) * mathAbs(width / aspectRatio); + } + + endPoint.x = beginPoint.x + width; + endPoint.y = beginPoint.y + height; +} + +function applyMinMaxProps(rect, beginPoint, endPoint, {min, max, prop}) { + rect[min] = mathMin(beginPoint[prop], endPoint[prop]); + rect[max] = mathMax(beginPoint[prop], endPoint[prop]); +} + +function getReplativePoints(chart, points, maintainAspectRatio) { + const beginPoint = getRelativePosition(points.dragStart, chart); + const endPoint = getRelativePosition(points.dragEnd, chart); + + if (maintainAspectRatio) { + const aspectRatio = chart.chartArea.width / chart.chartArea.height; + applyAspectRatio(endPoint, beginPoint, aspectRatio); + } + + return {beginPoint, endPoint}; +} + +export function computeDragRect(chart, mode, points, maintainAspectRatio) { const xEnabled = directionEnabled(mode, 'x', chart); const yEnabled = directionEnabled(mode, 'y', chart); - let {top, left, right, bottom, width: chartWidth, height: chartHeight} = chart.chartArea; + const {top, left, right, bottom, width: chartWidth, height: chartHeight} = chart.chartArea; + const rect = {top, left, right, bottom}; - const beginPoint = getRelativePosition(beginPointEvent, chart); - const endPoint = getRelativePosition(endPointEvent, chart); + const {beginPoint, endPoint} = getReplativePoints(chart, points, maintainAspectRatio && xEnabled && yEnabled); if (xEnabled) { - left = Math.min(beginPoint.x, endPoint.x); - right = Math.max(beginPoint.x, endPoint.x); + applyMinMaxProps(rect, beginPoint, endPoint, {min: 'left', max: 'right', prop: 'x'}); } if (yEnabled) { - top = Math.min(beginPoint.y, endPoint.y); - bottom = Math.max(beginPoint.y, endPoint.y); + applyMinMaxProps(rect, beginPoint, endPoint, {min: 'top', max: 'bottom', prop: 'y'}); } - const width = right - left; - const height = bottom - top; + + const width = rect.right - rect.left; + const height = rect.bottom - rect.top; return { - left, - top, - right, - bottom, + ...rect, width, height, zoomX: xEnabled && width ? 1 + ((chartWidth - width) / chartWidth) : 1, @@ -125,8 +153,8 @@ export function mouseUp(chart, event) { } removeHandler(chart, 'mousemove'); - const {mode, onZoomComplete, drag: {threshold = 0}} = state.options.zoom; - const rect = computeDragRect(chart, mode, state.dragStart, event); + const {mode, onZoomComplete, drag: {threshold = 0, maintainAspectRatio}} = state.options.zoom; + const rect = computeDragRect(chart, mode, {dragStart: state.dragStart, dragEnd: event}, maintainAspectRatio); const distanceX = directionEnabled(mode, 'x', chart) ? rect.width : 0; const distanceY = directionEnabled(mode, 'y', chart) ? rect.height : 0; const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY); diff --git a/src/plugin.js b/src/plugin.js index acdd23e5..f37bfa95 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -6,6 +6,8 @@ import {panFunctions, zoomFunctions, zoomRectFunctions} from './scale.types'; import {getState, removeState} from './state'; import {version} from '../package.json'; +const hasOwnProp = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); + function draw(chart, caller, options) { const dragOptions = options.zoom.drag; const {dragStart, dragEnd} = getState(chart); @@ -13,7 +15,7 @@ function draw(chart, caller, options) { if (dragOptions.drawTime !== caller || !dragEnd) { return; } - const {left, top, width, height} = computeDragRect(chart, options.zoom.mode, dragStart, dragEnd); + const {left, top, width, height} = computeDragRect(chart, options.zoom.mode, {dragStart, dragEnd}, dragOptions.maintainAspectRatio); const ctx = chart.ctx; ctx.save(); @@ -63,11 +65,11 @@ export default { const state = getState(chart); state.options = options; - if (Object.prototype.hasOwnProperty.call(options.zoom, 'enabled')) { + if (hasOwnProp(options.zoom, 'enabled')) { console.warn('The option `zoom.enabled` is no longer supported. Please use `zoom.wheel.enabled`, `zoom.drag.enabled`, or `zoom.pinch.enabled`.'); } - if (Object.prototype.hasOwnProperty.call(options.zoom, 'overScaleMode') - || Object.prototype.hasOwnProperty.call(options.pan, 'overScaleMode')) { + if (hasOwnProp(options.zoom, 'overScaleMode') + || hasOwnProp(options.pan, 'overScaleMode')) { console.warn('The option `overScaleMode` is deprecated. Please use `scaleMode` instead (and update `mode` as desired).'); } diff --git a/src/scale.types.js b/src/scale.types.js index a4699d06..506474e6 100644 --- a/src/scale.types.js +++ b/src/scale.types.js @@ -1,5 +1,6 @@ -import {valueOrDefault} from 'chart.js/helpers'; +import {_limitValue, valueOrDefault} from 'chart.js/helpers'; import {getState} from './state'; +import { mathAbs, mathMax, mathMin, mathRound } from './utils'; /** * @typedef {import('chart.js').Point} Point @@ -22,9 +23,7 @@ function zoomDelta(scale, zoom, center) { const centerPoint = scale.isHorizontal() ? center.x : center.y; // `scale.getValueForPixel()` can return a value less than the `scale.min` or // greater than `scale.max` when `centerPoint` is outside chartArea. - const minPercent = Math.max(0, Math.min(1, - (scale.getValueForPixel(centerPoint) - scale.min) / range || 0 - )); + const minPercent = _limitValue((scale.getValueForPixel(centerPoint) - scale.min) / range || 0, 0, 1); const maxPercent = 1 - minPercent; @@ -62,8 +61,8 @@ function getRange(scale, pixel0, pixel1) { const v0 = scale.getValueForPixel(pixel0); const v1 = scale.getValueForPixel(pixel1); return { - min: Math.min(v0, v1), - max: Math.max(v0, v1) + min: mathMin(v0, v1), + max: mathMax(v0, v1) }; } @@ -88,17 +87,17 @@ export function updateRange(scale, {min, max}, limits, zoom = false) { return true; } - const range = zoom ? Math.max(max - min, minRange) : scale.max - scale.min; + const range = zoom ? mathMax(max - min, minRange) : scale.max - scale.min; const offset = (range - max + min) / 2; min -= offset; max += offset; if (min < minLimit) { min = minLimit; - max = Math.min(minLimit + range, maxLimit); + max = mathMin(minLimit + range, maxLimit); } else if (max > maxLimit) { max = maxLimit; - min = Math.max(maxLimit - range, minLimit); + min = mathMax(maxLimit - range, minLimit); } scaleOpts.min = min; scaleOpts.max = max; @@ -119,7 +118,7 @@ function zoomRectNumericalScale(scale, from, to, limits) { updateRange(scale, getRange(scale, from, to), limits, true); } -const integerChange = (v) => v === 0 || isNaN(v) ? 0 : v < 0 ? Math.min(Math.round(v), -1) : Math.max(Math.round(v), 1); +const integerChange = (v) => v === 0 || isNaN(v) ? 0 : v < 0 ? mathMin(mathRound(v), -1) : mathMax(mathRound(v), 1); function existCategoryFromMaxZoom(scale) { const labels = scale.getLabels(); @@ -151,17 +150,17 @@ function panCategoryScale(scale, delta, limits) { const lastLabelIndex = labels.length - 1; let {min, max} = scale; // The visible range. Ticks can be skipped, and thus not reliable. - const range = Math.max(max - min, 1); + const range = mathMax(max - min, 1); // How many pixels of delta is required before making a step. stepSize, but limited to max 1/10 of the scale length. - const stepDelta = Math.round(scaleLength(scale) / Math.max(range, 10)); - const stepSize = Math.round(Math.abs(delta / stepDelta)); + const stepDelta = mathRound(scaleLength(scale) / mathMax(range, 10)); + const stepSize = mathRound(mathAbs(delta / stepDelta)); let applied; if (delta < -stepDelta) { - max = Math.min(max + stepSize, lastLabelIndex); + max = mathMin(max + stepSize, lastLabelIndex); min = range === 1 ? max : max - range; applied = max === lastLabelIndex; } else if (delta > stepDelta) { - min = Math.max(0, min - stepSize); + min = mathMax(0, min - stepSize); max = range === 1 ? min : min + range; applied = min === 0; } diff --git a/src/utils.js b/src/utils.js index 3d16880c..f098d2b9 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,5 +1,11 @@ import {each} from 'chart.js/helpers'; +export const objectKeys = Object.keys +export const mathAbs = Math.abs +export const mathMin = Math.min +export const mathMax = Math.max +export const mathRound = Math.round + export const getModifierKey = opts => opts && opts.enabled && opts.modifierKey; export const keyPressed = (key, event) => key && event[key + 'Key']; export const keyNotPressed = (key, event) => key && !event[key + 'Key']; diff --git a/test/specs/zoom.spec.js b/test/specs/zoom.spec.js index 615bc6a4..d29df843 100644 --- a/test/specs/zoom.spec.js +++ b/test/specs/zoom.spec.js @@ -253,6 +253,54 @@ describe('zoom', function() { expect(scaleY.options.min).toBeCloseTo(1.1); expect(scaleY.options.max).toBeCloseTo(1.7); }); + + it('should respect aspectRatio when mode = xy', function() { + chart = window.acquireChart({ + type: 'line', + data, + options: { + scales: { + x: { + type: 'linear' + }, + y: { + type: 'linear' + } + }, + plugins: { + legend: false, + title: false, + zoom: { + zoom: { + drag: { + enabled: true, + maintainAspectRatio: true, + }, + mode: 'xy' + } + } + } + } + }); + + scaleX = chart.scales.x; + scaleY = chart.scales.y; + + jasmine.triggerMouseEvent(chart, 'mousedown', { + x: scaleX.getPixelForValue(1.5), + y: scaleY.getPixelForValue(1.1) + }); + jasmine.triggerMouseEvent(chart, 'mouseup', { + x: scaleX.getPixelForValue(2.8), + y: scaleY.getPixelForValue(1.7) + }); + + expect(scaleX.options.min).toBeCloseTo(1.5); + expect(scaleX.options.max).toBeCloseTo(2.1); + expect(scaleY.options.min).toBeCloseTo(1.1); + expect(scaleY.options.max).toBeCloseTo(1.7); + }); + }); });