This is an automated email from the ASF dual-hosted git repository. sushuang pushed a commit to branch PR/plainheart_fix/alignTicks-precision in repository https://gitbox.apache.org/repos/asf/echarts.git
commit d168bf237a442c2254fd494d6ae76e4e4712deff Author: 100pah <[email protected]> AuthorDate: Fri Jan 9 16:44:16 2026 +0800 fix(axisTick&dataZoom): (1) Apply a better auto-precision method. (2) Make the rounding result consistent between dataZoom calculated window and specified axis `determinedMin/Max`. (3) Fix unexpected behaviors when dataZoom controls axes with `alignTicks: true` - previous they are not precisely aligned and the ticks jump significantly due to inappropriate rounding when dataZoom dragging. --- src/component/dataZoom/AxisProxy.ts | 202 ++++++++++++++++++---------- src/component/dataZoom/DataZoomModel.ts | 26 ++-- src/component/dataZoom/SliderZoomView.ts | 58 ++++---- src/component/dataZoom/dataZoomProcessor.ts | 31 +++-- src/component/dataZoom/helper.ts | 26 +++- src/component/helper/sliderMove.ts | 19 ++- src/component/toolbox/feature/DataZoom.ts | 39 ++++-- src/coord/Axis.ts | 15 +-- 8 files changed, 270 insertions(+), 146 deletions(-) diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index 11d5f6c74..cabe1db8c 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -17,13 +17,15 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; -import * as numberUtil from '../../util/number'; +import {clone, defaults, each, map} from 'zrender/src/core/util'; +import { + asc, getAcceptableTickPrecision, linearMap, mathAbs, mathCeil, mathFloor, mathMax, mathMin, round +} from '../../util/number'; import sliderMove from '../helper/sliderMove'; import GlobalModel from '../../model/Global'; import SeriesModel from '../../model/Series'; import ExtensionAPI from '../../core/ExtensionAPI'; -import { Dictionary } from '../../util/types'; +import { Dictionary, NullUndefined } from '../../util/types'; // TODO Polar? import DataZoomModel from './DataZoomModel'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; @@ -31,9 +33,8 @@ import { unionAxisExtentFromData } from '../../coord/axisHelper'; import { ensureScaleRawExtentInfo } from '../../coord/scaleRawExtentInfo'; import { getAxisMainType, isCoordSupported, DataZoomAxisDimension } from './helper'; import { SINGLE_REFERRING } from '../../util/model'; +import { isOrdinalScale, isTimeScale } from '../../scale/helper'; -const each = zrUtil.each; -const asc = numberUtil.asc; interface MinMaxSpan { minSpan: number @@ -42,6 +43,17 @@ interface MinMaxSpan { maxValueSpan: number } +interface AxisProxyWindow { + value: [number, number]; + percent: [number, number]; + // Percent invert from "value window", which may be slightly different from "percent window" due to some + // handling such as rounding. The difference may be magnified in cases like "alignTicks", so we use + // `percentInverted` in these cases. + // But we retain the original input percent in `percent` whenever possible, since they have been used in views. + percentInverted: [number, number]; + valuePrecision: number; +} + /** * Operate single axis. * One axis can only operated by one axis operator. @@ -56,13 +68,16 @@ class AxisProxy { private _dimName: DataZoomAxisDimension; private _axisIndex: number; - private _valueWindow: [number, number]; - private _percentWindow: [number, number]; + private _window: AxisProxyWindow; private _dataExtent: [number, number]; private _minMaxSpan: MinMaxSpan; + /** + * The host `dataZoom` model. An axis may be controlled by multiple `dataZoom`s, + * but only the first declared `dataZoom` is the host. + */ private _dataZoomModel: DataZoomModel; constructor( @@ -94,17 +109,10 @@ class AxisProxy { } /** - * @return Value can only be NaN or finite value. + * @return `getWindow().value` can only have NaN or finite value. */ - getDataValueWindow() { - return this._valueWindow.slice() as [number, number]; - } - - /** - * @return {Array.<number>} - */ - getDataPercentWindow() { - return this._percentWindow.slice() as [number, number]; + getWindow(): AxisProxyWindow { + return clone(this._window); } getTargetSeriesModels() { @@ -128,26 +136,31 @@ class AxisProxy { } getMinMaxSpan() { - return zrUtil.clone(this._minMaxSpan); + return clone(this._minMaxSpan); } /** + * [CAVEAT] Keep this method pure, so that it can be called multiple times. + * * Only calculate by given range and this._dataExtent, do not change anything. */ - calculateDataWindow(opt?: { - start?: number - end?: number - startValue?: number | string | Date - endValue?: number | string | Date - }) { + calculateDataWindow( + opt: { + start?: number // percent, 0 ~ 100 + end?: number // percent, 0 ~ 100 + startValue?: number | string | Date + endValue?: number | string | Date + } + ): AxisProxyWindow { const dataExtent = this._dataExtent; - const axisModel = this.getAxisModel(); - const scale = axisModel.axis.scale; + const axis = this.getAxisModel().axis; + const scale = axis.scale; const rangePropMode = this._dataZoomModel.getRangePropMode(); const percentExtent = [0, 100]; const percentWindow = [] as unknown as [number, number]; const valueWindow = [] as unknown as [number, number]; let hasPropModeValue; + const needRound = [false, false]; each(['start', 'end'] as const, function (prop, idx) { let boundPercent = opt[prop]; @@ -169,24 +182,19 @@ class AxisProxy { if (rangePropMode[idx] === 'percent') { boundPercent == null && (boundPercent = percentExtent[idx]); - // Use scale.parse to math round for category or time axis. - boundValue = scale.parse(numberUtil.linearMap( - boundPercent, percentExtent, dataExtent - )); + boundValue = linearMap(boundPercent, percentExtent, dataExtent); + needRound[idx] = true; } else { hasPropModeValue = true; + // NOTE: `scale.parse` can also round input for 'time' or 'ordinal' scale. boundValue = boundValue == null ? dataExtent[idx] : scale.parse(boundValue); // Calculating `percent` from `value` may be not accurate, because - // This calculation can not be inversed, because all of values that + // This calculation can not be inverted, because all of values that // are overflow the `dataExtent` will be calculated to percent '100%' - boundPercent = numberUtil.linearMap( - boundValue, dataExtent, percentExtent - ); + boundPercent = linearMap(boundValue, dataExtent, percentExtent); } - // valueWindow[idx] = round(boundValue); - // percentWindow[idx] = round(boundPercent); // fallback to extent start/end when parsed value or percent is invalid valueWindow[idx] = boundValue == null || isNaN(boundValue) ? dataExtent[idx] @@ -199,11 +207,17 @@ class AxisProxy { asc(valueWindow); asc(percentWindow); - // The windows from user calling of `dispatchAction` might be out of the extent, - // or do not obey the `min/maxSpan`, `min/maxValueSpan`. But we don't restrict window - // by `zoomLock` here, because we see `zoomLock` just as a interaction constraint, - // where API is able to initialize/modify the window size even though `zoomLock` - // specified. + // The windows specified from `dispatchAction` or `setOption` may: + // (1) be out of the extent, or + // (2) do not comply with `minSpan/maxSpan`, `minValueSpan/maxValueSpan`. + // So we clamp them here. + // But we don't restrict window by `zoomLock` here, because we see `zoomLock` just as a + // interaction constraint, where API is able to initialize/modify the window size even + // though `zoomLock` specified. + // PENDING: For historical reason, the option design is partially incompatible: + // If `option.start` and `option.endValue` are specified, and when we choose whether + // `min/maxValueSpan` or `minSpan/maxSpan` is applied, neither one is intuitive. + // (Currently using `minValueSpan/maxValueSpan`.) const spans = this._minMaxSpan; hasPropModeValue ? restrictSet(valueWindow, percentWindow, dataExtent, percentExtent, false) @@ -223,14 +237,67 @@ class AxisProxy { spans['max' + suffix as 'maxSpan' | 'maxValueSpan'] ); for (let i = 0; i < 2; i++) { - toWindow[i] = numberUtil.linearMap(fromWindow[i], fromExtent, toExtent, true); - toValue && (toWindow[i] = scale.parse(toWindow[i])); + toWindow[i] = linearMap(fromWindow[i], fromExtent, toExtent, true); + if (toValue) { + toWindow[i] = toWindow[i]; + needRound[i] = true; + } + } + simplyEnsureAsc(toWindow); + } + + // - In 'time' and 'ordinal' scale, rounding by 0 is required. + // - In 'interval' and 'log' scale, we round values for acceptable display with acceptable accuracy loose. + // "Values" can be rounded only if they are generated from `percent`, since user-specified "value" + // should be respected, and `DataZoomSelect` already performs its own rounding. + // - Currently we only round "value" but not "percent", since there is no need so far. + // - MEMO: See also #3228 and commit a89fd0d7f1833ecf08a4a5b7ecf651b4a0d8da41 + // - PENDING: The rounding result may slightly overflow the restriction from `min/maxSpan`, + // but it is acceptable so far. + const isScaleOrdinalOrTime = isOrdinalScale(scale) || isTimeScale(scale); + // Typically pxExtent has been ready in coordSys create. (See `create` of `Grid.ts`) + const pxExtent = axis.getExtent(); + // NOTICE: this pxSpan may be not accurate yet due to "outerBounds" logic, but acceptable. + const pxSpan = mathAbs(pxExtent[1] - pxExtent[0]); + const precision = isScaleOrdinalOrTime + ? 0 + : getAcceptableTickPrecision(valueWindow[1] - valueWindow[0], pxSpan, 0.5); + each([[0, mathCeil], [1, mathFloor]] as const, function ([idx, ceilOrFloor]) { + if (!needRound[idx] || !isFinite(precision)) { + return; + } + valueWindow[idx] = round(valueWindow[idx], precision); + valueWindow[idx] = mathMin(dataExtent[1], mathMax(dataExtent[0], valueWindow[idx])); // Clamp. + if (percentWindow[idx] === percentExtent[idx]) { + // When `percent` is 0 or 100, `value` must be `dataExtent[0]` or `dataExtent[1]` + // regardless of the calculated precision. + // NOTE: `percentWindow` is never over [0, 100] at this moment. + valueWindow[idx] = dataExtent[idx]; + if (isScaleOrdinalOrTime) { + // In case that dataExtent[idx] is not an integer (may occur since it comes from user input) + valueWindow[idx] = ceilOrFloor(valueWindow[idx]); + } + } + }); + simplyEnsureAsc(valueWindow); + + const percentInvertedWindow = [ + linearMap(valueWindow[0], dataExtent, percentExtent, true), + linearMap(valueWindow[1], dataExtent, percentExtent, true), + ] as [number, number]; + simplyEnsureAsc(percentInvertedWindow); + + function simplyEnsureAsc(window: number[]): void { + if (window[0] > window[1]) { + window[0] = window[1]; } } return { - valueWindow: valueWindow, - percentWindow: percentWindow + value: valueWindow, + percent: percentWindow, + percentInverted: percentInvertedWindow, + valuePrecision: precision, }; } @@ -239,8 +306,8 @@ class AxisProxy { * so it is recommended to be called in "process stage" but not "model init * stage". */ - reset(dataZoomModel: DataZoomModel) { - if (dataZoomModel !== this._dataZoomModel) { + reset(dataZoomModel: DataZoomModel, alignToPercentInverted: [number, number] | NullUndefined) { + if (!this.hostedBy(dataZoomModel)) { return; } @@ -251,24 +318,28 @@ class AxisProxy { // `calculateDataWindow` uses min/maxSpan. this._updateMinMaxSpan(); - const dataWindow = this.calculateDataWindow(dataZoomModel.settledOption); - - this._valueWindow = dataWindow.valueWindow; - this._percentWindow = dataWindow.percentWindow; + let opt = dataZoomModel.settledOption; + if (alignToPercentInverted) { + opt = defaults({ + start: alignToPercentInverted[0], + end: alignToPercentInverted[1], + }, opt); + } + this._window = this.calculateDataWindow(opt); // Update axis setting then. this._setAxisModel(); } filterData(dataZoomModel: DataZoomModel, api: ExtensionAPI) { - if (dataZoomModel !== this._dataZoomModel) { + if (!this.hostedBy(dataZoomModel)) { return; } const axisDim = this._dimName; const seriesModels = this.getTargetSeriesModels(); const filterMode = dataZoomModel.get('filterMode'); - const valueWindow = this._valueWindow; + const valueWindow = this._window.value; if (filterMode === 'none') { return; @@ -305,7 +376,7 @@ class AxisProxy { if (filterMode === 'weakFilter') { const store = seriesData.getStore(); - const dataDimIndices = zrUtil.map(dataDims, dim => seriesData.getDimensionIndex(dim), seriesData); + const dataDimIndices = map(dataDims, dim => seriesData.getDimensionIndex(dim), seriesData); seriesData.filterSelf(function (dataIndex) { let leftOut; let rightOut; @@ -368,12 +439,12 @@ class AxisProxy { // minValueSpan and maxValueSpan has higher priority than minSpan and maxSpan if (valueSpan != null) { - percentSpan = numberUtil.linearMap( + percentSpan = linearMap( dataExtent[0] + valueSpan, dataExtent, [0, 100], true ); } else if (percentSpan != null) { - valueSpan = numberUtil.linearMap( + valueSpan = linearMap( percentSpan, [0, 100], dataExtent, true ) - dataExtent[0]; } @@ -387,27 +458,22 @@ class AxisProxy { const axisModel = this.getAxisModel(); - const percentWindow = this._percentWindow; - const valueWindow = this._valueWindow; - - if (!percentWindow) { + const window = this._window; + if (!window) { return; } - - // [0, 500]: arbitrary value, guess axis extent. - let precision = numberUtil.getPixelPrecision(valueWindow, [0, 500]); - precision = Math.min(precision, 20); + const {percent, value} = window; // For value axis, if min/max/scale are not set, we just use the extent obtained // by series data, which may be a little different from the extent calculated by // `axisHelper.getScaleExtent`. But the different just affects the experience a // little when zooming. So it will not be fixed until some users require it strongly. const rawExtentInfo = axisModel.axis.scale.rawExtentInfo; - if (percentWindow[0] !== 0) { - rawExtentInfo.setDeterminedMinMax('min', +valueWindow[0].toFixed(precision)); + if (percent[0] !== 0) { + rawExtentInfo.setDeterminedMinMax('min', value[0]); } - if (percentWindow[1] !== 100) { - rawExtentInfo.setDeterminedMinMax('max', +valueWindow[1].toFixed(precision)); + if (percent[1] !== 100) { + rawExtentInfo.setDeterminedMinMax('max', value[1]); } rawExtentInfo.freeze(); } diff --git a/src/component/dataZoom/DataZoomModel.ts b/src/component/dataZoom/DataZoomModel.ts index 279c06a13..c83e166cb 100644 --- a/src/component/dataZoom/DataZoomModel.ts +++ b/src/component/dataZoom/DataZoomModel.ts @@ -23,13 +23,15 @@ import ComponentModel from '../../model/Component'; import { LayoutOrient, ComponentOption, - LabelOption + LabelOption, + NullUndefined } from '../../util/types'; import Model from '../../model/Model'; import GlobalModel from '../../model/Global'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; import { - getAxisMainType, DATA_ZOOM_AXIS_DIMENSIONS, DataZoomAxisDimension + getAxisMainType, DATA_ZOOM_AXIS_DIMENSIONS, DataZoomAxisDimension, + getAxisProxyFromModel } from './helper'; import SingleAxisModel from '../../coord/single/AxisModel'; import { MULTIPLE_REFERRING, SINGLE_REFERRING, ModelFinderIndexQuery, ModelFinderIdQuery } from '../../util/model'; @@ -131,15 +133,11 @@ export interface DataZoomOption extends ComponentOption { type RangeOption = Pick<DataZoomOption, 'start' | 'end' | 'startValue' | 'endValue'>; -export type DataZoomExtendedAxisBaseModel = AxisBaseModel & { - __dzAxisProxy: AxisProxy -}; - class DataZoomAxisInfo { indexList: number[] = []; indexMap: boolean[] = []; - add(axisCmptIdx: number) { + add(axisCmptIdx: ComponentModel['componentIndex']): void { // Remove duplication. if (!this.indexMap[axisCmptIdx]) { this.indexList.push(axisCmptIdx); @@ -456,10 +454,7 @@ class DataZoomModel<Opts extends DataZoomOption = DataZoomOption> extends Compon * @return If not found, return null/undefined. */ getAxisProxy(axisDim: DataZoomAxisDimension, axisIndex: number): AxisProxy { - const axisModel = this.getAxisModel(axisDim, axisIndex); - if (axisModel) { - return (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy; - } + return getAxisProxyFromModel(this.getAxisModel(axisDim, axisIndex)); } /** @@ -510,7 +505,7 @@ class DataZoomModel<Opts extends DataZoomOption = DataZoomOption> extends Compon getPercentRange(): number[] { const axisProxy = this.findRepresentativeAxisProxy(); if (axisProxy) { - return axisProxy.getDataPercentWindow(); + return axisProxy.getWindow().percent; } } @@ -523,11 +518,11 @@ class DataZoomModel<Opts extends DataZoomOption = DataZoomOption> extends Compon if (axisDim == null && axisIndex == null) { const axisProxy = this.findRepresentativeAxisProxy(); if (axisProxy) { - return axisProxy.getDataValueWindow(); + return axisProxy.getWindow().value; } } else { - return this.getAxisProxy(axisDim, axisIndex).getDataValueWindow(); + return this.getAxisProxy(axisDim, axisIndex).getWindow().value; } } @@ -537,7 +532,7 @@ class DataZoomModel<Opts extends DataZoomOption = DataZoomOption> extends Compon */ findRepresentativeAxisProxy(axisModel?: AxisBaseModel): AxisProxy { if (axisModel) { - return (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy; + return getAxisProxyFromModel(axisModel); } // Find the first hosted axisProxy @@ -576,6 +571,7 @@ class DataZoomModel<Opts extends DataZoomOption = DataZoomOption> extends Compon } } + /** * Retrieve those raw params from option, which will be cached separately, * because they will be overwritten by normalized/calculated values in the main diff --git a/src/component/dataZoom/SliderZoomView.ts b/src/component/dataZoom/SliderZoomView.ts index 30422c9c5..ad82f0fca 100644 --- a/src/component/dataZoom/SliderZoomView.ts +++ b/src/component/dataZoom/SliderZoomView.ts @@ -22,7 +22,7 @@ import * as eventTool from 'zrender/src/core/event'; import * as graphic from '../../util/graphic'; import * as throttle from '../../util/throttle'; import DataZoomView from './DataZoomView'; -import { linearMap, asc, parsePercent } from '../../util/number'; +import { linearMap, asc, parsePercent, round } from '../../util/number'; import * as layout from '../../util/layout'; import sliderMove from '../helper/sliderMove'; import GlobalModel from '../../model/Global'; @@ -35,7 +35,7 @@ import { RectLike } from 'zrender/src/core/BoundingRect'; import Axis from '../../coord/Axis'; import SeriesModel from '../../model/Series'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; -import { getAxisMainType, collectReferCoordSysModelInfo } from './helper'; +import { getAxisMainType, collectReferCoordSysModelInfo, getAlignTo } from './helper'; import { enableHoverEmphasis } from '../../util/states'; import { createSymbol, symbolBuildProxies } from '../../util/symbol'; import { deprecateLog } from '../../util/log'; @@ -44,6 +44,8 @@ import Displayable from 'zrender/src/graphic/Displayable'; import { createTextStyle } from '../../label/labelStyle'; import SeriesData from '../../data/SeriesData'; import tokens from '../../visual/tokens'; +import type AxisProxy from './AxisProxy'; +import { isOrdinalScale, isTimeScale } from '../../scale/helper'; const Rect = graphic.Rect; @@ -816,30 +818,35 @@ class SliderZoomView extends DataZoomView { private _updateDataInfo(nonRealtime?: boolean) { const dataZoomModel = this.dataZoomModel; - const displaybles = this._displayables; - const handleLabels = displaybles.handleLabels; + const displayables = this._displayables; + const handleLabels = displayables.handleLabels; const orient = this._orient; let labelTexts = ['', '']; - // FIXME - // date型,支持formatter,autoformatter(ec2 date.getAutoFormatter) if (dataZoomModel.get('showDetail')) { const axisProxy = dataZoomModel.findRepresentativeAxisProxy(); if (axisProxy) { - const axis = axisProxy.getAxisModel().axis; const range = this._range; - const dataInterval = nonRealtime + let dataInterval: [number, number]; + if (nonRealtime) { // See #4434, data and axis are not processed and reset yet in non-realtime mode. - ? axisProxy.calculateDataWindow({ - start: range[0], end: range[1] - }).valueWindow - : axisProxy.getDataValueWindow(); + let calcWinInput = {start: range[0], end: range[1]}; + const alignTo = getAlignTo(dataZoomModel, axisProxy); + if (alignTo) { + const alignToWindow = alignTo.calculateDataWindow(calcWinInput).percentInverted; + calcWinInput = {start: alignToWindow[0], end: alignToWindow[1]}; + } + dataInterval = axisProxy.calculateDataWindow(calcWinInput).value; + } + else { + dataInterval = axisProxy.getWindow().value; + } labelTexts = [ - this._formatLabel(dataInterval[0], axis), - this._formatLabel(dataInterval[1], axis) + this._formatLabel(dataInterval[0], axisProxy), + this._formatLabel(dataInterval[1], axisProxy) ]; } } @@ -854,7 +861,7 @@ class SliderZoomView extends DataZoomView { // Text should not transform by barGroup. // Ignore handlers transform const barTransform = graphic.getTransform( - displaybles.handles[handleIndex].parent, this.group + displayables.handles[handleIndex].parent, this.group ); const direction = graphic.transformDirection( handleIndex === 0 ? 'right' : 'left', barTransform @@ -877,27 +884,26 @@ class SliderZoomView extends DataZoomView { } } - private _formatLabel(value: ParsedValue, axis: Axis) { + private _formatLabel(value: number, axisProxy: AxisProxy) { const dataZoomModel = this.dataZoomModel; const labelFormatter = dataZoomModel.get('labelFormatter'); let labelPrecision = dataZoomModel.get('labelPrecision'); if (labelPrecision == null || labelPrecision === 'auto') { - labelPrecision = axis.getPixelPrecision(); + labelPrecision = axisProxy.getWindow().valuePrecision; } - const valueStr = (value == null || isNaN(value as number)) + const scale = axisProxy.getAxisModel().axis.scale; + const valueStr = (value == null || isNaN(value)) ? '' - // FIXME Glue code - : (axis.type === 'category' || axis.type === 'time') - ? axis.scale.getLabel({ - value: Math.round(value as number) - }) - // param of toFixed should less then 20. - : (value as number).toFixed(Math.min(labelPrecision as number, 20)); + : (isOrdinalScale(scale) || isTimeScale(scale)) + ? scale.getLabel({value: Math.round(value)}) + : isFinite(labelPrecision) + ? round(value, labelPrecision, true) + : value + ''; return isFunction(labelFormatter) - ? labelFormatter(value as number, valueStr) + ? labelFormatter(value, valueStr) : isString(labelFormatter) ? labelFormatter.replace('{value}', valueStr) : valueStr; diff --git a/src/component/dataZoom/dataZoomProcessor.ts b/src/component/dataZoom/dataZoomProcessor.ts index f511aaed3..d64269295 100644 --- a/src/component/dataZoom/dataZoomProcessor.ts +++ b/src/component/dataZoom/dataZoomProcessor.ts @@ -19,11 +19,12 @@ import {createHashMap, each} from 'zrender/src/core/util'; import SeriesModel from '../../model/Series'; -import DataZoomModel, { DataZoomExtendedAxisBaseModel } from './DataZoomModel'; -import { getAxisMainType, DataZoomAxisDimension } from './helper'; +import DataZoomModel from './DataZoomModel'; +import { getAxisMainType, DataZoomAxisDimension, DataZoomExtendedAxisBaseModel, getAlignTo } from './helper'; import AxisProxy from './AxisProxy'; import { StageHandler } from '../../util/types'; + const dataZoomProcessor: StageHandler = { // `dataZoomProcessor` will only be performed in needed series. Consider if @@ -54,7 +55,7 @@ const dataZoomProcessor: StageHandler = { }); const proxyList: AxisProxy[] = []; eachAxisModel(function (axisDim, axisIndex, axisModel, dataZoomModel) { - // Different dataZooms may constrol the same axis. In that case, + // Different dataZooms may control the same axis. In that case, // an axisProxy serves both of them. if (!axisModel.__dzAxisProxy) { // Use the first dataZoomModel as the main model of axisProxy. @@ -82,8 +83,19 @@ const dataZoomProcessor: StageHandler = { // We calculate window and reset axis here but not in model // init stage and not after action dispatch handler, because // reset should be called after seriesData.restoreData. + const axisProxyNeedAlign: [AxisProxy, AxisProxy][] = []; dataZoomModel.eachTargetAxis(function (axisDim, axisIndex) { - dataZoomModel.getAxisProxy(axisDim, axisIndex).reset(dataZoomModel); + const axisProxy = dataZoomModel.getAxisProxy(axisDim, axisIndex); + const alignToAxisProxy = getAlignTo(dataZoomModel, axisProxy); + if (alignToAxisProxy) { + axisProxyNeedAlign.push([axisProxy, alignToAxisProxy]); + } + else { + axisProxy.reset(dataZoomModel, null); + } + }); + each(axisProxyNeedAlign, function (item) { + item[0].reset(dataZoomModel, item[1].getWindow().percentInverted); }); // Caution: data zoom filtering is order sensitive when using @@ -110,14 +122,13 @@ const dataZoomProcessor: StageHandler = { // is able to get them from chart.getOption(). const axisProxy = dataZoomModel.findRepresentativeAxisProxy(); if (axisProxy) { - const percentRange = axisProxy.getDataPercentWindow(); - const valueRange = axisProxy.getDataValueWindow(); + const {percent, value} = axisProxy.getWindow(); dataZoomModel.setCalculatedRange({ - start: percentRange[0], - end: percentRange[1], - startValue: valueRange[0], - endValue: valueRange[1] + start: percent[0], + end: percent[1], + startValue: value[0], + endValue: value[1] }); } }); diff --git a/src/component/dataZoom/helper.ts b/src/component/dataZoom/helper.ts index 4b0a8a31e..e2009cb76 100644 --- a/src/component/dataZoom/helper.ts +++ b/src/component/dataZoom/helper.ts @@ -17,13 +17,14 @@ * under the License. */ -import { Payload } from '../../util/types'; +import { NullUndefined, Payload } from '../../util/types'; import GlobalModel from '../../model/Global'; import DataZoomModel from './DataZoomModel'; import { indexOf, createHashMap, assert, HashMap } from 'zrender/src/core/util'; import SeriesModel from '../../model/Series'; import { CoordinateSystemHostModel } from '../../coord/CoordinateSystem'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; +import type AxisProxy from './AxisProxy'; export interface DataZoomPayloadBatchItem { @@ -43,6 +44,10 @@ export interface DataZoomReferCoordSysInfo { axisModels: AxisBaseModel[]; } +export type DataZoomExtendedAxisBaseModel = AxisBaseModel & { + __dzAxisProxy: AxisProxy +}; + export const DATA_ZOOM_AXIS_DIMENSIONS = [ 'x', 'y', 'radius', 'angle', 'single' ] as const; @@ -205,3 +210,22 @@ export function collectReferCoordSysModelInfo(dataZoomModel: DataZoomModel): { return coordSysInfoWrap; } + +export function getAxisProxyFromModel(axisModel: AxisBaseModel): AxisProxy | NullUndefined { + return axisModel && (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy; +} + +/** + * NOTICE: If `axis_a` aligns to `axis_b`, but they are not controlled by + * the same `dataZoom`, do not consider `axis_b` as `alignTo` and + * then do not input it into `AxisProxy#reset`. + */ +export function getAlignTo(dataZoomModel: DataZoomModel, axisProxy: AxisProxy): AxisProxy | NullUndefined { + const alignToAxis = axisProxy.getAxisModel().axis.alignTo; + return ( + alignToAxis && dataZoomModel.getAxisProxy( + alignToAxis.dim as DataZoomAxisDimension, + alignToAxis.model.componentIndex + ) + ) ? getAxisProxyFromModel(alignToAxis.model) : null; +} diff --git a/src/component/helper/sliderMove.ts b/src/component/helper/sliderMove.ts index 902312d30..721f6704a 100644 --- a/src/component/helper/sliderMove.ts +++ b/src/component/helper/sliderMove.ts @@ -17,6 +17,8 @@ * under the License. */ +import { addSafe } from '../../util/number'; + /** * Calculate slider move result. * Usage: @@ -24,6 +26,9 @@ * maxSpan and the same as `Math.abs(handleEnd[1] - handleEnds[0])`. * (2) If handle0 is forbidden to cross handle1, set minSpan as `0`. * + * [CAVEAT] + * This method is inefficient due to the use of `addSafe`. + * * @param delta Move length. * @param handleEnds handleEnds[0] can be bigger then handleEnds[1]. * handleEnds will be modified in this method. @@ -48,7 +53,9 @@ export default function sliderMove( delta = delta || 0; - const extentSpan = extent[1] - extent[0]; + // Consider `7.1e-9 - 7e-9` get `1.0000000000000007e-10`, so use `addSafe` + // to remove rounding error whenever possible. + const extentSpan = addSafe(extent[1], -extent[0]); // Notice maxSpan and minSpan can be null/undefined. if (minSpan != null) { @@ -58,7 +65,7 @@ export default function sliderMove( maxSpan = Math.max(maxSpan, minSpan != null ? minSpan : 0); } if (handleIndex === 'all') { - let handleSpan = Math.abs(handleEnds[1] - handleEnds[0]); + let handleSpan = Math.abs(addSafe(handleEnds[1], -handleEnds[0])); handleSpan = restrict(handleSpan, [0, extentSpan]); minSpan = maxSpan = restrict(handleSpan, [minSpan, maxSpan]); handleIndex = 0; @@ -74,7 +81,9 @@ export default function sliderMove( // Restrict in extent. const extentMinSpan = minSpan || 0; const realExtent = extent.slice(); - originalDistSign.sign < 0 ? (realExtent[0] += extentMinSpan) : (realExtent[1] -= extentMinSpan); + originalDistSign.sign < 0 + ? (realExtent[0] = addSafe(realExtent[0], extentMinSpan)) + : (realExtent[1] = addSafe(realExtent[1], -extentMinSpan)); handleEnds[handleIndex] = restrict(handleEnds[handleIndex], realExtent); // Expand span. @@ -84,13 +93,13 @@ export default function sliderMove( currDistSign.sign !== originalDistSign.sign || currDistSign.span < minSpan )) { // If minSpan exists, 'cross' is forbidden. - handleEnds[1 - handleIndex] = handleEnds[handleIndex] + originalDistSign.sign * minSpan; + handleEnds[1 - handleIndex] = addSafe(handleEnds[handleIndex], originalDistSign.sign * minSpan); } // Shrink span. currDistSign = getSpanSign(handleEnds, handleIndex); if (maxSpan != null && currDistSign.span > maxSpan) { - handleEnds[1 - handleIndex] = handleEnds[handleIndex] + currDistSign.sign * maxSpan; + handleEnds[1 - handleIndex] = addSafe(handleEnds[handleIndex], currDistSign.sign * maxSpan); } return handleEnds; diff --git a/src/component/toolbox/feature/DataZoom.ts b/src/component/toolbox/feature/DataZoom.ts index e51ace23c..85a1727e8 100644 --- a/src/component/toolbox/feature/DataZoom.ts +++ b/src/component/toolbox/feature/DataZoom.ts @@ -35,9 +35,7 @@ import { Payload, Dictionary, ComponentOption, ItemStyleOption } from '../../../ import Cartesian2D from '../../../coord/cartesian/Cartesian2D'; import CartesianAxisModel from '../../../coord/cartesian/AxisModel'; import DataZoomModel from '../../dataZoom/DataZoomModel'; -import { - DataZoomPayloadBatchItem, DataZoomAxisDimension -} from '../../dataZoom/helper'; +import {DataZoomPayloadBatchItem} from '../../dataZoom/helper'; import { ModelFinderObject, ModelFinderIndexQuery, makeInternalComponentId, ModelFinderIdQuery, parseFinder, ParsedModelFinderKnown @@ -46,6 +44,8 @@ import ToolboxModel from '../ToolboxModel'; import { registerInternalOptionCreator } from '../../../model/internalComponentCreator'; import ComponentModel from '../../../model/Component'; import tokens from '../../../visual/tokens'; +import BoundingRect from 'zrender/src/core/BoundingRect'; +import { getAcceptableTickPrecision, round } from '../../../util/number'; const each = zrUtil.each; @@ -55,6 +55,9 @@ const DATA_ZOOM_ID_BASE = makeInternalComponentId('toolbox-dataZoom_'); const ICON_TYPES = ['zoom', 'back'] as const; type IconType = typeof ICON_TYPES[number]; +const XY2WH = {x: 'width', y: 'height'} as const; + + export interface ToolboxDataZoomFeatureOption extends ToolboxFeatureOption { type?: IconType[] icon?: {[key in IconType]?: string} @@ -135,15 +138,18 @@ class DataZoomFeature extends ToolboxFeature<ToolboxDataZoomFeatureOption> { return; } + const coordSysRect = coordSys.master.getRect().clone(); + const brushType = area.brushType; if (brushType === 'rect') { - setBatch('x', coordSys, (coordRange as BrushDimensionMinMax[])[0]); - setBatch('y', coordSys, (coordRange as BrushDimensionMinMax[])[1]); + setBatch('x', coordSys, coordSysRect, (coordRange as BrushDimensionMinMax[])[0]); + setBatch('y', coordSys, coordSysRect, (coordRange as BrushDimensionMinMax[])[1]); } else { setBatch( ({lineX: 'x', lineY: 'y'} as const)[brushType as 'lineX' | 'lineY'], coordSys, + coordSysRect, coordRange as BrushDimensionMinMax ); } @@ -153,29 +159,42 @@ class DataZoomFeature extends ToolboxFeature<ToolboxDataZoomFeatureOption> { this._dispatchZoomAction(snapshot); - function setBatch(dimName: DataZoomAxisDimension, coordSys: Cartesian2D, minMax: number[]) { + function setBatch( + dimName: 'x' | 'y', + coordSys: Cartesian2D, + coordSysRect: BoundingRect, + minMax: number[] + ) { const axis = coordSys.getAxis(dimName); const axisModel = axis.model; const dataZoomModel = findDataZoom(dimName, axisModel, ecModel); // Restrict range. const minMaxSpan = dataZoomModel.findRepresentativeAxisProxy(axisModel).getMinMaxSpan(); + const scaleExtent = axis.scale.getExtent(); if (minMaxSpan.minValueSpan != null || minMaxSpan.maxValueSpan != null) { minMax = sliderMove( - 0, minMax.slice(), axis.scale.getExtent(), 0, + 0, minMax.slice(), scaleExtent, 0, minMaxSpan.minValueSpan, minMaxSpan.maxValueSpan ); } + // Round for displayable. + const precision = getAcceptableTickPrecision( + scaleExtent[1] - scaleExtent[0], + coordSysRect[XY2WH[dimName]], + 0.5 + ); + dataZoomModel && (snapshot[dataZoomModel.id] = { dataZoomId: dataZoomModel.id, - startValue: minMax[0], - endValue: minMax[1] + startValue: isFinite(precision) ? round(minMax[0], precision) : minMax[0], + endValue: isFinite(precision) ? round(minMax[1], precision) : minMax[1] }); } function findDataZoom( - dimName: DataZoomAxisDimension, axisModel: CartesianAxisModel, ecModel: GlobalModel + dimName: 'x' | 'y', axisModel: CartesianAxisModel, ecModel: GlobalModel ): DataZoomModel { let found; ecModel.eachComponent({mainType: 'dataZoom', subType: 'select'}, function (dzModel: DataZoomModel) { diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index 6226e96b2..0390875e2 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -18,7 +18,7 @@ */ import {each, map} from 'zrender/src/core/util'; -import {linearMap, getPixelPrecision, round} from '../util/number'; +import {linearMap, round} from '../util/number'; import { createAxisTicks, createAxisLabels, @@ -80,6 +80,9 @@ class Axis { // `inverse` can be inferred by `extent` unless `extent[0] === extent[1]`. inverse: AxisBaseOption['inverse'] = false; + // Injected outside + alignTo: Axis; + constructor(dim: DimensionName, scale: Scale, extent: [number, number]) { this.dim = dim; @@ -111,16 +114,6 @@ class Axis { return this._extent.slice() as [number, number]; } - /** - * Get precision used for formatting - */ - getPixelPrecision(dataExtent?: [number, number]): number { - return getPixelPrecision( - dataExtent || this.scale.getExtent(), - this._extent - ); - } - /** * Set coord extent */ --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
