blob: e9c997d9af181bc5badd1c3dba2f7a9fec74abad [file] [log] [blame]
/*
* 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;