diff --git a/packages/devextreme/js/__internal/viz/axes/base_axis.ts b/packages/devextreme/js/__internal/viz/axes/base_axis.ts index 8811d932f81b..dec056f275da 100644 --- a/packages/devextreme/js/__internal/viz/axes/base_axis.ts +++ b/packages/devextreme/js/__internal/viz/axes/base_axis.ts @@ -31,9 +31,10 @@ import { Deferred, when } from '@js/core/utils/deferred'; import { extend } from '@js/core/utils/extend'; import { adjust } from '@js/core/utils/math'; import { - isDefined, isFunction, isPlainObject, type, + isDate, isDefined, isFunction, isPlainObject, type, } from '@js/core/utils/type'; import formatHelper from '@js/format_helper'; +import { multiplyInExponentialForm } from '@ts/core/utils/m_math'; import constants from '@ts/viz/axes/axes_constants'; import { calculateCanvasMargins, measureLabels } from '@ts/viz/axes/axes_utils'; import createConstantLine from '@ts/viz/axes/constant_line'; @@ -70,6 +71,12 @@ const _isArray = Array.isArray; const DEFAULT_AXIS_LABEL_SPACING = 5; const MAX_GRID_BORDER_ADHENSION = 4; +const PANNING_CORRECTION_ITERATION_COUNT = 5; +const PANNING_CORRECTION_PRECISION = 1e-4; + +const ZOOM_FACTOR_PRECISION = 2; +const ZOOM_FACTOR_MULTIPLIER = 10 ** ZOOM_FACTOR_PRECISION; + const TOP = constants.top; const BOTTOM = constants.bottom; const LEFT = constants.left; @@ -1294,6 +1301,146 @@ Axis.prototype = { return length; }, + _getTickIntervalValue() { + const tickInterval = this.getTickInterval(); + + if (!isDefined(tickInterval)) { + return 0; + } + + return this._options.dataType === 'datetime' ? dateUtils.dateToMilliseconds(tickInterval) : tickInterval; + }, + + getWholeRangeBreaks() { + const businessRange = this._translator.getBusinessRange(); + const { type } = this._options; + + if (type === constants.discrete || type === constants.logarithmic + || !isDefined(businessRange.min) || !isDefined(businessRange.max)) { + return []; + } + + const interval = this._getTickIntervalValue(); + + return this._getBreaksForRange(businessRange.min, businessRange.max) + .reduce((result, scaleBreak) => { + const hidden = this._getHiddenDuration(scaleBreak, interval); + const shift = ((scaleBreak.to - scaleBreak.from) - hidden) / 2; + + return hidden ? result.concat(extend({}, scaleBreak, { + from: this._addToValue(scaleBreak.from, shift), + to: this._addToValue(scaleBreak.to, -shift), + cumulativeWidth: 0, + })) : result; + }, []); + }, + + _getBreaksForRange(minVisible, maxVisible) { + const viewport = minVisible > maxVisible + ? { minVisible: maxVisible, maxVisible: minVisible } + : { minVisible, maxVisible }; + const breaks = this._getScaleBreaks(this._options, viewport, this._series, this.isArgumentAxis); + + return this._filterBreaks(breaks, viewport, this._options.breakStyle); + }, + + _getHiddenDuration(scaleBreak, tickInterval) { + const duration = scaleBreak.to - scaleBreak.from; + + return scaleBreak.gapSize ? duration : Math.max(duration - tickInterval, 0); + }, + + getVisualRangeLengthWithoutBreaks(range) { + const businessRange = range || this._translator.getBusinessRange(); + const length = this.getVisualRangeLength(businessRange); + const options = this._options; + + if (options.type === constants.discrete || options.type === constants.logarithmic + || !isDefined(businessRange.minVisible) || !isDefined(businessRange.maxVisible)) { + return length; + } + + const interval = this._getTickIntervalValue(); + + return this._getBreaksForRange(businessRange.minVisible, businessRange.maxVisible) + .reduce((result, scaleBreak) => result - this._getHiddenDuration(scaleBreak, interval), length); + }, + + _addToValue(value, diff) { + return isDate(value) ? new Date(value.getTime() + diff) : value + diff; + }, + + adjustPannedRange(range, anchor?: 'start' | 'end') { + const that = this; + const storedParams = that._storedZoomEndParams; + const { type } = that._options; + + if (!storedParams || type === constants.discrete || type === constants.logarithmic) { + return range; + } + + const { startRange } = storedParams; + + if (!this._getBreaksForRange(range.startValue, range.endValue).length + && !this._getBreaksForRange(startRange.startValue, startRange.endValue).length) { + return range; + } + + const isReversed = range.startValue > range.endValue; + + if (isReversed) { + const reordered = that.adjustPannedRange({ startValue: range.endValue, endValue: range.startValue }, anchor); + + return { startValue: reordered.endValue, endValue: reordered.startValue }; + } + + const targetLength = that.getVisualRangeLengthWithoutBreaks({ + minVisible: startRange.startValue, + maxVisible: startRange.endValue, + }); + + if (!targetLength) { + return range; + } + + const tolerance = targetLength * PANNING_CORRECTION_PRECISION; + const bounds = that.getZoomBounds(); + const keepsEndValue = anchor + ? anchor === 'end' + : range.startValue > startRange.startValue || range.endValue > startRange.endValue; + let result = range; + let current = range; + let bestDeviation = Infinity; + + for (let i = 0; i < PANNING_CORRECTION_ITERATION_COUNT; i += 1) { + const delta = that.getVisualRangeLengthWithoutBreaks({ + minVisible: current.startValue, + maxVisible: current.endValue, + }) - targetLength; + const deviation = Math.abs(delta); + + if (deviation < bestDeviation) { + bestDeviation = deviation; + result = current; + } + + if (deviation <= tolerance) { + break; + } + + current = keepsEndValue + ? { startValue: that._addToValue(current.startValue, delta), endValue: current.endValue } + : { startValue: current.startValue, endValue: that._addToValue(current.endValue, -delta) }; + + if (current.startValue >= current.endValue + || current.startValue < bounds.startValue || current.endValue > bounds.endValue) { + break; + } + } + + return result; + }, + getVisualRangeCenter(range, useMerge) { const translator = this.getTranslator(); const businessRange = translator.getBusinessRange(); @@ -2468,9 +2615,17 @@ Axis.prototype = { }; const typeIsNotChanged = that.getOptions().type === that._storedZoomEndParams.type; const shift = typeIsNotChanged ? adjust(that.getVisualRangeCenter() - that.getVisualRangeCenter(previousBusinessRange, false)) : NaN; - const zoomFactor = typeIsNotChanged - // @ts-expect-error - ? +`${Math.round(`${that.getVisualRangeLength(previousBusinessRange) / (that.getVisualRangeLength() || 1)}e+2`)}e-2` : NaN; + const calcZoomFactor = (): number => { + if (action === 'pan') { + return 1; + } + + const currentLength = that.getVisualRangeLength() || 1; + const ratio = that.getVisualRangeLength(previousBusinessRange) / currentLength; + + return Math.round(multiplyInExponentialForm(ratio, ZOOM_FACTOR_PRECISION)) / ZOOM_FACTOR_MULTIPLIER; + }; + const zoomFactor = typeIsNotChanged ? calcZoomFactor() : NaN; const zoomEndEvent = that._getZoomEndEventArg(previousRange, domEvent, action, zoomFactor, shift); zoomEndEvent.cancel = that.checkZoomingLowerLimitOvercome(zoomFactor === 1 ? 'pan' : 'zoom', zoomFactor).stopInteraction; diff --git a/packages/devextreme/js/__internal/viz/chart_components/base_chart.ts b/packages/devextreme/js/__internal/viz/chart_components/base_chart.ts index 6009f4a7ba6f..d469ad8b0c7a 100644 --- a/packages/devextreme/js/__internal/viz/chart_components/base_chart.ts +++ b/packages/devextreme/js/__internal/viz/chart_components/base_chart.ts @@ -682,7 +682,11 @@ export const BaseChart = BaseWidget.inherit({ zoomMaxArg = argBusinessRange.maxVisible; } - this._scrollBar.init(argBusinessRange, !this._argumentAxes[0].getOptions().valueMarginsEnabled).setPosition(zoomMinArg, zoomMaxArg); + const argumentAxis = this._argumentAxes[0]; + + this._scrollBar + .init(argBusinessRange, !argumentAxis.getOptions().valueMarginsEnabled, argumentAxis.getWholeRangeBreaks()) + .setPosition(zoomMinArg, zoomMaxArg); } this._updateTracker(trackerCanvases); diff --git a/packages/devextreme/js/__internal/viz/chart_components/scroll_bar.ts b/packages/devextreme/js/__internal/viz/chart_components/scroll_bar.ts index 4d744fc09aaf..6570b98c00fd 100644 --- a/packages/devextreme/js/__internal/viz/chart_components/scroll_bar.ts +++ b/packages/devextreme/js/__internal/viz/chart_components/scroll_bar.ts @@ -60,6 +60,8 @@ ScrollBar.prototype = { const scrollElement = this._scroll.element; eventsEngine.on(scrollElement, dragEventStart, (e) => { + this._dragStartOffset = this._offset; + fireEvent({ type: 'dxc-scroll-start', originalEvent: e, @@ -68,37 +70,63 @@ ScrollBar.prototype = { }); eventsEngine.on(scrollElement, dragEventMove, (e) => { - const dX = -e.offset.x * this._scale; - const dY = -e.offset.y * this._scale; - const lx = this._offset - (this._layoutOptions.vertical ? dY : dX) / this._scale; - this._applyPosition(lx, lx + this._translator.canvasLength / this._scale); + const position = this._getDragPosition(e); + this._applyPosition(position, position + this._thumbLength); - fireEvent({ - type: 'dxc-scroll-move', - originalEvent: e, - target: scrollElement, - // @ts-expect-error - offset: { - x: dX, - y: dY, - }, - }); + fireEvent(this._getDragEvent('dxc-scroll-move', e, scrollElement, position)); }); eventsEngine.on(scrollElement, dragEventEnd, (e) => { - fireEvent({ - type: 'dxc-scroll-end', - originalEvent: e, - target: scrollElement, - // @ts-expect-error - offset: { - x: -e.offset.x * this._scale, - y: -e.offset.y * this._scale, - }, - }); + fireEvent(this._getDragEvent('dxc-scroll-end', e, scrollElement, this._getDragPosition(e))); }); }, + _getDragPosition(e) { + const offset = this._layoutOptions.vertical ? e.offset.y : e.offset.x; + + return (this._dragStartOffset ?? this._offset) + offset; + }, + + _getDragEvent(type, e, target, position) { + return { + type, + originalEvent: e, + target, + offset: { + x: -e.offset.x * this._scale, + y: -e.offset.y * this._scale, + }, + scrollRange: this._getRangeAtPosition(position), + }; + }, + + _getBoundaryDirection() { + return this._translateWithOffset || (this._hasBreaks ? 1 : 0); + }, + + _getRangeAtPosition(position) { + const translator = this._translator; + const length = this._thumbLength; + + if (!isFinite(position) || !isFinite(length)) { + return undefined; + } + + const visibleArea = translator.getCanvasVisibleArea(); + const lastPosition = _max(visibleArea.max - length, visibleArea.min); + const start = _min(_max(position, visibleArea.min), lastPosition); + + // the inverse of setPosition: the same boundary directions, or the end coordinate of a + // discrete thumb would resolve to the next category and every drag would widen the range + const direction = this._getBoundaryDirection(); + const from = translator.from(start, -direction); + const to = translator.from(start + length, direction); + + return translator.isInverted() + ? { startValue: to, endValue: from } + : { startValue: from, endValue: to }; + }, + update(options) { const that = this; let position = options.position; @@ -129,18 +157,21 @@ ScrollBar.prototype = { return that; }, - init(range, stick) { + init(range, stick, wholeRangeBreaks) { const that = this; const isDiscrete = range.axisType === 'discrete'; that._translateWithOffset = (isDiscrete && !stick && 1) || 0; + that._hasBreaks = !!wholeRangeBreaks?.length; that._translator.update(extend({}, range, { minVisible: null, maxVisible: null, visibleCategories: null, + breaks: wholeRangeBreaks?.length ? wholeRangeBreaks : null, + userBreaks: null, }, isDiscrete && { min: null, max: null, - } || {}), that._canvas, { isHorizontal: !that._layoutOptions.vertical, stick }); + } || {}), that._canvas, { isHorizontal: !that._layoutOptions.vertical, stick, breaksSize: 0 }); return that; }, @@ -215,15 +246,18 @@ ScrollBar.prototype = { // Axis like functions setPosition(min, max) { - const that = this; - const translator = that._translator; - const minPoint = isDefined(min) ? translator.translate(min, -that._translateWithOffset) : translator.translate('canvas_position_start'); - const maxPoint = isDefined(max) ? translator.translate(max, that._translateWithOffset) : translator.translate('canvas_position_end'); - - that._offset = _min(minPoint, maxPoint); - that._scale = translator.getScale(min, max); - - that._applyPosition(_min(minPoint, maxPoint), _max(minPoint, maxPoint)); + const translator = this._translator; + const direction = this._getBoundaryDirection(); + const minPoint = isDefined(min) ? translator.translate(min, -direction) : translator.translate('canvas_position_start'); + const maxPoint = isDefined(max) ? translator.translate(max, direction) : translator.translate('canvas_position_end'); + + this._offset = _min(minPoint, maxPoint); + this._thumbLength = Math.abs(maxPoint - minPoint); + this._scale = this._thumbLength + ? translator.canvasLength / this._thumbLength + : translator.getScale(min, max); + + this._applyPosition(_min(minPoint, maxPoint), _max(minPoint, maxPoint)); }, customPositionIsAvailable() { diff --git a/packages/devextreme/js/__internal/viz/chart_components/zoom_and_pan.ts b/packages/devextreme/js/__internal/viz/chart_components/zoom_and_pan.ts index 42361753519e..3cb735514e15 100644 --- a/packages/devextreme/js/__internal/viz/chart_components/zoom_and_pan.ts +++ b/packages/devextreme/js/__internal/viz/chart_components/zoom_and_pan.ts @@ -162,7 +162,11 @@ export default { const translate = -offsetCalc(e, actionData, coordField, scale); zoom = extend(true, zoom, axis.getTranslator().zoom(translate, scale, axis.getZoomBounds())); // @ts-expect-error - const range = axis.adjustRange(getVizRangeObject([zoom.min, zoom.max])); + let pannedRange = getVizRangeObject([zoom.min, zoom.max]); + if (actionField === 'pan' && scale === 1) { + pannedRange = axis.adjustPannedRange(pannedRange); + } + const range = axis.adjustRange(pannedRange); const { stopInteraction, correctedRange } = axis.checkZoomingLowerLimitOvercome(actionField, scale, range); if (!isDefined(viewport) @@ -231,7 +235,9 @@ export default { const scale = e.scale || 1; const getRange = (axis) => { const zoom = axis.getTranslator().zoom(-offsetCalc(e, actionData, coordField, scale), scale, axis.getZoomBounds()); - return { startValue: zoom.min, endValue: zoom.max }; + const range = { startValue: zoom.min, endValue: zoom.max }; + + return actionField === 'pan' && scale === 1 ? axis.adjustPannedRange(range) : range; }; const getParameters = (silent) => ({ start: true, end: silent }); getFilteredAxes(axes).forEach((axis) => { @@ -262,6 +268,20 @@ export default { zoomStarted && chart._requestChange(['VISUAL_RANGE']); } + function panArgumentAxisToThumb(e, scrollRange) { + const axes = getFilteredAxes(chart._argumentAxes); + const getRange = (axis) => axis.adjustRange( + axis.adjustPannedRange( + getVizRangeObject([scrollRange.startValue, scrollRange.endValue]), + axis.getTranslator().isInverted() ? 'end' : 'start', + ), + ); + + axes.forEach((axis) => axisZoom(axis, null, getRange, () => ({ start: true, end: true }), 'pan', 1, e)); + + axes.length && chart._requestChange(['VISUAL_RANGE']); + } + function prepareActionData(coords, action) { const axes = chart._argumentAxes.filter((axis) => checkCoords(canvasToRect(axis.getCanvas()), coords)); @@ -610,10 +630,17 @@ export default { }) .on(SCROLL_BAR_MOVE_EVENT_NAME, (e) => { preventDefaults(e); - axesViewportChanging(zoomAndPan, 'pan', e, calcOffsetForDrag, (e) => e.offset); + if (e.scrollRange && options.argumentAxis.pan) { + panArgumentAxisToThumb(e, e.scrollRange); + } else { + axesViewportChanging(zoomAndPan, 'pan', e, calcOffsetForDrag, (e) => e.offset); + } }) .on(SCROLL_BAR_END_EVENT_NAME, (e) => { preventDefaults(e); + if (e.scrollRange && options.argumentAxis.pan && (e.offset.x || e.offset.y)) { + panArgumentAxisToThumb(e, e.scrollRange); + } finishAxesViewportChanging(zoomAndPan, 'pan', e, calcOffsetForDrag); // @ts-expect-error zoomAndPan.actionData = null; diff --git a/packages/devextreme/testing/helpers/chartMocks.js b/packages/devextreme/testing/helpers/chartMocks.js index 6d5945d1c21c..8a5cb2c9b705 100644 --- a/packages/devextreme/testing/helpers/chartMocks.js +++ b/packages/devextreme/testing/helpers/chartMocks.js @@ -979,6 +979,8 @@ export const MockAxis = function(renderOptions) { getZoomBounds: sinon.stub(), + getWholeRangeBreaks: sinon.stub().returns([]), + setInitRange: sinon.stub(), setGroupSeries: sinon.stub(), diff --git a/packages/devextreme/testing/tests/DevExpress.viz.charts/chart.integration.tests.js b/packages/devextreme/testing/tests/DevExpress.viz.charts/chart.integration.tests.js index f3b976cd4dae..dd8ce97d42d3 100644 --- a/packages/devextreme/testing/tests/DevExpress.viz.charts/chart.integration.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.viz.charts/chart.integration.tests.js @@ -5152,3 +5152,103 @@ QUnit.test('encodeHtml is applied on option changing (T1334517)', function(asser tooltip: ' | Total - 850' }); }); + +QUnit.module('ScrollBar with scale breaks', $.extend({}, moduleSetup, { + beforeEach() { + moduleSetup.beforeEach.call(this); + + const workdays = []; + for(let day = 1; day <= 31; day++) { + const date = new Date(1994, 2, day); + if(date.getDay() !== 0 && date.getDay() !== 6) { + workdays.push({ date: date, val: day }); + } + } + + this.options = { + dataSource: workdays, + series: [{ argumentField: 'date', valueField: 'val' }], + scrollBar: { visible: true }, + legend: { visible: false }, + argumentAxis: { + visualRange: { + startValue: new Date(1994, 2, 9), + endValue: new Date(1994, 2, 18) + } + } + }; + }, + createChart(options) { + return moduleSetup.createChart.call(this, $.extend(true, {}, this.options, options)); + }, + setVisualRange(chart, firstDay) { + const axis = chart.getArgumentAxis(); + + axis.visualRange({ + startValue: new Date(1994, 2, firstDay), + endValue: new Date(1994, 2, firstDay + 9) + }); + + const $thumb = this.$container.find('.dxc-scroll-bar rect'); + const barArea = chart._scrollBar._translator.getCanvasVisibleArea(); + const wholeRange = axis.getTranslator().getBusinessRange(); + + return { + position: parseFloat($thumb.attr('y')), + barShare: parseFloat($thumb.attr('height')) / (barArea.max - barArea.min), + contentShare: axis.getVisualRangeLengthWithoutBreaks() + / axis.getVisualRangeLengthWithoutBreaks({ minVisible: wholeRange.min, maxVisible: wholeRange.max }) + }; + }, + checkThumbSlidesSmoothly(assert, chart) { + const thumbs = []; + + for(let firstDay = 1; firstDay <= 22; firstDay++) { + thumbs.push(this.setVisualRange(chart, firstDay)); + } + + for(let i = 1; i < thumbs.length; i++) { + assert.ok(thumbs[i].position >= thumbs[i - 1].position, + `thumb never moves back on March ${i + 1} (${thumbs[i - 1].position} -> ${thumbs[i].position})`); + assert.roughEqual(thumbs[i].barShare, thumbs[i].contentShare, 0.005, + `thumb size matches the rendered content on March ${i + 1}`); + } + + assert.ok(thumbs[thumbs.length - 1].position > thumbs[0].position, 'the thumb did move'); + } +}), () => { + QUnit.test('Thumb must not jump when the visual range starts inside a weekend break', function(assert) { + const chart = this.createChart({ argumentAxis: { workdaysOnly: true } }); + + const beforeBreak = this.setVisualRange(chart, 11); + const insideBreak = this.setVisualRange(chart, 12); + + assert.ok(insideBreak.position > beforeBreak.position, + `thumb moves forward instead of jumping to the beginning of the scroll bar (${beforeBreak.position} -> ${insideBreak.position})`); + assert.roughEqual(insideBreak.barShare, insideBreak.contentShare, 0.005, + 'thumb size matches the rendered content'); + }); + + QUnit.test('Thumb must slide smoothly over weekend breaks (workdaysOnly)', function(assert) { + this.checkThumbSlidesSmoothly(assert, this.createChart({ argumentAxis: { workdaysOnly: true } })); + }); + + QUnit.test('Thumb must slide smoothly over a user-defined scale break', function(assert) { + const chart = this.createChart({ + argumentAxis: { + breaks: [{ + startValue: new Date(1994, 2, 12), + endValue: new Date(1994, 2, 14) + }] + } + }); + + assert.ok(this.$container.find('.dxc-arg-breaks path').length > 0, 'the scale break is applied'); + + this.checkThumbSlidesSmoothly(assert, chart); + }); + + QUnit.test('Thumb must slide smoothly when there are no scale breaks', function(assert) { + this.checkThumbSlidesSmoothly(assert, this.createChart({})); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.viz.charts/chart.part5.tests.js b/packages/devextreme/testing/tests/DevExpress.viz.charts/chart.part5.tests.js index 69e18cd46ea3..ace0d36a04b2 100644 --- a/packages/devextreme/testing/tests/DevExpress.viz.charts/chart.part5.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.viz.charts/chart.part5.tests.js @@ -422,7 +422,7 @@ QUnit.test('chart with visible scrollBar', function(assert) { assert.ok(scrollBar, 'scroll bar'); assert.ok(scrollBar.init.calledOnce); - assert.deepEqual(scrollBar.init.lastCall.args, [range, true]); + assert.deepEqual(scrollBar.init.lastCall.args, [range, true, []]); assert.ok(scrollBar.setPane.calledOnce); assert.equal(scrollBar.setPane.lastCall.args[0], chart._getLayoutTargets()); @@ -454,7 +454,7 @@ QUnit.test('chart with visible scrollBar. Rotated', function(assert) { assert.ok(scrollBar); assert.ok(scrollBar.init.calledOnce); - assert.deepEqual(scrollBar.init.lastCall.args, [range, true]); + assert.deepEqual(scrollBar.init.lastCall.args, [range, true, []]); assert.ok(scrollBar.setPane.calledOnce); assert.equal(scrollBar.setPane.lastCall.args[0], chart._getLayoutTargets()); @@ -557,7 +557,7 @@ QUnit.test('scrollbarModule.ScrollBar option changed', function(assert) { }]); assert.equal(scrollBar.init.callCount, 1, 'scroll bar init calls'); - assert.deepEqual(scrollBar.init.lastCall.args, [range, true]); + assert.deepEqual(scrollBar.init.lastCall.args, [range, true, []]); assert.equal(scrollBar.setPosition.callCount, 1); assert.deepEqual(scrollBar.setPosition.lastCall.args, [range.minVisible, range.maxVisible]); @@ -625,7 +625,7 @@ QUnit.test('Options changed - show scrollBar', function(assert) { const range = chart._argumentAxes[0].getTranslator().getBusinessRange(); assert.equal(scrollBar.init.callCount, 1, 'scroll bar init calls'); - assert.deepEqual(scrollBar.init.lastCall.args, [range, true]); + assert.deepEqual(scrollBar.init.lastCall.args, [range, true, []]); assert.equal(scrollBar.setPosition.callCount, 1); assert.deepEqual(scrollBar.setPosition.lastCall.args, [range.minVisible, range.maxVisible]); diff --git a/packages/devextreme/testing/tests/DevExpress.viz.charts/scrollBar.tests.js b/packages/devextreme/testing/tests/DevExpress.viz.charts/scrollBar.tests.js index 70b8554fbb65..ec5e430bbd04 100644 --- a/packages/devextreme/testing/tests/DevExpress.viz.charts/scrollBar.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.viz.charts/scrollBar.tests.js @@ -563,10 +563,13 @@ QUnit.test('init scrollBar', function(assert) { maxVisible: null, min: 10, minVisible: null, - visibleCategories: null + visibleCategories: null, + breaks: null, + userBreaks: null }, canvas, { isHorizontal: true, - stick: false + stick: false, + breaksSize: 0 }] ); }); @@ -591,14 +594,35 @@ QUnit.test('init scrollBar. Rotated', function(assert) { maxVisible: null, min: 10, minVisible: null, - visibleCategories: null + visibleCategories: null, + breaks: null, + userBreaks: null }, canvas, { isHorizontal: false, - stick: false + stick: false, + breaksSize: 0 }] ); }); +QUnit.test('init scrollBar. Remove scale breaks', function(assert) { + const group = new Element(); + const scrollBar = new ScrollBar(this.renderer, group); + const rangeWithBreaks = $.extend({}, range, { + breaks: [{ from: 40, to: 50, cumulativeWidth: 0 }], + userBreaks: [{ from: 40, to: 50 }] + }); + scrollBar.update(this.options).updateSize(canvas); + + scrollBar.init(rangeWithBreaks, false); + + const scrollTranslator = translator2DModule.Translator2D.lastCall.returnValue; + const { breaks, userBreaks } = scrollTranslator.update.lastCall.args[0]; + + assert.strictEqual(breaks, null, 'breaks are calculated for the visual range only and must not be applied to the whole-range translator'); + assert.strictEqual(userBreaks, null, 'userBreaks are calculated for the visual range only and must not be applied to the whole-range translator'); +}); + QUnit.test('init scrollBar. Remove min and max ', function(assert) { const group = new Element(); const scrollBar = new ScrollBar(this.renderer, group); @@ -620,10 +644,13 @@ QUnit.test('init scrollBar. Remove min and max ', function(assert) { min: null, minVisible: null, visibleCategories: null, + breaks: null, + userBreaks: null, axisType: 'discrete' }, canvas, { isHorizontal: true, - stick: false + stick: false, + breaksSize: 0 }]); }); diff --git a/packages/devextreme/testing/tests/DevExpress.viz.charts/zoomAndPan.tests.js b/packages/devextreme/testing/tests/DevExpress.viz.charts/zoomAndPan.tests.js index 3a0258027f54..2a3beabc8b52 100644 --- a/packages/devextreme/testing/tests/DevExpress.viz.charts/zoomAndPan.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.viz.charts/zoomAndPan.tests.js @@ -2077,6 +2077,61 @@ QUnit.test('Scrollbar does not pan argument axis if it can not be panned', funct assert.deepEqual(scrollBarElement.offset(), scrollBarOffset); }); +QUnit.test('Scrollbar drag keeps the number of visible categories on a discrete axis', function(assert) { + const chart = this.createChart({ + scrollBar: { + visible: true + }, + argumentAxis: { + type: 'discrete', + visualRange: { + startValue: 3, + endValue: 7 + } + }, + zoomAndPan: { + argumentAxis: 'pan' + }, + series: [{ type: 'bar' }] + }); + const countBefore = chart.getArgumentAxis().visualRange().categories.length; + + pointerMock(chart._scrollBar._scroll.element).start({ x: 0, y: 0 }).dragStart().drag(60).dragEnd(); + + const rangeAfter = chart.getArgumentAxis().visualRange(); + + assert.equal(rangeAfter.categories.length, countBefore, 'the range keeps its size: ' + JSON.stringify(rangeAfter.categories)); + assert.notEqual(rangeAfter.startValue, 3, 'the range really moved'); +}); + +QUnit.test('Scrollbar drag does not pan the argument axis if it can not be panned', function(assert) { + const onZoomStart = sinon.spy(); + const onZoomEnd = sinon.spy(); + const chart = this.createChart({ + scrollBar: { + visible: true + }, + argumentAxis: { + visualRange: { + startValue: 3, + endValue: 7 + } + }, + zoomAndPan: { + argumentAxis: 'zoom', + valueAxis: 'pan' + }, + onZoomStart: onZoomStart, + onZoomEnd: onZoomEnd + }); + + pointerMock(chart._scrollBar._scroll.element).start({ x: 0, y: 0 }).dragStart().drag(200).dragEnd(); + + assert.deepEqual(chart.getArgumentAxis().visualRange(), { startValue: 3, endValue: 7 }, 'the visual range is not panned'); + assert.equal(onZoomStart.callCount, 0); + assert.equal(onZoomEnd.callCount, 0); +}); + QUnit.module('Check visualRange changing strategy choosing', environment); QUnit.test('Drag. Small chart rendering time on start and big time in the middle', function(assert) { @@ -3604,3 +3659,219 @@ QUnit.test('Axes zooming - pinch', function(assert) { assert.roughEqual(argumentAxis.getAxisPosition(), 300, 2.01, 'argument axis moved - zoom out'); assert.roughEqual(valueAxis.getAxisPosition(), 400, 2.01, 'value axis moved - zoom out'); }); + +const workdaysDataSource = (() => { + const arr = []; + for(let day = 1; day <= 31; day++) { + const date = new Date(1994, 2, day); + if(date.getDay() !== 0 && date.getDay() !== 6) { + arr.push({ arg: date, val: day }); + } + } + return arr; +})(); + +QUnit.module('Panning over scale breaks', $.extend({}, environment, { + createDateChart(argumentAxisOptions, chartOptions) { + return this.createChart($.extend(true, { + dataSource: workdaysDataSource, + zoomAndPan: { argumentAxis: 'pan' }, + argumentAxis: $.extend({ + argumentType: 'datetime', + visualRange: { + startValue: new Date(1994, 2, 9), + endValue: new Date(1994, 2, 18) + } + }, argumentAxisOptions) + }, chartOptions)); + }, + getScale(chart) { + return chart.getArgumentAxis().getTranslator().getInterval(24 * 60 * 60 * 1000); + }, + hasScaleBreaks(chart) { + return (chart.getArgumentAxis().getTranslator().getBusinessRange().breaks || []).length > 0; + }, + panForward() { + this.pointer.start({ x: 100, y: 300 }).dragStart().drag(-300).dragEnd(); + }, + // the real ScrollBar has to run the gesture: it is the one that reports where its thumb + // points, and a hand-made event pair would silently exercise the fallback path instead + scrollForward(chart) { + pointerMock(chart._scrollBar._scroll.element) + .start({ x: 0, y: 0 }).dragStart().drag(200).dragEnd(); + } +}), () => { + QUnit.test('Panning over weekend breaks must not change the chart scale (workdaysOnly)', function(assert) { + const chart = this.createDateChart({ workdaysOnly: true }); + const scaleBefore = this.getScale(chart); + + for(let i = 1; i <= 5; i++) { + this.panForward(); + assert.roughEqual(this.getScale(chart), scaleBefore, 2, `chart keeps its scale after pan ${i}`); + } + }); + + QUnit.test('Panning over user-defined scale breaks must not change the chart scale', function(assert) { + const chart = this.createDateChart({ + breaks: [12, 19, 26].map((day) => ({ + startValue: new Date(1994, 2, day), + endValue: new Date(1994, 2, day + 2) + })) + }); + const scaleBefore = this.getScale(chart); + + for(let i = 1; i <= 5; i++) { + this.panForward(); + assert.roughEqual(this.getScale(chart), scaleBefore, 2, `chart keeps its scale after pan ${i}`); + } + + assert.ok(this.hasScaleBreaks(chart), 'the scale breaks really apply to the visual range'); + }); + + QUnit.test('Panning over weekend breaks must be reported as a pan, not as a zoom', function(assert) { + const onZoomEnd = sinon.spy(); + const chart = this.createDateChart({ workdaysOnly: true }, { onZoomEnd: onZoomEnd }); + + for(let i = 1; i <= 5; i++) { + this.panForward(); + } + + assert.strictEqual(onZoomEnd.callCount, 5, 'every gesture reported'); + onZoomEnd.getCalls().forEach((call, i) => { + assert.strictEqual(call.args[0].actionType, 'pan', `gesture ${i + 1} is a pan`); + assert.strictEqual(call.args[0].zoomFactor, 1, `gesture ${i + 1} did not change the zoom factor`); + }); + + assert.ok(chart.getArgumentAxis().visualRange().startValue > new Date(1994, 2, 9), 'the range really moved forward'); + }); + + QUnit.test('Dragging the scroll bar over weekend breaks must not change the chart scale', function(assert) { + const chart = this.createDateChart({ workdaysOnly: true }, { + scrollBar: { visible: true }, + zoomAndPan: { argumentAxis: 'both' } + }); + const scaleBefore = this.getScale(chart); + + for(let i = 1; i <= 5; i++) { + this.scrollForward(chart); + assert.roughEqual(this.getScale(chart), scaleBefore, 2, `chart keeps its scale after scroll ${i}`); + } + + assert.ok(chart.getArgumentAxis().visualRange().startValue > new Date(1994, 2, 9), 'the range really moved forward'); + }); + + QUnit.test('Panning over weekend breaks must not change the chart scale on an inverted axis', function(assert) { + const chart = this.createDateChart({ workdaysOnly: true, inverted: true }); + const scaleBefore = this.getScale(chart); + + for(let i = 1; i <= 5; i++) { + this.panForward(); + assert.roughEqual(this.getScale(chart), scaleBefore, 2, `chart keeps its scale after pan ${i}`); + } + }); + + // the correction must keep running after the break has left the range, otherwise the span + // stays as the break made it and the chart rescales + QUnit.test('Panning past a scale break must not change the chart scale once the break is gone', function(assert) { + const chart = this.createDateChart({ + breaks: [{ + startValue: new Date(1994, 2, 12), + endValue: new Date(1994, 2, 16) + }] + }); + const scaleBefore = this.getScale(chart); + + for(let i = 1; i <= 5; i++) { + this.panForward(); + assert.roughEqual(this.getScale(chart), scaleBefore, 2, `chart keeps its scale after pan ${i}`); + } + + assert.ok(!this.hasScaleBreaks(chart), 'the break really left the visual range'); + }); + + QUnit.test('Panning over weekend breaks must not change the chart scale on a rotated chart', function(assert) { + const chart = this.createDateChart({ workdaysOnly: true }, { rotated: true }); + const scaleBefore = this.getScale(chart); + + for(let i = 1; i <= 5; i++) { + this.pointer.start({ x: 300, y: 100 }).dragStart().drag(0, -300).dragEnd(); + assert.roughEqual(this.getScale(chart), scaleBefore, 2, `chart keeps its scale after pan ${i}`); + } + }); + + QUnit.test('Dragging the scroll bar over weekend breaks must not change the chart scale on an inverted axis', function(assert) { + const chart = this.createDateChart({ workdaysOnly: true, inverted: true }, { + scrollBar: { visible: true }, + zoomAndPan: { argumentAxis: 'both' } + }); + const scaleBefore = this.getScale(chart); + + for(let i = 1; i <= 5; i++) { + this.scrollForward(chart); + assert.roughEqual(this.getScale(chart), scaleBefore, 2, `chart keeps its scale after scroll ${i}`); + } + + assert.notDeepEqual(chart.getArgumentAxis().visualRange(), { + startValue: new Date(1994, 2, 9), + endValue: new Date(1994, 2, 18) + }, 'the range really moved'); + }); + + // the scrollbar drag path was only covered with workdaysOnly, whose breaks carry a gapSize + // and are never reshaped by the tick generator + QUnit.test('Dragging the scroll bar over a user-defined scale break must not change the chart scale', function(assert) { + const chart = this.createDateChart({ + breaks: [12, 19, 26].map((day) => ({ + startValue: new Date(1994, 2, day), + endValue: new Date(1994, 2, day + 2) + })) + }, { + scrollBar: { visible: true }, + zoomAndPan: { argumentAxis: 'both' } + }); + const scaleBefore = this.getScale(chart); + + for(let i = 1; i <= 3; i++) { + this.scrollForward(chart); + assert.roughEqual(this.getScale(chart), scaleBefore, 2, `chart keeps its scale after scroll ${i}`); + } + + assert.ok(this.hasScaleBreaks(chart), 'the scale breaks really apply to the visual range'); + }); + + QUnit.test('A click on the scroll bar thumb must not move the visual range', function(assert) { + const chart = this.createDateChart({ workdaysOnly: true }, { + scrollBar: { visible: true }, + zoomAndPan: { argumentAxis: 'both' } + }); + const rangeBefore = chart.getArgumentAxis().visualRange(); + + pointerMock(chart._scrollBar._scroll.element).start({ x: 0, y: 0 }).dragStart().dragEnd(); + + assert.deepEqual(chart.getArgumentAxis().visualRange(), rangeBefore, 'the range is left as it was'); + }); + + QUnit.test('Panning an axis without scale breaks is not affected', function(assert) { + const chart = this.createDateChart({}); + const scaleBefore = this.getScale(chart); + + for(let i = 1; i <= 5; i++) { + this.panForward(); + assert.roughEqual(this.getScale(chart), scaleBefore, 2, `chart keeps its scale after pan ${i}`); + } + }); + + QUnit.test('Panning to the end of the whole range stops at the bound and keeps the scale', function(assert) { + const chart = this.createDateChart({ workdaysOnly: true }); + const scaleBefore = this.getScale(chart); + const argumentAxis = chart.getArgumentAxis(); + + for(let i = 0; i < 30; i++) { + this.panForward(); + } + + assert.roughEqual(this.getScale(chart), scaleBefore, 2, 'chart keeps its scale at the bound'); + assert.deepEqual(argumentAxis.visualRange().endValue, argumentAxis.getZoomBounds().endValue, + 'the range stopped at the end of the data'); + }); +});