From 95d6bcca6b375e8c8fd3ae3e47993f013a326c4c Mon Sep 17 00:00:00 2001 From: magiccwarss Date: Sat, 7 Nov 2026 20:55:15 +0400 Subject: [PATCH 1/2] fix(dataZoom): preserve value ranges when recovering options. close #21408 --- src/component/dataZoom/DataZoomModel.ts | 19 ++- src/component/dataZoom/dataZoomProcessor.ts | 4 +- test/dataZoom-range-recovery.html | 111 ++++++++++++++ .../component/dataZoom/rangeRoundTrip.test.ts | 139 ++++++++++++++++++ 4 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 test/dataZoom-range-recovery.html create mode 100644 test/ut/spec/component/dataZoom/rangeRoundTrip.test.ts diff --git a/src/component/dataZoom/DataZoomModel.ts b/src/component/dataZoom/DataZoomModel.ts index 52d93dc3a3..e5981559e6 100644 --- a/src/component/dataZoom/DataZoomModel.ts +++ b/src/component/dataZoom/DataZoomModel.ts @@ -496,9 +496,22 @@ class DataZoomModel extends Compon setCalculatedRange(opt: RangeOption): void { const option = this.option; - each(['start', 'startValue', 'end', 'endValue'] as const, function (name) { - (option as any)[name] = opt[name]; - }); + const settledOption = this.settledOption; + // A representative axis cannot supply value inputs for every target axis. + // Keep value-mode bounds (including unspecified bounds) so getOption can + // recover each axis independently, even when the representative range is clamped. + each([['start', 'startValue'], ['end', 'endValue']] as const, function (names, index) { + if (this._rangePropMode[index] === 'value') { + option[names[0]] = null; + option[names[1]] = settledOption[names[1]]; + } + else { + option[names[0]] = opt[names[0]]; + option[names[1]] = opt[names[1]]; + } + }, this); + // Actions can change the mode without changing the user-specified rangeMode. + option.rangeMode = this.getRangePropMode(); } getPercentRange(): number[] { diff --git a/src/component/dataZoom/dataZoomProcessor.ts b/src/component/dataZoom/dataZoomProcessor.ts index 811b6a264d..ef3f823ea2 100644 --- a/src/component/dataZoom/dataZoomProcessor.ts +++ b/src/component/dataZoom/dataZoomProcessor.ts @@ -119,8 +119,8 @@ const dataZoomProcessor: StageHandler = { }); ecModel.eachComponent('dataZoom', function (dataZoomModel: DataZoomModel) { - // Fullfill all of the range props so that user - // is able to get them from chart.getOption(). + // Update the output range while preserving the value-mode inputs + // needed to recover all target axes from chart.getOption(). const axisProxy = dataZoomModel.findRepresentativeAxisProxy(); if (axisProxy) { const {percent, value} = axisProxy.getWindow(); diff --git a/test/dataZoom-range-recovery.html b/test/dataZoom-range-recovery.html new file mode 100644 index 0000000000..9c116d0746 --- /dev/null +++ b/test/dataZoom-range-recovery.html @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + diff --git a/test/ut/spec/component/dataZoom/rangeRoundTrip.test.ts b/test/ut/spec/component/dataZoom/rangeRoundTrip.test.ts new file mode 100644 index 0000000000..3d40190f08 --- /dev/null +++ b/test/ut/spec/component/dataZoom/rangeRoundTrip.test.ts @@ -0,0 +1,139 @@ + +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { createChart, getECModel } from '../../../core/utHelper'; +import { EChartsType } from '../../../../../src/echarts'; +import { EChartsOption } from '../../../../../src/export/option'; +import { AxisBaseModel } from '../../../../../src/coord/AxisBaseModel'; + + +describe('dataZoom range recovery', function () { + const charts: EChartsType[] = []; + + function create(option: Parameters[0]): EChartsType { + const chart = createChart({width: 500, height: 400}); + charts.push(chart); + chart.setOption(option); + return chart; + } + + function makeOption(swapped = false): EChartsOption { + const bounds = swapped ? [300, 100] : [100, 300]; + return { + animation: false, + grid: {left: 50, right: 50, top: 50, bottom: 50}, + xAxis: {type: 'value', min: 0, max: 1}, + yAxis: bounds.map(function (bound, index) { + return {min: -bound, max: bound, position: index ? 'right' : 'left'}; + }), + dataZoom: { + type: 'inside', yAxisIndex: 'all', + rangeMode: ['value', 'value'], filterMode: 'none' + }, + series: [{type: 'line', yAxisIndex: swapped ? 0 : 1, data: [[0, -300], [1, 300]]}] + }; + } + + function expectRange(chart: EChartsType, index: number, extent: number[]): void { + const axis = (getECModel(chart).getComponent('yAxis', index) as AxisBaseModel).axis; + expect(axis.scale.getExtent()).toEqual(extent); + expect(chart.convertToPixel({yAxisIndex: index}, extent[0])).toBeCloseTo(350); + expect(chart.convertToPixel({yAxisIndex: index}, extent[1])).toBeCloseTo(50); + } + + afterEach(function () { + charts.forEach(function (chart) { chart.dispose(); }); + charts.length = 0; + }); + + it('recovers unchanged options repeatedly without sharing calculated value bounds', function () { + let chart = create(makeOption()); + for (let i = 0; i < 3; i++) { + expectRange(chart, 0, [-100, 100]); + expectRange(chart, 1, [-300, 300]); + chart = create(chart.getOption()); + } + expectRange(chart, 1, [-300, 300]); + }); + + it('preserves axis bounds when adding a series with full option replacement', function () { + const option = makeOption(); + const series = option.series; + option.series = []; + const chart = create(option); + const replacement = chart.getOption(); + replacement.series = series; + chart.setOption(replacement, {notMerge: true}); + expectRange(chart, 0, [-100, 100]); + expectRange(chart, 1, [-300, 300]); + }); + + it('also recovers unequal axes in the opposite order', function () { + const chart = create(create(makeOption(true)).getOption()); + expectRange(chart, 0, [-300, 300]); + expectRange(chart, 1, [-100, 100]); + }); + + it('keeps explicit value bounds beyond the representative axis extent', function () { + const option = makeOption(); + option.dataZoom = { + type: 'inside', yAxisIndex: 'all', filterMode: 'none', + rangeMode: ['value', 'value'], startValue: -200, endValue: 200 + }; + const chart = create(option); + expectRange(chart, 0, [-100, 100]); + expectRange(chart, 1, [-200, 200]); + const recovered = create(chart.getOption()); + expectRange(recovered, 0, [-100, 100]); + expectRange(recovered, 1, [-200, 200]); + }); + + it('recovers percent zoom actions after starting in value mode', function () { + const chart = create(makeOption()); + chart.dispatchAction({type: 'dataZoom', start: 25, end: 75}); + expectRange(chart, 0, [-50, 50]); + expectRange(chart, 1, [-150, 150]); + const recovered = create(chart.getOption()); + expectRange(recovered, 0, [-50, 50]); + expectRange(recovered, 1, [-150, 150]); + }); + + it('recovers mixed modes with an unspecified value boundary', function () { + const option = makeOption(); + option.dataZoom = { + type: 'inside', yAxisIndex: 'all', filterMode: 'none', + rangeMode: ['value', 'percent'], end: 75 + }; + const chart = create(option); + expectRange(chart, 0, [-100, 50]); + expectRange(chart, 1, [-300, 150]); + const recovered = create(chart.getOption()); + expectRange(recovered, 0, [-100, 50]); + expectRange(recovered, 1, [-300, 150]); + }); + + it('recovers explicit value zoom actions without turning them into percentages', function () { + const chart = create(makeOption()); + chart.dispatchAction({type: 'dataZoom', startValue: -50, endValue: 50}); + const recovered = create(chart.getOption()); + expectRange(recovered, 0, [-50, 50]); + expectRange(recovered, 1, [-50, 50]); + }); +}); From 9648e2620c7a038bce484d87e65d5eeffea5f776 Mon Sep 17 00:00:00 2001 From: magiccwarss Date: Sat, 7 Nov 2026 22:01:57 +0400 Subject: [PATCH 2/2] test(dataZoom): distinguish accepted inputs from calculated ranges --- test/dataZoom-action.html | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/test/dataZoom-action.html b/test/dataZoom-action.html index 908866cacb..b176ae8368 100644 --- a/test/dataZoom-action.html +++ b/test/dataZoom-action.html @@ -170,10 +170,14 @@ console.log('!!! ' + ctx.hint + ' !!!\n' + '(from chart.getOption())\n' + JSON.stringify(states, null, 2)); validateStates(states, btnDef, 'GET_OPTION'); + validateStates([0, 1].map(function (idx) { + var range = ctx.chart.getModel().getComponent('dataZoom', idx).getValueRange(); + return {startValue: range[0], endValue: range[1]}; + }), btnDef, 'VALUE_RANGE'); } function validateStates(actualStates, btnDef, btnDefExpectKey) { - if (!btnDef.expect) { + if (!btnDef.expect || !btnDef.expect[btnDefExpectKey]) { return; } testHelper.printAssert(ctx.chart, function (assert) { @@ -228,6 +232,16 @@ }) }); ctx.valueButtons.forEach(function (item) { + if (item.expect && item.expect.GET_OPTION) { + item.expect.VALUE_RANGE = item.expect.GET_OPTION; + item.expect.GET_OPTION = [{ + startValue: item.startValue, + endValue: item.endValue + }, { + startValue: item.startValue, + endValue: item.endValue + }]; + } buttons.push({ text: item.text, onclick: function () { @@ -832,6 +846,11 @@ + '"(' + special[1] + ') ' + extra; } + function getInputTimestamp(value) { + var parts = fmt2Str(value).split('-'); + return +new Date(+parts[0], +parts[1] - 1, +parts[2]); + } + var ctx = { hint: 'time axis value should be integer', percentButttons: getDefaultPercentButtons({minSpan: undefined, maxSpan: undefined}), @@ -841,11 +860,11 @@ endValue: fmt2Str(specialNormal[1]), expect: { GET_OPTION: [{ - startValue: specialNormal[0], - endValue: specialNormal[1], + startValue: getInputTimestamp(specialNormal[0]), + endValue: getInputTimestamp(specialNormal[1]), }, { - startValue: specialNormal[0], - endValue: specialNormal[1], + startValue: getInputTimestamp(specialNormal[0]), + endValue: getInputTimestamp(specialNormal[1]), }] } }, { @@ -855,8 +874,10 @@ expect: { GET_OPTION: (function () { const startValue = xDataMin; + const inputSpan = getInputTimestamp(specialOverXDataMin[1]) + - getInputTimestamp(specialOverXDataMin[0]); const endValue = Math.min( - xDataMin + (specialOverXDataMin[1] - specialOverXDataMin[0]), + xDataMin + inputSpan, xDataMax ); return [{ @@ -875,8 +896,10 @@ expect: { GET_OPTION: (function () { const endValue = xDataMax; + const inputSpan = getInputTimestamp(specialOverXDataMax[1]) + - getInputTimestamp(specialOverXDataMax[0]); const startValue = Math.max( - xDataMax - (specialOverXDataMax[1] - specialOverXDataMax[0]), + xDataMax - inputSpan, xDataMin ); return [{ @@ -1076,4 +1099,4 @@ - \ No newline at end of file +