Skip to content

Commit d168bf2

Browse files
committed
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.
1 parent 479dcd4 commit d168bf2

8 files changed

Lines changed: 270 additions & 146 deletions

File tree

src/component/dataZoom/AxisProxy.ts

Lines changed: 134 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,24 @@
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';
2224
import sliderMove from '../helper/sliderMove';
2325
import GlobalModel from '../../model/Global';
2426
import SeriesModel from '../../model/Series';
2527
import ExtensionAPI from '../../core/ExtensionAPI';
26-
import { Dictionary } from '../../util/types';
28+
import { Dictionary, NullUndefined } from '../../util/types';
2729
// TODO Polar?
2830
import DataZoomModel from './DataZoomModel';
2931
import { AxisBaseModel } from '../../coord/AxisBaseModel';
3032
import { unionAxisExtentFromData } from '../../coord/axisHelper';
3133
import { ensureScaleRawExtentInfo } from '../../coord/scaleRawExtentInfo';
3234
import { getAxisMainType, isCoordSupported, DataZoomAxisDimension } from './helper';
3335
import { SINGLE_REFERRING } from '../../util/model';
36+
import { isOrdinalScale, isTimeScale } from '../../scale/helper';
3437

35-
const each = zrUtil.each;
36-
const asc = numberUtil.asc;
3738

3839
interface 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

Comments
 (0)