1717* under the License.
1818*/
1919
20- import * as zrUtil from 'zrender/src/core/util' ;
21- import * as numberUtil from '../../util/number' ;
20+ import { clone , defaults , each , map } from 'zrender/src/core/util' ;
21+ import {
22+ asc , getAcceptableTickPrecision , linearMap , mathAbs , mathCeil , mathFloor , mathMax , mathMin , round
23+ } from '../../util/number' ;
2224import sliderMove from '../helper/sliderMove' ;
2325import GlobalModel from '../../model/Global' ;
2426import SeriesModel from '../../model/Series' ;
2527import ExtensionAPI from '../../core/ExtensionAPI' ;
26- import { Dictionary } from '../../util/types' ;
28+ import { Dictionary , NullUndefined } from '../../util/types' ;
2729// TODO Polar?
2830import DataZoomModel from './DataZoomModel' ;
2931import { AxisBaseModel } from '../../coord/AxisBaseModel' ;
3032import { unionAxisExtentFromData } from '../../coord/axisHelper' ;
3133import { ensureScaleRawExtentInfo } from '../../coord/scaleRawExtentInfo' ;
3234import { getAxisMainType , isCoordSupported , DataZoomAxisDimension } from './helper' ;
3335import { SINGLE_REFERRING } from '../../util/model' ;
36+ import { isOrdinalScale , isTimeScale } from '../../scale/helper' ;
3437
35- const each = zrUtil . each ;
36- const asc = numberUtil . asc ;
3738
3839interface MinMaxSpan {
3940 minSpan : number
@@ -42,6 +43,17 @@ interface MinMaxSpan {
4243 maxValueSpan : number
4344}
4445
46+ interface AxisProxyWindow {
47+ value : [ number , number ] ;
48+ percent : [ number , number ] ;
49+ // Percent invert from "value window", which may be slightly different from "percent window" due to some
50+ // handling such as rounding. The difference may be magnified in cases like "alignTicks", so we use
51+ // `percentInverted` in these cases.
52+ // But we retain the original input percent in `percent` whenever possible, since they have been used in views.
53+ percentInverted : [ number , number ] ;
54+ valuePrecision : number ;
55+ }
56+
4557/**
4658 * Operate single axis.
4759 * One axis can only operated by one axis operator.
@@ -56,13 +68,16 @@ class AxisProxy {
5668 private _dimName : DataZoomAxisDimension ;
5769 private _axisIndex : number ;
5870
59- private _valueWindow : [ number , number ] ;
60- private _percentWindow : [ number , number ] ;
71+ private _window : AxisProxyWindow ;
6172
6273 private _dataExtent : [ number , number ] ;
6374
6475 private _minMaxSpan : MinMaxSpan ;
6576
77+ /**
78+ * The host `dataZoom` model. An axis may be controlled by multiple `dataZoom`s,
79+ * but only the first declared `dataZoom` is the host.
80+ */
6681 private _dataZoomModel : DataZoomModel ;
6782
6883 constructor (
@@ -94,17 +109,10 @@ class AxisProxy {
94109 }
95110
96111 /**
97- * @return Value can only be NaN or finite value.
112+ * @return `getWindow().value` can only have NaN or finite value.
98113 */
99- getDataValueWindow ( ) {
100- return this . _valueWindow . slice ( ) as [ number , number ] ;
101- }
102-
103- /**
104- * @return {Array.<number> }
105- */
106- getDataPercentWindow ( ) {
107- return this . _percentWindow . slice ( ) as [ number , number ] ;
114+ getWindow ( ) : AxisProxyWindow {
115+ return clone ( this . _window ) ;
108116 }
109117
110118 getTargetSeriesModels ( ) {
@@ -128,26 +136,31 @@ class AxisProxy {
128136 }
129137
130138 getMinMaxSpan ( ) {
131- return zrUtil . clone ( this . _minMaxSpan ) ;
139+ return clone ( this . _minMaxSpan ) ;
132140 }
133141
134142 /**
143+ * [CAVEAT] Keep this method pure, so that it can be called multiple times.
144+ *
135145 * Only calculate by given range and this._dataExtent, do not change anything.
136146 */
137- calculateDataWindow ( opt ?: {
138- start ?: number
139- end ?: number
140- startValue ?: number | string | Date
141- endValue ?: number | string | Date
142- } ) {
147+ calculateDataWindow (
148+ opt : {
149+ start ?: number // percent, 0 ~ 100
150+ end ?: number // percent, 0 ~ 100
151+ startValue ?: number | string | Date
152+ endValue ?: number | string | Date
153+ }
154+ ) : AxisProxyWindow {
143155 const dataExtent = this . _dataExtent ;
144- const axisModel = this . getAxisModel ( ) ;
145- const scale = axisModel . axis . scale ;
156+ const axis = this . getAxisModel ( ) . axis ;
157+ const scale = axis . scale ;
146158 const rangePropMode = this . _dataZoomModel . getRangePropMode ( ) ;
147159 const percentExtent = [ 0 , 100 ] ;
148160 const percentWindow = [ ] as unknown as [ number , number ] ;
149161 const valueWindow = [ ] as unknown as [ number , number ] ;
150162 let hasPropModeValue ;
163+ const needRound = [ false , false ] ;
151164
152165 each ( [ 'start' , 'end' ] as const , function ( prop , idx ) {
153166 let boundPercent = opt [ prop ] ;
@@ -169,24 +182,19 @@ class AxisProxy {
169182
170183 if ( rangePropMode [ idx ] === 'percent' ) {
171184 boundPercent == null && ( boundPercent = percentExtent [ idx ] ) ;
172- // Use scale.parse to math round for category or time axis.
173- boundValue = scale . parse ( numberUtil . linearMap (
174- boundPercent , percentExtent , dataExtent
175- ) ) ;
185+ boundValue = linearMap ( boundPercent , percentExtent , dataExtent ) ;
186+ needRound [ idx ] = true ;
176187 }
177188 else {
178189 hasPropModeValue = true ;
190+ // NOTE: `scale.parse` can also round input for 'time' or 'ordinal' scale.
179191 boundValue = boundValue == null ? dataExtent [ idx ] : scale . parse ( boundValue ) ;
180192 // Calculating `percent` from `value` may be not accurate, because
181- // This calculation can not be inversed , because all of values that
193+ // This calculation can not be inverted , because all of values that
182194 // are overflow the `dataExtent` will be calculated to percent '100%'
183- boundPercent = numberUtil . linearMap (
184- boundValue , dataExtent , percentExtent
185- ) ;
195+ boundPercent = linearMap ( boundValue , dataExtent , percentExtent ) ;
186196 }
187197
188- // valueWindow[idx] = round(boundValue);
189- // percentWindow[idx] = round(boundPercent);
190198 // fallback to extent start/end when parsed value or percent is invalid
191199 valueWindow [ idx ] = boundValue == null || isNaN ( boundValue )
192200 ? dataExtent [ idx ]
@@ -199,11 +207,17 @@ class AxisProxy {
199207 asc ( valueWindow ) ;
200208 asc ( percentWindow ) ;
201209
202- // The windows from user calling of `dispatchAction` might be out of the extent,
203- // or do not obey the `min/maxSpan`, `min/maxValueSpan`. But we don't restrict window
204- // by `zoomLock` here, because we see `zoomLock` just as a interaction constraint,
205- // where API is able to initialize/modify the window size even though `zoomLock`
206- // specified.
210+ // The windows specified from `dispatchAction` or `setOption` may:
211+ // (1) be out of the extent, or
212+ // (2) do not comply with `minSpan/maxSpan`, `minValueSpan/maxValueSpan`.
213+ // So we clamp them here.
214+ // But we don't restrict window by `zoomLock` here, because we see `zoomLock` just as a
215+ // interaction constraint, where API is able to initialize/modify the window size even
216+ // though `zoomLock` specified.
217+ // PENDING: For historical reason, the option design is partially incompatible:
218+ // If `option.start` and `option.endValue` are specified, and when we choose whether
219+ // `min/maxValueSpan` or `minSpan/maxSpan` is applied, neither one is intuitive.
220+ // (Currently using `minValueSpan/maxValueSpan`.)
207221 const spans = this . _minMaxSpan ;
208222 hasPropModeValue
209223 ? restrictSet ( valueWindow , percentWindow , dataExtent , percentExtent , false )
@@ -223,14 +237,67 @@ class AxisProxy {
223237 spans [ 'max' + suffix as 'maxSpan' | 'maxValueSpan' ]
224238 ) ;
225239 for ( let i = 0 ; i < 2 ; i ++ ) {
226- toWindow [ i ] = numberUtil . linearMap ( fromWindow [ i ] , fromExtent , toExtent , true ) ;
227- toValue && ( toWindow [ i ] = scale . parse ( toWindow [ i ] ) ) ;
240+ toWindow [ i ] = linearMap ( fromWindow [ i ] , fromExtent , toExtent , true ) ;
241+ if ( toValue ) {
242+ toWindow [ i ] = toWindow [ i ] ;
243+ needRound [ i ] = true ;
244+ }
245+ }
246+ simplyEnsureAsc ( toWindow ) ;
247+ }
248+
249+ // - In 'time' and 'ordinal' scale, rounding by 0 is required.
250+ // - In 'interval' and 'log' scale, we round values for acceptable display with acceptable accuracy loose.
251+ // "Values" can be rounded only if they are generated from `percent`, since user-specified "value"
252+ // should be respected, and `DataZoomSelect` already performs its own rounding.
253+ // - Currently we only round "value" but not "percent", since there is no need so far.
254+ // - MEMO: See also #3228 and commit a89fd0d7f1833ecf08a4a5b7ecf651b4a0d8da41
255+ // - PENDING: The rounding result may slightly overflow the restriction from `min/maxSpan`,
256+ // but it is acceptable so far.
257+ const isScaleOrdinalOrTime = isOrdinalScale ( scale ) || isTimeScale ( scale ) ;
258+ // Typically pxExtent has been ready in coordSys create. (See `create` of `Grid.ts`)
259+ const pxExtent = axis . getExtent ( ) ;
260+ // NOTICE: this pxSpan may be not accurate yet due to "outerBounds" logic, but acceptable.
261+ const pxSpan = mathAbs ( pxExtent [ 1 ] - pxExtent [ 0 ] ) ;
262+ const precision = isScaleOrdinalOrTime
263+ ? 0
264+ : getAcceptableTickPrecision ( valueWindow [ 1 ] - valueWindow [ 0 ] , pxSpan , 0.5 ) ;
265+ each ( [ [ 0 , mathCeil ] , [ 1 , mathFloor ] ] as const , function ( [ idx , ceilOrFloor ] ) {
266+ if ( ! needRound [ idx ] || ! isFinite ( precision ) ) {
267+ return ;
268+ }
269+ valueWindow [ idx ] = round ( valueWindow [ idx ] , precision ) ;
270+ valueWindow [ idx ] = mathMin ( dataExtent [ 1 ] , mathMax ( dataExtent [ 0 ] , valueWindow [ idx ] ) ) ; // Clamp.
271+ if ( percentWindow [ idx ] === percentExtent [ idx ] ) {
272+ // When `percent` is 0 or 100, `value` must be `dataExtent[0]` or `dataExtent[1]`
273+ // regardless of the calculated precision.
274+ // NOTE: `percentWindow` is never over [0, 100] at this moment.
275+ valueWindow [ idx ] = dataExtent [ idx ] ;
276+ if ( isScaleOrdinalOrTime ) {
277+ // In case that dataExtent[idx] is not an integer (may occur since it comes from user input)
278+ valueWindow [ idx ] = ceilOrFloor ( valueWindow [ idx ] ) ;
279+ }
280+ }
281+ } ) ;
282+ simplyEnsureAsc ( valueWindow ) ;
283+
284+ const percentInvertedWindow = [
285+ linearMap ( valueWindow [ 0 ] , dataExtent , percentExtent , true ) ,
286+ linearMap ( valueWindow [ 1 ] , dataExtent , percentExtent , true ) ,
287+ ] as [ number , number ] ;
288+ simplyEnsureAsc ( percentInvertedWindow ) ;
289+
290+ function simplyEnsureAsc ( window : number [ ] ) : void {
291+ if ( window [ 0 ] > window [ 1 ] ) {
292+ window [ 0 ] = window [ 1 ] ;
228293 }
229294 }
230295
231296 return {
232- valueWindow : valueWindow ,
233- percentWindow : percentWindow
297+ value : valueWindow ,
298+ percent : percentWindow ,
299+ percentInverted : percentInvertedWindow ,
300+ valuePrecision : precision ,
234301 } ;
235302 }
236303
@@ -239,8 +306,8 @@ class AxisProxy {
239306 * so it is recommended to be called in "process stage" but not "model init
240307 * stage".
241308 */
242- reset ( dataZoomModel : DataZoomModel ) {
243- if ( dataZoomModel !== this . _dataZoomModel ) {
309+ reset ( dataZoomModel : DataZoomModel , alignToPercentInverted : [ number , number ] | NullUndefined ) {
310+ if ( ! this . hostedBy ( dataZoomModel ) ) {
244311 return ;
245312 }
246313
@@ -251,24 +318,28 @@ class AxisProxy {
251318 // `calculateDataWindow` uses min/maxSpan.
252319 this . _updateMinMaxSpan ( ) ;
253320
254- const dataWindow = this . calculateDataWindow ( dataZoomModel . settledOption ) ;
255-
256- this . _valueWindow = dataWindow . valueWindow ;
257- this . _percentWindow = dataWindow . percentWindow ;
321+ let opt = dataZoomModel . settledOption ;
322+ if ( alignToPercentInverted ) {
323+ opt = defaults ( {
324+ start : alignToPercentInverted [ 0 ] ,
325+ end : alignToPercentInverted [ 1 ] ,
326+ } , opt ) ;
327+ }
328+ this . _window = this . calculateDataWindow ( opt ) ;
258329
259330 // Update axis setting then.
260331 this . _setAxisModel ( ) ;
261332 }
262333
263334 filterData ( dataZoomModel : DataZoomModel , api : ExtensionAPI ) {
264- if ( dataZoomModel !== this . _dataZoomModel ) {
335+ if ( ! this . hostedBy ( dataZoomModel ) ) {
265336 return ;
266337 }
267338
268339 const axisDim = this . _dimName ;
269340 const seriesModels = this . getTargetSeriesModels ( ) ;
270341 const filterMode = dataZoomModel . get ( 'filterMode' ) ;
271- const valueWindow = this . _valueWindow ;
342+ const valueWindow = this . _window . value ;
272343
273344 if ( filterMode === 'none' ) {
274345 return ;
@@ -305,7 +376,7 @@ class AxisProxy {
305376
306377 if ( filterMode === 'weakFilter' ) {
307378 const store = seriesData . getStore ( ) ;
308- const dataDimIndices = zrUtil . map ( dataDims , dim => seriesData . getDimensionIndex ( dim ) , seriesData ) ;
379+ const dataDimIndices = map ( dataDims , dim => seriesData . getDimensionIndex ( dim ) , seriesData ) ;
309380 seriesData . filterSelf ( function ( dataIndex ) {
310381 let leftOut ;
311382 let rightOut ;
@@ -368,12 +439,12 @@ class AxisProxy {
368439
369440 // minValueSpan and maxValueSpan has higher priority than minSpan and maxSpan
370441 if ( valueSpan != null ) {
371- percentSpan = numberUtil . linearMap (
442+ percentSpan = linearMap (
372443 dataExtent [ 0 ] + valueSpan , dataExtent , [ 0 , 100 ] , true
373444 ) ;
374445 }
375446 else if ( percentSpan != null ) {
376- valueSpan = numberUtil . linearMap (
447+ valueSpan = linearMap (
377448 percentSpan , [ 0 , 100 ] , dataExtent , true
378449 ) - dataExtent [ 0 ] ;
379450 }
@@ -387,27 +458,22 @@ class AxisProxy {
387458
388459 const axisModel = this . getAxisModel ( ) ;
389460
390- const percentWindow = this . _percentWindow ;
391- const valueWindow = this . _valueWindow ;
392-
393- if ( ! percentWindow ) {
461+ const window = this . _window ;
462+ if ( ! window ) {
394463 return ;
395464 }
396-
397- // [0, 500]: arbitrary value, guess axis extent.
398- let precision = numberUtil . getPixelPrecision ( valueWindow , [ 0 , 500 ] ) ;
399- precision = Math . min ( precision , 20 ) ;
465+ const { percent, value} = window ;
400466
401467 // For value axis, if min/max/scale are not set, we just use the extent obtained
402468 // by series data, which may be a little different from the extent calculated by
403469 // `axisHelper.getScaleExtent`. But the different just affects the experience a
404470 // little when zooming. So it will not be fixed until some users require it strongly.
405471 const rawExtentInfo = axisModel . axis . scale . rawExtentInfo ;
406- if ( percentWindow [ 0 ] !== 0 ) {
407- rawExtentInfo . setDeterminedMinMax ( 'min' , + valueWindow [ 0 ] . toFixed ( precision ) ) ;
472+ if ( percent [ 0 ] !== 0 ) {
473+ rawExtentInfo . setDeterminedMinMax ( 'min' , value [ 0 ] ) ;
408474 }
409- if ( percentWindow [ 1 ] !== 100 ) {
410- rawExtentInfo . setDeterminedMinMax ( 'max' , + valueWindow [ 1 ] . toFixed ( precision ) ) ;
475+ if ( percent [ 1 ] !== 100 ) {
476+ rawExtentInfo . setDeterminedMinMax ( 'max' , value [ 1 ] ) ;
411477 }
412478 rawExtentInfo . freeze ( ) ;
413479 }
0 commit comments