/*
|
* 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 * as zrUtil from 'zrender/src/core/util';
|
import * as numberUtil 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';
|
// TODO Polar?
|
import DataZoomModel from './DataZoomModel';
|
import { AxisBaseModel } from '../../coord/AxisBaseModel';
|
import { unionAxisExtentFromData } from '../../coord/axisHelper';
|
import { ensureScaleRawExtentInfo } from '../../coord/scaleRawExtentInfo';
|
import { getAxisMainType, isCoordSupported, DataZoomAxisDimension } from './helper';
|
import { SINGLE_REFERRING } from '../../util/model';
|
|
const each = zrUtil.each;
|
const asc = numberUtil.asc;
|
|
interface MinMaxSpan {
|
minSpan: number
|
maxSpan: number
|
minValueSpan: number
|
maxValueSpan: number
|
}
|
|
type SupportedAxis = 'xAxis' | 'yAxis' | 'angleAxis' | 'radiusAxis' | 'singleAxis';
|
|
/**
|
* Operate single axis.
|
* One axis can only operated by one axis operator.
|
* Different dataZoomModels may be defined to operate the same axis.
|
* (i.e. 'inside' data zoom and 'slider' data zoom components)
|
* So dataZoomModels share one axisProxy in that case.
|
*/
|
class AxisProxy {
|
|
ecModel: GlobalModel;
|
|
private _dimName: DataZoomAxisDimension;
|
private _axisIndex: number;
|
|
private _valueWindow: [number, number];
|
private _percentWindow: [number, number];
|
|
private _dataExtent: [number, number];
|
|
private _minMaxSpan: MinMaxSpan;
|
|
private _dataZoomModel: DataZoomModel;
|
|
constructor(
|
dimName: DataZoomAxisDimension,
|
axisIndex: number,
|
dataZoomModel: DataZoomModel,
|
ecModel: GlobalModel
|
) {
|
this._dimName = dimName;
|
|
this._axisIndex = axisIndex;
|
|
this.ecModel = ecModel;
|
|
this._dataZoomModel = dataZoomModel;
|
|
// /**
|
// * @readOnly
|
// * @private
|
// */
|
// this.hasSeriesStacked;
|
}
|
|
/**
|
* Whether the axisProxy is hosted by dataZoomModel.
|
*/
|
hostedBy(dataZoomModel: DataZoomModel): boolean {
|
return this._dataZoomModel === dataZoomModel;
|
}
|
|
/**
|
* @return Value can only be NaN or finite value.
|
*/
|
getDataValueWindow() {
|
return this._valueWindow.slice() as [number, number];
|
}
|
|
/**
|
* @return {Array.<number>}
|
*/
|
getDataPercentWindow() {
|
return this._percentWindow.slice() as [number, number];
|
}
|
|
getTargetSeriesModels() {
|
const seriesModels: SeriesModel[] = [];
|
|
this.ecModel.eachSeries(function (seriesModel) {
|
if (isCoordSupported(seriesModel)) {
|
const axisMainType = getAxisMainType(this._dimName);
|
const axisModel = seriesModel.getReferringComponents(axisMainType, SINGLE_REFERRING).models[0];
|
if (axisModel && this._axisIndex === axisModel.componentIndex) {
|
seriesModels.push(seriesModel);
|
}
|
}
|
}, this);
|
|
return seriesModels;
|
}
|
|
getAxisModel(): AxisBaseModel {
|
return this.ecModel.getComponent(this._dimName + 'Axis', this._axisIndex) as AxisBaseModel;
|
}
|
|
getMinMaxSpan() {
|
return zrUtil.clone(this._minMaxSpan);
|
}
|
|
/**
|
* Only calculate by given range and this._dataExtent, do not change anything.
|
*/
|
calculateDataWindow(opt?: {
|
start?: number
|
end?: number
|
startValue?: number
|
endValue?: number
|
}) {
|
const dataExtent = this._dataExtent;
|
const axisModel = this.getAxisModel();
|
const scale = axisModel.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;
|
|
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).
|
// 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).
|
// we use `getRangePropMode()` to mark which prop is used. `rangePropMode` is updated
|
// only when setOption or dispatchAction, otherwise it remains its original value.
|
// (Why not only record `percentProp` and always map to `valueProp`? Because
|
// the map `valueProp` -> `percentProp` -> `valueProp` probably not the original
|
// `valueProp`. consider two axes constrolled by one dataZoom. They have different
|
// data extent. All of values that are overflow the `dataExtent` will be calculated
|
// to percent '100%').
|
|
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
|
));
|
}
|
else {
|
hasPropModeValue = true;
|
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
|
// are overflow the `dataExtent` will be calculated to percent '100%'
|
boundPercent = numberUtil.linearMap(
|
boundValue, dataExtent, percentExtent
|
);
|
}
|
|
// valueWindow[idx] = round(boundValue);
|
// percentWindow[idx] = round(boundPercent);
|
valueWindow[idx] = boundValue;
|
percentWindow[idx] = boundPercent;
|
});
|
|
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 dont 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.
|
const spans = this._minMaxSpan;
|
hasPropModeValue
|
? restrictSet(valueWindow, percentWindow, dataExtent, percentExtent, false)
|
: restrictSet(percentWindow, valueWindow, percentExtent, dataExtent, true);
|
|
function restrictSet(
|
fromWindow: number[],
|
toWindow: number[],
|
fromExtent: number[],
|
toExtent: number[],
|
toValue: boolean
|
) {
|
const suffix = toValue ? 'Span' : 'ValueSpan';
|
sliderMove(
|
0, fromWindow, fromExtent, 'all',
|
spans['min' + suffix as 'minSpan' | 'minValueSpan'],
|
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]));
|
}
|
}
|
|
return {
|
valueWindow: valueWindow,
|
percentWindow: percentWindow
|
};
|
}
|
|
/**
|
* Notice: reset should not be called before series.restoreData() called,
|
* so it is recommanded to be called in "process stage" but not "model init
|
* stage".
|
*/
|
reset(dataZoomModel: DataZoomModel) {
|
if (dataZoomModel !== this._dataZoomModel) {
|
return;
|
}
|
|
const targetSeries = this.getTargetSeriesModels();
|
// Culculate data window and data extent, and record them.
|
this._dataExtent = calculateDataExtent(this, this._dimName, targetSeries);
|
|
// this.hasSeriesStacked = false;
|
// each(targetSeries, function (series) {
|
// let data = series.getData();
|
// let dataDim = data.mapDimension(this._dimName);
|
// let stackedDimension = data.getCalculationInfo('stackedDimension');
|
// if (stackedDimension && stackedDimension === dataDim) {
|
// this.hasSeriesStacked = true;
|
// }
|
// }, this);
|
|
// `calculateDataWindow` uses min/maxSpan.
|
this._updateMinMaxSpan();
|
|
const dataWindow = this.calculateDataWindow(dataZoomModel.settledOption);
|
|
this._valueWindow = dataWindow.valueWindow;
|
this._percentWindow = dataWindow.percentWindow;
|
|
// Update axis setting then.
|
this._setAxisModel();
|
}
|
|
filterData(dataZoomModel: DataZoomModel, api: ExtensionAPI) {
|
if (dataZoomModel !== this._dataZoomModel) {
|
return;
|
}
|
|
const axisDim = this._dimName;
|
const seriesModels = this.getTargetSeriesModels();
|
const filterMode = dataZoomModel.get('filterMode');
|
const valueWindow = this._valueWindow;
|
|
if (filterMode === 'none') {
|
return;
|
}
|
|
// FIXME
|
// Toolbox may has dataZoom injected. And if there are stacked bar chart
|
// with NaN data, NaN will be filtered and stack will be wrong.
|
// So we need to force the mode to be set empty.
|
// In fect, it is not a big deal that do not support filterMode-'filter'
|
// when using toolbox#dataZoom, utill tooltip#dataZoom support "single axis
|
// selection" some day, which might need "adapt to data extent on the
|
// otherAxis", which is disabled by filterMode-'empty'.
|
// But currently, stack has been fixed to based on value but not index,
|
// so this is not an issue any more.
|
// let otherAxisModel = this.getOtherAxisModel();
|
// if (dataZoomModel.get('$fromToolbox')
|
// && otherAxisModel
|
// && otherAxisModel.hasSeriesStacked
|
// ) {
|
// filterMode = 'empty';
|
// }
|
|
// TODO
|
// filterMode 'weakFilter' and 'empty' is not optimized for huge data yet.
|
|
each(seriesModels, function (seriesModel) {
|
let seriesData = seriesModel.getData();
|
const dataDims = seriesData.mapDimensionsAll(axisDim);
|
|
if (!dataDims.length) {
|
return;
|
}
|
|
if (filterMode === 'weakFilter') {
|
seriesData.filterSelf(function (dataIndex) {
|
let leftOut;
|
let rightOut;
|
let hasValue;
|
for (let i = 0; i < dataDims.length; i++) {
|
const value = seriesData.get(dataDims[i], dataIndex) as number;
|
const thisHasValue = !isNaN(value);
|
const thisLeftOut = value < valueWindow[0];
|
const thisRightOut = value > valueWindow[1];
|
if (thisHasValue && !thisLeftOut && !thisRightOut) {
|
return true;
|
}
|
thisHasValue && (hasValue = true);
|
thisLeftOut && (leftOut = true);
|
thisRightOut && (rightOut = true);
|
}
|
// If both left out and right out, do not filter.
|
return hasValue && leftOut && rightOut;
|
});
|
}
|
else {
|
each(dataDims, function (dim) {
|
if (filterMode === 'empty') {
|
seriesModel.setData(
|
seriesData = seriesData.map(dim, function (value: number) {
|
return !isInWindow(value) ? NaN : value;
|
})
|
);
|
}
|
else {
|
const range: Dictionary<[number, number]> = {};
|
range[dim] = valueWindow;
|
|
// console.time('select');
|
seriesData.selectRange(range);
|
// console.timeEnd('select');
|
}
|
});
|
}
|
|
each(dataDims, function (dim) {
|
seriesData.setApproximateExtent(valueWindow, dim);
|
});
|
});
|
|
function isInWindow(value: number) {
|
return value >= valueWindow[0] && value <= valueWindow[1];
|
}
|
}
|
|
private _updateMinMaxSpan() {
|
const minMaxSpan = this._minMaxSpan = {} as MinMaxSpan;
|
const dataZoomModel = this._dataZoomModel;
|
const dataExtent = this._dataExtent;
|
|
each(['min', 'max'], function (minMax) {
|
let percentSpan = dataZoomModel.get(minMax + 'Span' as 'minSpan' | 'maxSpan');
|
let valueSpan = dataZoomModel.get(minMax + 'ValueSpan' as 'minValueSpan' | 'maxValueSpan');
|
valueSpan != null && (valueSpan = this.getAxisModel().axis.scale.parse(valueSpan));
|
|
// minValueSpan and maxValueSpan has higher priority than minSpan and maxSpan
|
if (valueSpan != null) {
|
percentSpan = numberUtil.linearMap(
|
dataExtent[0] + valueSpan, dataExtent, [0, 100], true
|
);
|
}
|
else if (percentSpan != null) {
|
valueSpan = numberUtil.linearMap(
|
percentSpan, [0, 100], dataExtent, true
|
) - dataExtent[0];
|
}
|
|
minMaxSpan[minMax + 'Span' as 'minSpan' | 'maxSpan'] = percentSpan;
|
minMaxSpan[minMax + 'ValueSpan' as 'minValueSpan' | 'maxValueSpan'] = valueSpan;
|
}, this);
|
}
|
|
private _setAxisModel() {
|
|
const axisModel = this.getAxisModel();
|
|
const percentWindow = this._percentWindow;
|
const valueWindow = this._valueWindow;
|
|
if (!percentWindow) {
|
return;
|
}
|
|
// [0, 500]: arbitrary value, guess axis extent.
|
let precision = numberUtil.getPixelPrecision(valueWindow, [0, 500]);
|
precision = Math.min(precision, 20);
|
|
// 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 (percentWindow[1] !== 100) {
|
rawExtentInfo.setDeterminedMinMax('max', +valueWindow[1].toFixed(precision));
|
}
|
rawExtentInfo.freeze();
|
}
|
}
|
|
function calculateDataExtent(axisProxy: AxisProxy, axisDim: string, seriesModels: SeriesModel[]) {
|
const dataExtent = [Infinity, -Infinity];
|
|
each(seriesModels, function (seriesModel) {
|
unionAxisExtentFromData(dataExtent, seriesModel.getData(), axisDim);
|
});
|
|
// It is important to get "consistent" extent when more then one axes is
|
// controlled by a `dataZoom`, otherwise those axes will not be synchronized
|
// when zooming. But it is difficult to know what is "consistent", considering
|
// axes have different type or even different meanings (For example, two
|
// time axes are used to compare data of the same date in different years).
|
// So basically dataZoom just obtains extent by series.data (in category axis
|
// extent can be obtained from axis.data).
|
// Nevertheless, user can set min/max/scale on axes to make extent of axes
|
// consistent.
|
const axisModel = axisProxy.getAxisModel();
|
const rawExtentResult = ensureScaleRawExtentInfo(axisModel.axis.scale, axisModel, dataExtent).calculate();
|
|
return [rawExtentResult.min, rawExtentResult.max] as [number, number];
|
}
|
|
export default AxisProxy;
|