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 d47ea4ad7bea2aa8f595dcebf1454f1512dfeb98 Author: 100pah <[email protected]> AuthorDate: Thu Feb 19 21:04:10 2026 +0800 fix(dataZoom): Do not display values outside of effective extent. --- src/component/dataZoom/AxisProxy.ts | 50 +++++++++++++------- src/component/dataZoom/SliderZoomView.ts | 79 +++++++++++++++++++------------- src/coord/scaleRawExtentInfo.ts | 21 +++++++-- test/bar-overflow-plot2.html | 1 + 4 files changed, 98 insertions(+), 53 deletions(-) diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index 3bcf7a23a..fee554bcc 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -34,6 +34,7 @@ import { SINGLE_REFERRING } from '../../util/model'; import { isOrdinalScale, isTimeScale } from '../../scale/helper'; import { AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, scaleRawExtentInfoReallyCreate, + ScaleRawExtentResultForZoom, } from '../../coord/scaleRawExtentInfo'; import { suppressOnAxisZero } from '../../coord/axisHelper'; @@ -45,20 +46,20 @@ interface MinMaxSpan { maxValueSpan: number } -interface AxisProxyWindow { - value: [number, number]; - percent: [number, number]; +export interface AxisProxyWindow { + // NOTE: May include non-effective portion. + value: number[]; + noZoomEffMM: ScaleRawExtentResultForZoom['noZoomEffMM']; + percent: 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]; + percentInverted: number[]; valuePrecision: number; } /** - * NOTICE: Its lifetime is different from `Axis` instance. It is recreated in each run of "ec prepare". - * * Operate single axis. * One axis can only operated by one axis operator. * Different dataZoomModels may be defined to operate the same axis. @@ -69,12 +70,15 @@ class AxisProxy { ecModel: GlobalModel; + // NOTICE: The lifetime of `AxisProxy` instance is different from `Axis` instance. + // It is recreated in each run of "ec prepare". + private _dimName: DataZoomAxisDimension; private _axisIndex: number; private _window: AxisProxyWindow; - private _dataExtent: number[]; + private _extent: ScaleRawExtentResultForZoom; private _minMaxSpan: MinMaxSpan; @@ -156,7 +160,7 @@ class AxisProxy { endValue?: number | string | Date } ): AxisProxyWindow { - const dataExtent = this._dataExtent; + const {noZoomMapMM: dataExtent, noZoomEffMM} = this._extent; const axis = this.getAxisModel().axis; const scale = axis.scale; const dataZoomModel = this._dataZoomModel; @@ -167,13 +171,26 @@ class AxisProxy { let hasPropModeValue; const needRound = [false, false]; + // NOTE: + // The current percentage base calculation strategy: + // - If the window boundary is NOT at 0% or 100%, boundary values are derived from the raw extent + // (series data + axis.min/max; see `ScaleRawExtentInfo['makeForZoom']`). Any subsequent "nice" + // expansion are excluded. + // - If the window boundary is at 0% or 100%, the "nice"-expanded portion is included. + // Pros: + // - The effect may be preferable when users intend to quickly narrow down to data details, + // especially when "nice strategy" excessively expands the extent. + // - It simplifies the logic, otherwise, "nice strategy" would need to be applied twice (full window + // + current window). + // Cons: + // - This strategy causes jitter when switching dataZoom to/from 0%/100% (though generally acceptable). + each(['start', 'end'] as const, function (prop, idx) { let boundPercent = opt[prop]; let boundValue = opt[prop + 'Value' as 'startValue' | 'endValue']; - // Notice: dataZoom is based either on `percentProp` ('start', 'end') or - // on `valueProp` ('startValue', 'endValue'). (They are based on the data extent - // but not min/max of axis, which will be calculated by data window then). + // NOTE: dataZoom is based either on `percentProp` ('start', 'end') or + // on `valueProp` ('startValue', 'endValue'). // The former one is suitable for cases that a dataZoom component controls multiple // axes with different unit or extent, and the latter one is suitable for accurate // zoom by pixel (e.g., in dataZoomSelect). @@ -307,6 +324,7 @@ class AxisProxy { return { value: valueWindow, + noZoomEffMM: noZoomEffMM.slice(), percent: percentWindow, percentInverted: percentInvertedWindow, valuePrecision: precision, @@ -318,7 +336,7 @@ class AxisProxy { * so it is recommended to be called in "process stage" but not "model init * stage". */ - reset(dataZoomModel: DataZoomModel, alignToPercentInverted: [number, number] | NullUndefined) { + reset(dataZoomModel: DataZoomModel, alignToPercentInverted: number[] | NullUndefined) { if (!this.hostedBy(dataZoomModel)) { return; } @@ -338,7 +356,7 @@ class AxisProxy { suppressOnAxisZero(axis, {dz: true}); const rawExtentInfo = axis.scale.rawExtentInfo; - this._dataExtent = rawExtentInfo.makeForZoom(); + this._extent = rawExtentInfo.makeForZoom(); // `calculateDataWindow` uses min/maxSpan. this._updateMinMaxSpan(); @@ -441,7 +459,7 @@ class AxisProxy { } else { const range: Dictionary<[number, number]> = {}; - range[dim] = valueWindow; + range[dim] = valueWindow as [number, number]; // console.time('select'); seriesData.selectRange(range); @@ -451,7 +469,7 @@ class AxisProxy { } each(dataDims, function (dim) { - seriesData.setApproximateExtent(valueWindow, dim); + seriesData.setApproximateExtent(valueWindow as [number, number], dim); }); }); @@ -463,7 +481,7 @@ class AxisProxy { private _updateMinMaxSpan() { const minMaxSpan = this._minMaxSpan = {} as MinMaxSpan; const dataZoomModel = this._dataZoomModel; - const dataExtent = this._dataExtent; + const dataExtent = this._extent.noZoomMapMM; each(['min', 'max'], function (minMax) { let percentSpan = dataZoomModel.get(minMax + 'Span' as 'minSpan' | 'maxSpan'); diff --git a/src/component/dataZoom/SliderZoomView.ts b/src/component/dataZoom/SliderZoomView.ts index 9cc8a447f..ef8ddc4d2 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, round } from '../../util/number'; +import { linearMap, asc, parsePercent, round, mathMax, mathMin } from '../../util/number'; import * as layout from '../../util/layout'; import sliderMove from '../helper/sliderMove'; import GlobalModel from '../../model/Global'; @@ -44,8 +44,11 @@ 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'; +import { AxisProxyWindow } from './AxisProxy'; +import type Scale from '../../scale/Scale'; +import { SCALE_EXTENT_KIND_EFFECTIVE } from '../../scale/scaleMapper'; + const Rect = graphic.Rect; @@ -827,11 +830,12 @@ class SliderZoomView extends DataZoomView { if (dataZoomModel.get('showDetail')) { const axisProxy = dataZoomModel.findRepresentativeAxisProxy(); + const scale = axisProxy.getAxisModel().axis.scale; if (axisProxy) { const range = this._range; - let dataInterval: [number, number]; + let window: AxisProxyWindow; if (nonRealtime) { // See #4434, data and axis are not processed and reset yet in non-realtime mode. let calcWinInput = {start: range[0], end: range[1]}; @@ -840,15 +844,15 @@ class SliderZoomView extends DataZoomView { const alignToWindow = alignTo.calculateDataWindow(calcWinInput).percentInverted; calcWinInput = {start: alignToWindow[0], end: alignToWindow[1]}; } - dataInterval = axisProxy.calculateDataWindow(calcWinInput).value; + window = axisProxy.calculateDataWindow(calcWinInput); } else { - dataInterval = axisProxy.getWindow().value; + window = axisProxy.getWindow(); } labelTexts = [ - this._formatLabel(dataInterval[0], axisProxy), - this._formatLabel(dataInterval[1], axisProxy) + formatLabel(dataZoomModel, 0, window, scale), + formatLabel(dataZoomModel, 1, window, scale) ]; } } @@ -886,31 +890,6 @@ class SliderZoomView extends DataZoomView { } } - 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 = axisProxy.getWindow().valuePrecision; - } - - const scale = axisProxy.getAxisModel().axis.scale; - const valueStr = (value == null || isNaN(value)) - ? '' - : (isOrdinalScale(scale) || isTimeScale(scale)) - ? scale.getLabel({value: Math.round(value)}) - : isFinite(labelPrecision) - ? round(value, labelPrecision, true) - : value + ''; - - return isFunction(labelFormatter) - ? labelFormatter(value, valueStr) - : isString(labelFormatter) - ? labelFormatter.replace('{value}', valueStr) - : valueStr; - } - private _onOverDataInfoTriggerArea(isOver: boolean): void { this._isOverDataInfoTriggerArea = isOver; this._showDataInfo(isOver); @@ -1149,6 +1128,42 @@ class SliderZoomView extends DataZoomView { } +function formatLabel( + dataZoomModel: SliderZoomModel, + extentIdx: 0 | 1, + window: AxisProxyWindow, + scale: Scale +): string { + const labelFormatter = dataZoomModel.get('labelFormatter'); + + let labelPrecision = dataZoomModel.get('labelPrecision'); + if (labelPrecision == null || labelPrecision === 'auto') { + labelPrecision = window.valuePrecision; + } + + // Do not display values out of `SCALE_EXTENT_KIND_EFFECTIVE` - generally they are meaningless. + // For example, `scaleExtent[0]` is often `0`, and negative values are unlikely to be meaningful. + // That is, "nice" expansion and `SCALE_EXTENT_KIND_MAPPING` expansion are always not display in labels. + const value = (extentIdx ? mathMin : mathMax)( + window.value[extentIdx], + window.noZoomEffMM[extentIdx], + ); + + const valueStr = (value == null || isNaN(value)) + ? '' + : (isOrdinalScale(scale) || isTimeScale(scale)) + ? scale.getLabel({value: Math.round(value)}) + : isFinite(labelPrecision) + ? round(value, labelPrecision, true) + : value + ''; + + return isFunction(labelFormatter) + ? labelFormatter(value, valueStr) + : isString(labelFormatter) + ? labelFormatter.replace('{value}', valueStr) + : valueStr; +} + function getOtherDim(thisDim: 'x' | 'y' | 'radius' | 'angle' | 'single' | 'z') { // FIXME // 这个逻辑和getOtherAxis里一致,但是写在这里是否不好 diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index 3f64dcf6b..628ea1b1f 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -92,10 +92,14 @@ type ScaleRawExtentResultForContainShape = Pick< >; /** - * Return the min max before `dataZoom` applied for mapping. - * "mapping" means `SCALE_EXTENT_KIND_MAPPING`. + * Return the min max before `dataZoom` applied. */ -type ScaleRawExtentResultForZoom = number[]; +export type ScaleRawExtentResultForZoom = { + // "effective" means `SCALE_EXTENT_KIND_EFFECTIVE`. + noZoomEffMM: number[]; + // "mapping" means `SCALE_EXTENT_KIND_MAPPING`. + noZoomMapMM: number[]; +}; type ScaleRawExtentResultFinal = Pick< ScaleRawExtentInternal, @@ -343,7 +347,10 @@ export class ScaleRawExtentInfo { makeForZoom(): ScaleRawExtentResultForZoom { const internal = this._i; - return (internal.noZoomEffMMExp || internal.noZoomEffMM).slice(); + return { + noZoomEffMM: internal.noZoomEffMM.slice(), + noZoomMapMM: makeNoZoomMappingMM(internal), + }; } makeFinal(): ScaleRawExtentResultFinal { @@ -359,7 +366,7 @@ export class ScaleRawExtentInfo { needCrossZero: internal.needCrossZero, needToggleAxisInverse: internal.needToggleAxisInverse, effMM: noZoomEffMM.slice(), - mapMM: this.makeForZoom(), + mapMM: makeNoZoomMappingMM(internal), }; const effMM = result.effMM; const mapMM = result.mapMM; @@ -414,6 +421,10 @@ export class ScaleRawExtentInfo { } +function makeNoZoomMappingMM(internal: ScaleRawExtentInternal): number[] { + return (internal.noZoomEffMMExp || internal.noZoomEffMM).slice(); +} + /** * Should be called when a new extent is created or modified. */ diff --git a/test/bar-overflow-plot2.html b/test/bar-overflow-plot2.html index cec30e22f..c297ef410 100644 --- a/test/bar-overflow-plot2.html +++ b/test/bar-overflow-plot2.html @@ -165,6 +165,7 @@ under the License. 'Bars must not overflow the xAxis.', 'Check yAxis onZero to xAxis (xAxis is value axis).', `series data min value is **${testHelper.printObject(_data.minXSet)}**`, + `dataZoom min label should **not below zero**`, ], option: createOption(), inputsStyle: 'compact', --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
