Spaces:
Running
Running
| /* | |
| * 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 | string | Date | |
| endValue?: number | string | Date | |
| }) { | |
| 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); | |
| // fallback to extent start/end when parsed value or percent is invalid | |
| valueWindow[idx] = boundValue == null || isNaN(boundValue) | |
| ? dataExtent[idx] | |
| : boundValue; | |
| percentWindow[idx] = boundPercent == null || isNaN(boundPercent) | |
| ? percentExtent[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 don't 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() is called, | |
| * so it is recommended 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); | |
| // `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') { | |
| const store = seriesData.getStore(); | |
| const dataDimIndices = zrUtil.map(dataDims, dim => seriesData.getDimensionIndex(dim), seriesData); | |
| seriesData.filterSelf(function (dataIndex) { | |
| let leftOut; | |
| let rightOut; | |
| let hasValue; | |
| for (let i = 0; i < dataDims.length; i++) { | |
| const value = store.get(dataDimIndices[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; | |