diff --git a/src/chart/bar/BarView.ts b/src/chart/bar/BarView.ts index ef193bd1fb..ab6337d59b 100644 --- a/src/chart/bar/BarView.ts +++ b/src/chart/bar/BarView.ts @@ -92,14 +92,15 @@ type RealtimeSortConfig = { // Return a number, based on which the ordinal sorted. type OrderMapping = (dataIndex: number) => number; -function getClipArea(coord: CoordSysOfBar, data: SeriesData, strictClip?: boolean) { +function getClipArea(coord: CoordSysOfBar, data: SeriesData) { const coordSysClipArea = coord.getArea && coord.getArea(); if (isCoordinateSystemType(coord, 'cartesian2d')) { const baseAxis = coord.getBaseAxis(); - // When boundaryGap is false or using time axis. bar may exceed the grid. + // When boundaryGap is false in category axis, bar may exceed the grid. // We should not clip this part. // See test/bar2.html - if (!strictClip && (baseAxis.type !== 'category' || !baseAxis.onBand)) { + // PENDING: The effect is not preferable, but we preserve it for backward compatibility. + if (baseAxis.type === 'category' && !baseAxis.onBand) { const expandWidth = data.getLayout('bandWidth'); if (baseAxis.isHorizontal()) { (coordSysClipArea as CartesianCoordArea).x -= expandWidth; @@ -220,12 +221,12 @@ class BarView extends ChartView { } const needsClip = seriesModel.get('clip', true) || realtimeSortCfg; - const strictClip = seriesModel.get('clip', true); - const coordSysClipArea = getClipArea(coord, data, strictClip); + const coordSysClipArea = getClipArea(coord, data); // If there is clipPath created in large mode. Remove it. group.removeClipPath(); // We don't use clipPath in normal mode because we needs a perfect animation // And don't want the label are clipped. + // Instead, `Clipper` is used in normal mode. const roundCap = seriesModel.get('roundCap', true); diff --git a/src/chart/bar/install.ts b/src/chart/bar/install.ts index 156f03e26e..65ab609637 100644 --- a/src/chart/bar/install.ts +++ b/src/chart/bar/install.ts @@ -19,7 +19,7 @@ import { EChartsExtensionInstallRegisters } from '../../extension'; import * as zrUtil from 'zrender/src/core/util'; -import {layout, createProgressiveLayout} from '../../layout/barGrid'; +import {layout, createProgressiveLayout, registerBarGridAxisHandlers} from '../../layout/barGrid'; import dataSample from '../../processor/dataSample'; import BarSeries from './BarSeries'; @@ -67,4 +67,5 @@ export function install(registers: EChartsExtensionInstallRegisters) { ); }); + registerBarGridAxisHandlers(registers); } diff --git a/src/chart/bar/installPictorialBar.ts b/src/chart/bar/installPictorialBar.ts index ec49128b83..dc5ba41b65 100644 --- a/src/chart/bar/installPictorialBar.ts +++ b/src/chart/bar/installPictorialBar.ts @@ -20,7 +20,7 @@ import { EChartsExtensionInstallRegisters } from '../../extension'; import PictorialBarView from './PictorialBarView'; import PictorialBarSeriesModel from './PictorialBarSeries'; -import { createProgressiveLayout, layout } from '../../layout/barGrid'; +import { createProgressiveLayout, layout, registerBarGridAxisHandlers } from '../../layout/barGrid'; import { curry } from 'zrender/src/core/util'; export function install(registers: EChartsExtensionInstallRegisters) { @@ -30,4 +30,6 @@ export function install(registers: EChartsExtensionInstallRegisters) { registers.registerLayout(registers.PRIORITY.VISUAL.LAYOUT, curry(layout, 'pictorialBar')); // Do layout after other overall layout, which can prepare some information. registers.registerLayout(registers.PRIORITY.VISUAL.PROGRESSIVE_LAYOUT, createProgressiveLayout('pictorialBar')); + + registerBarGridAxisHandlers(registers); } diff --git a/src/chart/boxplot/BoxplotSeries.ts b/src/chart/boxplot/BoxplotSeries.ts index 7fba2a79fb..f839ca552a 100644 --- a/src/chart/boxplot/BoxplotSeries.ts +++ b/src/chart/boxplot/BoxplotSeries.ts @@ -65,6 +65,7 @@ export interface BoxplotSeriesOption coordinateSystem?: 'cartesian2d' layout?: LayoutOrient + clip?: boolean; /** * [min, max] can be percent of band width. */ @@ -73,9 +74,11 @@ export interface BoxplotSeriesOption data?: (BoxplotDataValue | BoxplotDataItemOption)[] } +export const SERIES_TYPE_BOXPLOT = 'boxplot'; + class BoxplotSeriesModel extends SeriesModel { - static readonly type = 'series.boxplot'; + static readonly type = 'series.' + SERIES_TYPE_BOXPLOT; readonly type = BoxplotSeriesModel.type; static readonly dependencies = ['xAxis', 'yAxis', 'grid']; @@ -109,6 +112,7 @@ class BoxplotSeriesModel extends SeriesModel { legendHoverLink: true, layout: null, + clip: true, boxWidth: [7, 50], itemStyle: { diff --git a/src/chart/boxplot/BoxplotView.ts b/src/chart/boxplot/BoxplotView.ts index 0d88309773..faffc27451 100644 --- a/src/chart/boxplot/BoxplotView.ts +++ b/src/chart/boxplot/BoxplotView.ts @@ -17,20 +17,27 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; import ChartView from '../../view/Chart'; import * as graphic from '../../util/graphic'; import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; import Path, { PathProps } from 'zrender/src/graphic/Path'; -import BoxplotSeriesModel, { BoxplotDataItemOption } from './BoxplotSeries'; +import BoxplotSeriesModel, { SERIES_TYPE_BOXPLOT, BoxplotDataItemOption } from './BoxplotSeries'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import SeriesData from '../../data/SeriesData'; import { BoxplotItemLayout } from './boxplotLayout'; import { saveOldStyle } from '../../animation/basicTransition'; +import { resolveNormalBoxClipping } from '../helper/whiskerBoxCommon'; +import { + createClipPath, SHAPE_CLIP_KIND_FULLY_CLIPPED, SHAPE_CLIP_KIND_NOT_CLIPPED, + SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, + updateClipPath +} from '../helper/createClipPathFromCoordSys'; +import { map } from 'zrender/src/core/util'; + class BoxplotView extends ChartView { - static type = 'boxplot'; + static type = SERIES_TYPE_BOXPLOT; type = BoxplotView.type; private _data: SeriesData; @@ -47,12 +54,29 @@ class BoxplotView extends ChartView { } const constDim = seriesModel.getWhiskerBoxesLayout() === 'horizontal' ? 1 : 0; + const needClip = seriesModel.get('clip', true); + const coordSys = seriesModel.coordinateSystem; + const clipArea = coordSys.getArea && coordSys.getArea(); + const clipPath = needClip && createClipPath(coordSys, false, seriesModel); data.diff(oldData) .add(function (newIdx) { if (data.hasValue(newIdx)) { const itemLayout = data.getItemLayout(newIdx) as BoxplotItemLayout; + + const clipKind = needClip + ? resolveNormalBoxClipping(clipArea, itemLayout) : SHAPE_CLIP_KIND_NOT_CLIPPED; + if (clipKind === SHAPE_CLIP_KIND_FULLY_CLIPPED) { + return; + } + const symbolEl = createNormalBox(itemLayout, data, newIdx, constDim, true); + // One axis tick can corresponds to a group of box items (from different series), + // so it may be visually misleading when a group of items are partially outside + // but no clipping is applied. + // Consider performance of zr Element['clipPath'], only set to partially clipped elements. + updateClipPath(clipKind === SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, symbolEl, clipPath); + data.setItemGraphicEl(newIdx, symbolEl); group.add(symbolEl); } @@ -67,6 +91,14 @@ class BoxplotView extends ChartView { } const itemLayout = data.getItemLayout(newIdx) as BoxplotItemLayout; + + const clipKind = needClip + ? resolveNormalBoxClipping(clipArea, itemLayout) : SHAPE_CLIP_KIND_NOT_CLIPPED; + if (clipKind === SHAPE_CLIP_KIND_FULLY_CLIPPED) { + group.remove(symbolEl); + return; + } + if (!symbolEl) { symbolEl = createNormalBox(itemLayout, data, newIdx, constDim); } @@ -75,6 +107,9 @@ class BoxplotView extends ChartView { updateNormalBoxData(itemLayout, symbolEl, data, newIdx); } + // See `updateClipPath` in `add`. + updateClipPath(clipKind === SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, symbolEl, clipPath); + group.add(symbolEl); data.setItemGraphicEl(newIdx, symbolEl); @@ -192,7 +227,7 @@ function updateNormalBoxData( } function transInit(points: number[][], dim: number, itemLayout: BoxplotItemLayout) { - return zrUtil.map(points, function (point) { + return map(points, function (point) { point = point.slice(); point[dim] = itemLayout.initBaseline; return point; diff --git a/src/chart/boxplot/boxplotLayout.ts b/src/chart/boxplot/boxplotLayout.ts index 053462d2f2..283c18979d 100644 --- a/src/chart/boxplot/boxplotLayout.ts +++ b/src/chart/boxplot/boxplotLayout.ts @@ -17,103 +17,70 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; +import { isArray } from 'zrender/src/core/util'; import {parsePercent} from '../../util/number'; import type GlobalModel from '../../model/Global'; -import BoxplotSeriesModel from './BoxplotSeries'; -import Axis2D from '../../coord/cartesian/Axis2D'; - -const each = zrUtil.each; - -interface GroupItem { - seriesModels: BoxplotSeriesModel[] - axis: Axis2D - boxOffsetList: number[] - boxWidthList: number[] -} +import BoxplotSeriesModel, { SERIES_TYPE_BOXPLOT } from './BoxplotSeries'; +import { + countSeriesOnAxisOnKey, eachAxisOnKey, eachSeriesOnAxisOnKey, + requireAxisStatistics +} from '../../coord/axisStatistics'; +import { makeCallOnlyOnce } from '../../util/model'; +import { EChartsExtensionInstallRegisters } from '../../extension'; +import Axis from '../../coord/Axis'; +import { registerAxisContainShapeHandler } from '../../coord/scaleRawExtentInfo'; +import { calcBandWidth } from '../../coord/axisBand'; +import { + createBandWidthBasedAxisContainShapeHandler, + getMetricsNonOrdinalLinearPositiveMinGap, + makeAxisStatKey +} from '../helper/axisSnippets'; + + +const callOnlyOnce = makeCallOnlyOnce(); export interface BoxplotItemLayout { ends: number[][] initBaseline: number } -export default function boxplotLayout(ecModel: GlobalModel) { - - const groupResult = groupSeriesByAxis(ecModel); - - each(groupResult, function (groupItem) { - const seriesModels = groupItem.seriesModels; - - if (!seriesModels.length) { +export function boxplotLayout(ecModel: GlobalModel) { + const axisStatKey = makeAxisStatKey(SERIES_TYPE_BOXPLOT); + eachAxisOnKey(ecModel, axisStatKey, function (axis) { + const seriesCount = countSeriesOnAxisOnKey(axis, axisStatKey); + if (!seriesCount) { return; } - - calculateBase(groupItem); - - each(seriesModels, function (seriesModel, idx) { + const baseResult = calculateBase(axis, seriesCount); + eachSeriesOnAxisOnKey(axis, axisStatKey, function (seriesModel: BoxplotSeriesModel, idx) { layoutSingleSeries( seriesModel, - groupItem.boxOffsetList[idx], - groupItem.boxWidthList[idx] + baseResult.boxOffsetList[idx], + baseResult.boxWidthList[idx] ); }); }); } -/** - * Group series by axis. - */ -function groupSeriesByAxis(ecModel: GlobalModel) { - const result: GroupItem[] = []; - const axisList: Axis2D[] = []; - - ecModel.eachSeriesByType('boxplot', function (seriesModel: BoxplotSeriesModel) { - const baseAxis = seriesModel.getBaseAxis(); - let idx = zrUtil.indexOf(axisList, baseAxis); - - if (idx < 0) { - idx = axisList.length; - axisList[idx] = baseAxis; - result[idx] = { - axis: baseAxis, - seriesModels: [] - } as GroupItem; - } - - result[idx].seriesModels.push(seriesModel); - }); - - return result; -} - /** * Calculate offset and box width for each series. */ -function calculateBase(groupItem: GroupItem) { - const baseAxis = groupItem.axis; - const seriesModels = groupItem.seriesModels; - const seriesCount = seriesModels.length; - - const boxWidthList: number[] = groupItem.boxWidthList = []; - const boxOffsetList: number[] = groupItem.boxOffsetList = []; +function calculateBase(baseAxis: Axis, seriesCount: number): { + boxOffsetList: number[]; + boxWidthList: number[]; +} { + const boxWidthList: number[] = []; + const boxOffsetList: number[] = []; const boundList: number[][] = []; - let bandWidth: number; - if (baseAxis.type === 'category') { - bandWidth = baseAxis.getBandWidth(); - } - else { - let maxDataCount = 0; - each(seriesModels, function (seriesModel) { - maxDataCount = Math.max(maxDataCount, seriesModel.getData().count()); - }); - const extent = baseAxis.getExtent(); - bandWidth = Math.abs(extent[1] - extent[0]) / maxDataCount; - } + const bandWidth = calcBandWidth( + baseAxis, + {fromStat: {key: makeAxisStatKey(SERIES_TYPE_BOXPLOT)}, min: 1}, + ).w; - each(seriesModels, function (seriesModel) { + eachSeriesOnAxisOnKey(baseAxis, makeAxisStatKey(SERIES_TYPE_BOXPLOT), function (seriesModel: BoxplotSeriesModel) { let boxWidthBound = seriesModel.get('boxWidth'); - if (!zrUtil.isArray(boxWidthBound)) { + if (!isArray(boxWidthBound)) { boxWidthBound = [boxWidthBound, boxWidthBound]; } boundList.push([ @@ -127,7 +94,7 @@ function calculateBase(groupItem: GroupItem) { const boxWidth = (availableWidth - boxGap * (seriesCount - 1)) / seriesCount; let base = boxWidth / 2 - availableWidth / 2; - each(seriesModels, function (seriesModel, idx) { + eachSeriesOnAxisOnKey(baseAxis, makeAxisStatKey(SERIES_TYPE_BOXPLOT), function (seriesModel, idx) { boxOffsetList.push(base); base += boxGap + boxWidth; @@ -135,6 +102,11 @@ function calculateBase(groupItem: GroupItem) { Math.min(Math.max(boxWidth, boundList[idx][0]), boundList[idx][1]) ); }); + + return { + boxOffsetList, + boxWidthList, + }; } /** @@ -212,3 +184,21 @@ function layoutSingleSeries(seriesModel: BoxplotSeriesModel, offset: number, box ends.push(from, to); } } + +export function registerBoxplotAxisHandlers(registers: EChartsExtensionInstallRegisters) { + callOnlyOnce(registers, function () { + const axisStatKey = makeAxisStatKey(SERIES_TYPE_BOXPLOT); + requireAxisStatistics( + registers, + { + key: axisStatKey, + seriesType: SERIES_TYPE_BOXPLOT, + getMetrics: getMetricsNonOrdinalLinearPositiveMinGap, + } + ); + registerAxisContainShapeHandler( + axisStatKey, + createBandWidthBasedAxisContainShapeHandler(axisStatKey) + ); + }); +} diff --git a/src/chart/boxplot/install.ts b/src/chart/boxplot/install.ts index b5a7efcd98..0cfebd0a19 100644 --- a/src/chart/boxplot/install.ts +++ b/src/chart/boxplot/install.ts @@ -20,7 +20,7 @@ import { EChartsExtensionInstallRegisters } from '../../extension'; import BoxplotSeriesModel from './BoxplotSeries'; import BoxplotView from './BoxplotView'; -import boxplotLayout from './boxplotLayout'; +import {boxplotLayout, registerBoxplotAxisHandlers} from './boxplotLayout'; import { boxplotTransform } from './boxplotTransform'; export function install(registers: EChartsExtensionInstallRegisters) { @@ -28,4 +28,6 @@ export function install(registers: EChartsExtensionInstallRegisters) { registers.registerChartView(BoxplotView); registers.registerLayout(boxplotLayout); registers.registerTransform(boxplotTransform); + + registerBoxplotAxisHandlers(registers); } diff --git a/src/chart/candlestick/CandlestickSeries.ts b/src/chart/candlestick/CandlestickSeries.ts index 55938e3b48..d4196d121e 100644 --- a/src/chart/candlestick/CandlestickSeries.ts +++ b/src/chart/candlestick/CandlestickSeries.ts @@ -82,9 +82,11 @@ export interface CandlestickSeriesOption data?: (CandlestickDataValue | CandlestickDataItemOption)[] } +export const SERIES_TYPE_CANDLESTICK = 'candlestick'; + class CandlestickSeriesModel extends SeriesModel { - static readonly type = 'series.candlestick'; + static readonly type = 'series.' + SERIES_TYPE_CANDLESTICK; readonly type = CandlestickSeriesModel.type; static readonly dependencies = ['xAxis', 'yAxis', 'grid']; diff --git a/src/chart/candlestick/CandlestickView.ts b/src/chart/candlestick/CandlestickView.ts index 706cae6fcc..d2acdba758 100644 --- a/src/chart/candlestick/CandlestickView.ts +++ b/src/chart/candlestick/CandlestickView.ts @@ -22,24 +22,27 @@ import ChartView from '../../view/Chart'; import * as graphic from '../../util/graphic'; import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; import Path, { PathProps } from 'zrender/src/graphic/Path'; -import {createClipPath} from '../helper/createClipPathFromCoordSys'; -import CandlestickSeriesModel, { CandlestickDataItemOption } from './CandlestickSeries'; +import { + createClipPath, SHAPE_CLIP_KIND_FULLY_CLIPPED, SHAPE_CLIP_KIND_NOT_CLIPPED, + SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, updateClipPath +} from '../helper/createClipPathFromCoordSys'; +import CandlestickSeriesModel, { SERIES_TYPE_CANDLESTICK, CandlestickDataItemOption } from './CandlestickSeries'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import { StageHandlerProgressParams } from '../../util/types'; import SeriesData from '../../data/SeriesData'; import {CandlestickItemLayout} from './candlestickLayout'; -import { CoordinateSystemClipArea } from '../../coord/CoordinateSystem'; import Model from '../../model/Model'; import { saveOldStyle } from '../../animation/basicTransition'; import Element from 'zrender/src/Element'; import { getBorderColor, getColor } from './candlestickVisual'; +import { resolveNormalBoxClipping } from '../helper/whiskerBoxCommon'; const SKIP_PROPS = ['color', 'borderColor'] as const; class CandlestickView extends ChartView { - static readonly type = 'candlestick'; + static readonly type = SERIES_TYPE_CANDLESTICK; readonly type = CandlestickView.type; private _isLargeDraw: boolean; @@ -96,9 +99,10 @@ class CandlestickView extends ChartView { const group = this.group; const isSimpleBox = data.getLayout('isSimpleBox'); - const needsClip = seriesModel.get('clip', true); - const coord = seriesModel.coordinateSystem; - const clipArea = coord.getArea && coord.getArea(); + const needClip = seriesModel.get('clip', true); + const coordSys = seriesModel.coordinateSystem; + const clipArea = coordSys.getArea && coordSys.getArea(); + const clipPath = needClip && createClipPath(coordSys, false, seriesModel); // There is no old data only when first rendering or switching from // stream mode to normal mode, where previous elements should be removed. @@ -113,13 +117,20 @@ class CandlestickView extends ChartView { if (data.hasValue(newIdx)) { const itemLayout = data.getItemLayout(newIdx) as CandlestickItemLayout; - if (needsClip && isNormalBoxClipped(clipArea, itemLayout)) { + const clipKind = needClip + ? resolveNormalBoxClipping(clipArea, itemLayout) : SHAPE_CLIP_KIND_NOT_CLIPPED; + if (clipKind === SHAPE_CLIP_KIND_FULLY_CLIPPED) { return; } const el = createNormalBox(itemLayout, newIdx, transPointDim, true); graphic.initProps(el, {shape: {points: itemLayout.ends}}, seriesModel, newIdx); + // In some edge cases (e.g., single item with min/max set), the disappearance of + // items may confuse users if no clipping is applied. + // Consider performance of zr Element['clipPath'], only set to partially clipped elements. + updateClipPath(clipKind === SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, el, clipPath); + setBoxCommon(el, data, newIdx, isSimpleBox); group.add(el); @@ -137,7 +148,10 @@ class CandlestickView extends ChartView { } const itemLayout = data.getItemLayout(newIdx) as CandlestickItemLayout; - if (needsClip && isNormalBoxClipped(clipArea, itemLayout)) { + + const clipKind = needClip + ? resolveNormalBoxClipping(clipArea, itemLayout) : SHAPE_CLIP_KIND_NOT_CLIPPED; + if (clipKind === SHAPE_CLIP_KIND_FULLY_CLIPPED) { group.remove(el); return; } @@ -157,6 +171,9 @@ class CandlestickView extends ChartView { setBoxCommon(el, data, newIdx, isSimpleBox); + // See `updateClipPath` in `add`. + updateClipPath(clipKind === SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, el, clipPath); + group.add(el); data.setItemGraphicEl(newIdx, el); }) @@ -177,13 +194,7 @@ class CandlestickView extends ChartView { const clipPath = seriesModel.get('clip', true) ? createClipPath(seriesModel.coordinateSystem, false, seriesModel) : null; - if (clipPath) { - this.group.setClipPath(clipPath); - } - else { - this.group.removeClipPath(); - } - + updateClipPath(!!clipPath, this.group, clipPath); } _incrementalRenderNormal(params: StageHandlerProgressParams, seriesModel: CandlestickSeriesModel) { @@ -215,6 +226,7 @@ class CandlestickView extends ChartView { _clear() { this.group.removeAll(); + updateClipPath(false, this.group, null); this._data = null; } } @@ -283,18 +295,6 @@ function createNormalBox( }); } -function isNormalBoxClipped(clipArea: CoordinateSystemClipArea, itemLayout: CandlestickItemLayout) { - let clipped = true; - for (let i = 0; i < itemLayout.ends.length; i++) { - // If any point are in the region. - if (clipArea.contain(itemLayout.ends[i][0], itemLayout.ends[i][1])) { - clipped = false; - break; - } - } - return clipped; -} - function setBoxCommon(el: NormalBoxPath, data: SeriesData, dataIndex: number, isSimpleBox?: boolean) { const itemModel = data.getItemModel(dataIndex) as Model; diff --git a/src/chart/candlestick/candlestickLayout.ts b/src/chart/candlestick/candlestickLayout.ts index 6b6fa03a6e..67fd4e7100 100644 --- a/src/chart/candlestick/candlestickLayout.ts +++ b/src/chart/candlestick/candlestickLayout.ts @@ -19,14 +19,29 @@ import {subPixelOptimize} from '../../util/graphic'; import createRenderPlanner from '../helper/createRenderPlanner'; -import {parsePercent} from '../../util/number'; +import {mathMax, mathMin, parsePercent} from '../../util/number'; import {map, retrieve2} from 'zrender/src/core/util'; import { DimensionIndex, StageHandler, StageHandlerProgressParams } from '../../util/types'; -import CandlestickSeriesModel, { CandlestickDataItemOption } from './CandlestickSeries'; +import CandlestickSeriesModel, { SERIES_TYPE_CANDLESTICK, CandlestickDataItemOption } from './CandlestickSeries'; import SeriesData from '../../data/SeriesData'; import { RectLike } from 'zrender/src/core/BoundingRect'; import DataStore from '../../data/DataStore'; import { createFloat32Array } from '../../util/vendor'; +import { makeCallOnlyOnce } from '../../util/model'; +import { + requireAxisStatistics +} from '../../coord/axisStatistics'; +import { EChartsExtensionInstallRegisters } from '../../extension'; +import { registerAxisContainShapeHandler } from '../../coord/scaleRawExtentInfo'; +import { + createBandWidthBasedAxisContainShapeHandler, + getMetricsNonOrdinalLinearPositiveMinGap, + makeAxisStatKey +} from '../helper/axisSnippets'; +import { calcBandWidth } from '../../coord/axisBand'; + + +const callOnlyOnce = makeCallOnlyOnce(); export interface CandlestickItemLayout { sign: number @@ -40,9 +55,9 @@ export interface CandlestickLayoutMeta { isSimpleBox: boolean } -const candlestickLayout: StageHandler = { +export const candlestickLayout: StageHandler = { - seriesType: 'candlestick', + seriesType: SERIES_TYPE_CANDLESTICK, plan: createRenderPlanner(), @@ -87,8 +102,8 @@ const candlestickLayout: StageHandler = { const lowestVal = store.get(lowestDimI, dataIndex) as number; const highestVal = store.get(highestDimI, dataIndex) as number; - const ocLow = Math.min(openVal, closeVal); - const ocHigh = Math.max(openVal, closeVal); + const ocLow = mathMin(openVal, closeVal); + const ocHigh = mathMax(openVal, closeVal); const ocLowPoint = getPoint(ocLow, axisDimVal); const ocHighPoint = getPoint(ocHigh, axisDimVal); @@ -229,7 +244,7 @@ function getSign( ? 0 : (dataIndex > 0 // If close === open, compare with close of last record - ? (store.get(closeDimI, dataIndex - 1) <= closeVal ? 1 : -1) + ? ((store.get(closeDimI, dataIndex - 1) as number) <= closeVal ? 1 : -1) // No record of previous, set to be positive : 1 ); @@ -240,14 +255,11 @@ function getSign( function calculateCandleWidth(seriesModel: CandlestickSeriesModel, data: SeriesData) { const baseAxis = seriesModel.getBaseAxis(); - let extent; - const bandWidth = baseAxis.type === 'category' - ? baseAxis.getBandWidth() - : ( - extent = baseAxis.getExtent(), - Math.abs(extent[1] - extent[0]) / data.count() - ); + const bandWidth = calcBandWidth( + baseAxis, + {fromStat: {key: makeAxisStatKey(SERIES_TYPE_CANDLESTICK)}, min: 1} + ).w; const barMaxWidth = parsePercent( retrieve2(seriesModel.get('barMaxWidth'), bandWidth), @@ -262,7 +274,23 @@ function calculateCandleWidth(seriesModel: CandlestickSeriesModel, data: SeriesD return barWidth != null ? parsePercent(barWidth, bandWidth) // Put max outer to ensure bar visible in spite of overlap. - : Math.max(Math.min(bandWidth / 2, barMaxWidth), barMinWidth); + : mathMax(mathMin(bandWidth / 2, barMaxWidth), barMinWidth); } -export default candlestickLayout; +export function registerCandlestickAxisHandlers(registers: EChartsExtensionInstallRegisters) { + callOnlyOnce(registers, function () { + const axisStatKey = makeAxisStatKey(SERIES_TYPE_CANDLESTICK); + requireAxisStatistics( + registers, + { + key: axisStatKey, + seriesType: SERIES_TYPE_CANDLESTICK, + getMetrics: getMetricsNonOrdinalLinearPositiveMinGap + } + ); + registerAxisContainShapeHandler( + axisStatKey, + createBandWidthBasedAxisContainShapeHandler(axisStatKey) + ); + }); +} diff --git a/src/chart/candlestick/candlestickVisual.ts b/src/chart/candlestick/candlestickVisual.ts index a665ea3b69..6a3cb42a4d 100644 --- a/src/chart/candlestick/candlestickVisual.ts +++ b/src/chart/candlestick/candlestickVisual.ts @@ -19,7 +19,7 @@ import createRenderPlanner from '../helper/createRenderPlanner'; import { StageHandler } from '../../util/types'; -import CandlestickSeriesModel, { CandlestickDataItemOption } from './CandlestickSeries'; +import CandlestickSeriesModel, { SERIES_TYPE_CANDLESTICK, CandlestickDataItemOption } from './CandlestickSeries'; import Model from '../../model/Model'; import { extend } from 'zrender/src/core/util'; @@ -46,7 +46,7 @@ export function getBorderColor(sign: number, model: Model): string; } diff --git a/src/chart/custom/CustomView.ts b/src/chart/custom/CustomView.ts index 3665b1e93a..e8f30d6489 100644 --- a/src/chart/custom/CustomView.ts +++ b/src/chart/custom/CustomView.ts @@ -25,7 +25,10 @@ import * as graphicUtil from '../../util/graphic'; import { setDefaultStateProxy, toggleHoverEmphasis } from '../../util/states'; import * as labelStyleHelper from '../../label/labelStyle'; import {getDefaultLabel} from '../helper/labelHelper'; -import {getLayoutOnAxis} from '../../layout/barGrid'; +import { + BarGridLayoutOptionForCustomSeries, BarGridLayoutResultForCustomSeries, + computeBarLayoutForCustomSeries +} from '../../layout/barGrid'; import DataDiffer from '../../data/DataDiffer'; import Model from '../../model/Model'; import ChartView from '../../view/Chart'; @@ -43,7 +46,7 @@ import { ECElement, DisplayStateNonNormal, OrdinalRawValue, - InnerDecalObject + InnerDecalObject, } from '../../util/types'; import Element, { ElementTextConfig } from 'zrender/src/Element'; import prepareCartesian2d from '../../coord/cartesian/prepareCustom'; @@ -90,7 +93,7 @@ import CustomSeriesModel, { CustomPathOption, CustomRootElementOption, CustomSeriesOption, - CustomCompoundPathOption + CustomCompoundPathOption, } from './CustomSeries'; import { PatternObject } from 'zrender/src/graphic/Pattern'; import { @@ -106,6 +109,7 @@ import type SeriesModel from '../../model/Series'; import { getCustomSeries } from './customSeriesRegister'; import tokens from '../../visual/tokens'; + const EMPHASIS = 'emphasis' as const; const NORMAL = 'normal' as const; const BLUR = 'blur' as const; @@ -286,7 +290,7 @@ export default class CustomChartView extends ChartView { function setIncrementalAndHoverLayer(el: Displayable) { if (!el.isGroup) { el.incremental = true; - el.ensureState('emphasis').hoverLayer = true; + el.ensureState('emphasis').hoverLayer = graphicUtil.HOVER_LAYER_FOR_INCREMENTAL; } } for (let idx = params.start; idx < params.end; idx++) { @@ -916,11 +920,11 @@ function makeRenderItem( * @return If not support, return undefined. */ function barLayout( - opt: Omit[0], 'axis'> - ): ReturnType { + opt: BarGridLayoutOptionForCustomSeries + ): BarGridLayoutResultForCustomSeries { if (coordSys.type === 'cartesian2d') { const baseAxis = coordSys.getBaseAxis() as Axis2D; - return getLayoutOnAxis(defaults({axis: baseAxis}, opt)); + return computeBarLayoutForCustomSeries(defaults({axis: baseAxis}, opt)); } } diff --git a/src/chart/gauge/GaugeView.ts b/src/chart/gauge/GaugeView.ts index 1df6458a14..d7465e8a83 100644 --- a/src/chart/gauge/GaugeView.ts +++ b/src/chart/gauge/GaugeView.ts @@ -22,7 +22,7 @@ import * as graphic from '../../util/graphic'; import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; import {createTextStyle, setLabelValueAnimation, animateLabelValue} from '../../label/labelStyle'; import ChartView from '../../view/Chart'; -import {parsePercent, round, linearMap} from '../../util/number'; +import {parsePercent, round, linearMap, DEFAULT_PRECISION_FOR_ROUNDING_ERROR} from '../../util/number'; import GaugeSeriesModel, { GaugeDataItemOption } from './GaugeSeries'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; @@ -272,7 +272,7 @@ class GaugeView extends ChartView { const distance = labelModel.get('distance') + splitLineDistance; const label = formatLabel( - round(i / splitNumber * (maxVal - minVal) + minVal), + round(i / splitNumber * (maxVal - minVal) + minVal, DEFAULT_PRECISION_FOR_ROUNDING_ERROR), labelModel.get('formatter') ); const autoColor = getColor(i / splitNumber); diff --git a/src/chart/heatmap/HeatmapView.ts b/src/chart/heatmap/HeatmapView.ts index 4e21c179e6..403332025d 100644 --- a/src/chart/heatmap/HeatmapView.ts +++ b/src/chart/heatmap/HeatmapView.ts @@ -35,6 +35,7 @@ import type Calendar from '../../coord/calendar/Calendar'; import { setLabelStyle, getLabelStatesModels } from '../../label/labelStyle'; import type Element from 'zrender/src/Element'; import type Matrix from '../../coord/matrix/Matrix'; +import { calcBandWidth } from '../../coord/axisBand'; // Coord can be 'geo' 'bmap' 'amap' 'leaflet'... interface GeoLikeCoordSys extends CoordinateSystem { @@ -199,8 +200,8 @@ class HeatmapView extends ChartView { } // add 0.5px to avoid the gaps - width = xAxis.getBandWidth() + .5; - height = yAxis.getBandWidth() + .5; + width = calcBandWidth(xAxis).w + .5; + height = calcBandWidth(yAxis).w + .5; xAxisExtent = xAxis.scale.getExtent(); yAxisExtent = yAxis.scale.getExtent(); } @@ -241,10 +242,10 @@ class HeatmapView extends ChartView { if (isNaN(data.get(dataDims[2], idx) as number) || isNaN(dataDimX as number) || isNaN(dataDimY as number) - || dataDimX < xAxisExtent[0] - || dataDimX > xAxisExtent[1] - || dataDimY < yAxisExtent[0] - || dataDimY > yAxisExtent[1] + || (dataDimX as number) < xAxisExtent[0] + || (dataDimX as number) > xAxisExtent[1] + || (dataDimY as number) < yAxisExtent[0] + || (dataDimY as number) > yAxisExtent[1] ) { continue; } @@ -345,7 +346,7 @@ class HeatmapView extends ChartView { // PENDING if (incremental) { // Rect must use hover layer if it's incremental. - rect.states.emphasis.hoverLayer = true; + rect.states.emphasis.hoverLayer = graphic.HOVER_LAYER_FOR_INCREMENTAL; } group.add(rect); diff --git a/src/chart/helper/Line.ts b/src/chart/helper/Line.ts index 07748206ab..647b5c1d3a 100644 --- a/src/chart/helper/Line.ts +++ b/src/chart/helper/Line.ts @@ -303,7 +303,9 @@ class Line extends graphic.Group { defaultText: (rawVal == null ? lineData.getName(idx) : isFinite(rawVal) - ? round(rawVal) + // PENDING: the `rawVal` is not supposed to be rounded. But this rounding was introduced + // in the early stages, so changing it would likely be breaking. + ? round(rawVal, 10) : rawVal) + '' }); const label = this.getTextContent() as InnerLineLabel; diff --git a/src/chart/helper/LineDraw.ts b/src/chart/helper/LineDraw.ts index 94c417f4f7..4e0a83ee1d 100644 --- a/src/chart/helper/LineDraw.ts +++ b/src/chart/helper/LineDraw.ts @@ -172,7 +172,7 @@ class LineDraw { function updateIncrementalAndHover(el: Displayable) { if (!el.isGroup && !isEffectObject(el)) { el.incremental = true; - el.ensureState('emphasis').hoverLayer = true; + el.ensureState('emphasis').hoverLayer = graphic.HOVER_LAYER_FOR_INCREMENTAL; } } diff --git a/src/chart/helper/SymbolDraw.ts b/src/chart/helper/SymbolDraw.ts index 93e13848e1..5889b1af6c 100644 --- a/src/chart/helper/SymbolDraw.ts +++ b/src/chart/helper/SymbolDraw.ts @@ -292,7 +292,7 @@ class SymbolDraw { function updateIncrementalAndHover(el: Displayable) { if (!el.isGroup) { el.incremental = true; - el.ensureState('emphasis').hoverLayer = true; + el.ensureState('emphasis').hoverLayer = graphic.HOVER_LAYER_FOR_INCREMENTAL; } } for (let idx = taskParams.start; idx < taskParams.end; idx++) { diff --git a/src/chart/helper/axisSnippets.ts b/src/chart/helper/axisSnippets.ts new file mode 100644 index 0000000000..66bcdf53b6 --- /dev/null +++ b/src/chart/helper/axisSnippets.ts @@ -0,0 +1,63 @@ +/* +* 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 type Axis from '../../coord/Axis'; +import { calcBandWidth } from '../../coord/axisBand'; +import { AXIS_STAT_KEY_DELIMITER, AxisStatKey, AxisStatMetrics } from '../../coord/axisStatistics'; +import { CoordinateSystem } from '../../coord/CoordinateSystem'; +import { AxisContainShapeHandler } from '../../coord/scaleRawExtentInfo'; +import { isOrdinalScale } from '../../scale/helper'; +import { isNullableNumberFinite } from '../../util/number'; +import { ComponentSubType } from '../../util/types'; + + +/** + * Require `requireAxisStatistics`. + */ +export function createBandWidthBasedAxisContainShapeHandler(axisStatKey: AxisStatKey): AxisContainShapeHandler { + return function (axis, scale, ecModel) { + const bandWidthResult = calcBandWidth(axis, {fromStat: {key: axisStatKey}}); + const invRatio = (bandWidthResult.fromStat || {}).invRatio; + if (isNullableNumberFinite(invRatio) && isNullableNumberFinite(bandWidthResult.w)) { + return [-bandWidthResult.w / 2 * invRatio, bandWidthResult.w / 2 * invRatio]; + } + }; +} + + +/** + * A pre-built `makeAxisStatKey`. + * See `makeAxisStatKey2`. Use two functions rather than a optional parameter to impose checking. + */ +export function makeAxisStatKey(seriesType: ComponentSubType): AxisStatKey { + return (seriesType + AXIS_STAT_KEY_DELIMITER) as AxisStatKey; +} +export function makeAxisStatKey2(seriesType: ComponentSubType, coordSysType: CoordinateSystem['type']): AxisStatKey { + return (seriesType + AXIS_STAT_KEY_DELIMITER + coordSysType) as AxisStatKey; +} + +/** + * A pre-built `getMetrics`. + */ +export function getMetricsNonOrdinalLinearPositiveMinGap(axis: Axis): AxisStatMetrics { + return { + // non-category scale do not use `liPosMinGap` to calculate `bandWidth`. + liPosMinGap: !isOrdinalScale(axis.scale) + }; +}; diff --git a/src/chart/helper/createClipPathFromCoordSys.ts b/src/chart/helper/createClipPathFromCoordSys.ts index 191ac95c62..8f9bc3f937 100644 --- a/src/chart/helper/createClipPathFromCoordSys.ts +++ b/src/chart/helper/createClipPathFromCoordSys.ts @@ -20,16 +20,18 @@ import * as graphic from '../../util/graphic'; import {round} from '../../util/number'; import SeriesModel from '../../model/Series'; -import { SeriesOption } from '../../util/types'; +import { NullUndefined, SeriesOption } from '../../util/types'; import type Cartesian2D from '../../coord/cartesian/Cartesian2D'; import type Polar from '../../coord/polar/Polar'; import { CoordinateSystem } from '../../coord/CoordinateSystem'; -import { isFunction } from 'zrender/src/core/util'; +import { assert, isFunction } from 'zrender/src/core/util'; +import type Element from 'zrender/src/Element'; + type SeriesModelWithLineWidth = SeriesModel; -function createGridClipPath( +export function createGridClipPath( cartesian: Cartesian2D, hasAnimation: boolean, seriesModel: SeriesModelWithLineWidth, @@ -104,7 +106,7 @@ function createGridClipPath( return clipPath; } -function createPolarClipPath( +export function createPolarClipPath( polar: Polar, hasAnimation: boolean, seriesModel: SeriesModelWithLineWidth @@ -146,7 +148,7 @@ function createPolarClipPath( return clipPath; } -function createClipPath( +export function createClipPath( coordSys: CoordinateSystem, hasAnimation: boolean, seriesModel: SeriesModelWithLineWidth, @@ -165,8 +167,26 @@ function createClipPath( return null; } -export { - createGridClipPath, - createPolarClipPath, - createClipPath -}; +export type ShapeClipKind = + typeof SHAPE_CLIP_KIND_NOT_CLIPPED + | typeof SHAPE_CLIP_KIND_PARTIALLY_CLIPPED + | typeof SHAPE_CLIP_KIND_FULLY_CLIPPED; +export const SHAPE_CLIP_KIND_NOT_CLIPPED = 0; +export const SHAPE_CLIP_KIND_PARTIALLY_CLIPPED = 1; +export const SHAPE_CLIP_KIND_FULLY_CLIPPED = 2; + +export function updateClipPath( + clip: boolean, + symbolEl: Element, + clipPath: graphic.Path | NullUndefined +): void { + if (clip) { + if (__DEV__) { + assert(clipPath); + } + symbolEl.setClipPath(clipPath); + } + else { + symbolEl.removeClipPath(); + } +} diff --git a/src/chart/helper/createSeriesData.ts b/src/chart/helper/createSeriesData.ts index a154906435..7ee0f55399 100644 --- a/src/chart/helper/createSeriesData.ts +++ b/src/chart/helper/createSeriesData.ts @@ -23,7 +23,7 @@ import prepareSeriesDataSchema from '../../data/helper/createDimensions'; import {getDimensionTypeByAxis} from '../../data/helper/dimensionHelper'; import {getDataItemValue} from '../../util/model'; import CoordinateSystem from '../../core/CoordinateSystem'; -import {getCoordSysInfoBySeries} from '../../model/referHelper'; +import {getCoordSysInfoBySeries, SeriesModelCoordSysInfo} from '../../model/referHelper'; import { createSourceFromSeriesDataOption, Source } from '../../data/Source'; import {enableDataStack} from '../../data/helper/dataStackHelper'; import {makeSeriesEncodeForAxisCoordSys} from '../../data/helper/sourceHelper'; @@ -40,7 +40,7 @@ import SeriesDimensionDefine from '../../data/SeriesDimensionDefine'; function getCoordSysDimDefs( seriesModel: SeriesModel, - coordSysInfo: ReturnType + coordSysInfo: SeriesModelCoordSysInfo ) { const coordSysName = seriesModel.get('coordinateSystem'); const registeredCoordSys = CoordinateSystem.get(coordSysName); diff --git a/src/chart/helper/whiskerBoxCommon.ts b/src/chart/helper/whiskerBoxCommon.ts index 294fd0eae2..8839b59e05 100644 --- a/src/chart/helper/whiskerBoxCommon.ts +++ b/src/chart/helper/whiskerBoxCommon.ts @@ -21,13 +21,18 @@ import createSeriesDataSimply from './createSeriesDataSimply'; import * as zrUtil from 'zrender/src/core/util'; import {getDimensionTypeByAxis} from '../../data/helper/dimensionHelper'; import {makeSeriesEncodeForAxisCoordSys} from '../../data/helper/sourceHelper'; -import type { SeriesOption, SeriesOnCartesianOptionMixin, LayoutOrient } from '../../util/types'; +import type { SeriesOption, SeriesOnCartesianOptionMixin, LayoutOrient, NullUndefined } from '../../util/types'; import type GlobalModel from '../../model/Global'; import type SeriesModel from '../../model/Series'; import type CartesianAxisModel from '../../coord/cartesian/AxisModel'; import type SeriesData from '../../data/SeriesData'; import type Axis2D from '../../coord/cartesian/Axis2D'; import { CoordDimensionDefinition } from '../../data/helper/createDimensions'; +import { CoordinateSystemClipArea } from '../../coord/CoordinateSystem'; +import { + SHAPE_CLIP_KIND_FULLY_CLIPPED, SHAPE_CLIP_KIND_NOT_CLIPPED, SHAPE_CLIP_KIND_PARTIALLY_CLIPPED, + ShapeClipKind +} from './createClipPathFromCoordSys'; interface CommonOption extends SeriesOption, SeriesOnCartesianOptionMixin { // - 'horizontal': Multiple whisker boxes (each drawn vertically) @@ -44,8 +49,8 @@ interface DataItemOption { value?: number[] } -interface WhiskerBoxCommonMixin extends SeriesModel{} -class WhiskerBoxCommonMixin { +export interface WhiskerBoxCommonMixin extends SeriesModel{} +export class WhiskerBoxCommonMixin { private _baseAxisDim: string; @@ -184,5 +189,26 @@ class WhiskerBoxCommonMixin { }; - -export { WhiskerBoxCommonMixin }; +/** + * PENDING: We do not use zr Element clipPath due to performance consideration, + * although it may be further optimized. + */ +export function resolveNormalBoxClipping( + clipArea: CoordinateSystemClipArea, + itemLayout: { + ends: number[][]; + } +): ShapeClipKind { + const count = itemLayout.ends.length; + let containCount = 0; + for (let i = 0; i < count; i++) { + // clip if any points is out of the area, otherwise the shape may partially + // out of the coord sys area and overlap with axis labels. + if (clipArea.contain(itemLayout.ends[i][0], itemLayout.ends[i][1])) { + containCount++; + } + } + return !containCount ? SHAPE_CLIP_KIND_FULLY_CLIPPED + : containCount < count ? SHAPE_CLIP_KIND_PARTIALLY_CLIPPED + : SHAPE_CLIP_KIND_NOT_CLIPPED; +} diff --git a/src/chart/line/LineSeries.ts b/src/chart/line/LineSeries.ts index 0cd0c9ef83..88dbdb4044 100644 --- a/src/chart/line/LineSeries.ts +++ b/src/chart/line/LineSeries.ts @@ -209,7 +209,7 @@ class LineSeriesModel extends SeriesModel { // follow the label interval strategy. showAllSymbol: 'auto', - // Whether to connect break point. + // Whether to connect break point. (non-finite values) connectNulls: false, // Sampling for large data. Can be: 'average', 'max', 'min', 'sum', 'lttb'. diff --git a/src/chart/line/LineView.ts b/src/chart/line/LineView.ts index 3682d96512..fd7934e52a 100644 --- a/src/chart/line/LineView.ts +++ b/src/chart/line/LineView.ts @@ -27,7 +27,7 @@ import * as graphic from '../../util/graphic'; import * as modelUtil from '../../util/model'; import { ECPolyline, ECPolygon } from './poly'; import ChartView from '../../view/Chart'; -import { prepareDataCoordInfo, getStackedOnPoint } from './helper'; +import { prepareDataCoordInfo, getStackedOnPoint, isPointIllegal } from './helper'; import { createGridClipPath, createPolarClipPath } from '../helper/createClipPathFromCoordSys'; import LineSeriesModel, { LineSeriesOption } from './LineSeries'; import type GlobalModel from '../../model/Global'; @@ -84,42 +84,33 @@ function isPointsSame(points1: ArrayLike, points2: ArrayLike) { return true; } -function bboxFromPoints(points: ArrayLike) { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; +function xyExtentFromPoints(points: ArrayLike) { + const xExtent = modelUtil.initExtentForUnion(); + const yExtent = modelUtil.initExtentForUnion(); for (let i = 0; i < points.length;) { const x = points[i++]; const y = points[i++]; - if (!isNaN(x)) { - minX = Math.min(x, minX); - maxX = Math.max(x, maxX); - } - if (!isNaN(y)) { - minY = Math.min(y, minY); - maxY = Math.max(y, maxY); + if (!isPointIllegal(x, y)) { + modelUtil.unionExtentFromNumber(xExtent, x); + modelUtil.unionExtentFromNumber(yExtent, y); } } - return [ - [minX, minY], - [maxX, maxY] - ]; + return [xExtent, yExtent]; } function getBoundingDiff(points1: ArrayLike, points2: ArrayLike): number { - const [min1, max1] = bboxFromPoints(points1); - const [min2, max2] = bboxFromPoints(points2); + const [xExtent1, yExtent1] = xyExtentFromPoints(points1); + const [xExtent2, yExtent2] = xyExtentFromPoints(points2); // Get a max value from each corner of two boundings. return Math.max( - Math.abs(min1[0] - min2[0]), - Math.abs(min1[1] - min2[1]), + Math.abs(xExtent1[0] - xExtent2[0]), + Math.abs(yExtent1[0] - yExtent2[0]), - Math.abs(max1[0] - max2[0]), - Math.abs(max1[1] - max2[1]) + Math.abs(xExtent1[1] - xExtent2[1]), + Math.abs(yExtent1[1] - yExtent2[1]) ); } @@ -181,7 +172,7 @@ function turnPointsIntoStep( * should stay the same as the lines above. See #20021 */ const reference = basePoints || points; - if (!isNaN(reference[i]) && !isNaN(reference[i + 1])) { + if (!isPointIllegal(reference[i], reference[i + 1])) { filteredPoints.push(points[i], points[i + 1]); } } @@ -408,7 +399,7 @@ function getIsIgnoreFunc( zrUtil.each(categoryAxis.getViewLabels(), function (labelItem) { const ordinalNumber = (categoryAxis.scale as OrdinalScale) - .getRawOrdinalNumber(labelItem.tickValue); + .getRawOrdinalNumber(labelItem.tick.value); labelMap[ordinalNumber] = 1; }); @@ -447,15 +438,10 @@ function canShowAllSymbolForCategory( return true; } - -function isPointNull(x: number, y: number) { - return isNaN(x) || isNaN(y); -} - function getLastIndexNotNull(points: ArrayLike) { let len = points.length / 2; for (; len > 0; len--) { - if (!isPointNull(points[len * 2 - 2], points[len * 2 - 1])) { + if (!isPointIllegal(points[len * 2 - 2], points[len * 2 - 1])) { break; } } @@ -477,7 +463,7 @@ function getIndexRange(points: ArrayLike, xOrY: number, dim: 'x' | 'y') let nextIndex = -1; for (let i = 0; i < len; i++) { b = points[i * 2 + dimIdx]; - if (isNaN(b) || isNaN(points[i * 2 + 1 - dimIdx])) { + if (isPointIllegal(b, points[i * 2 + 1 - dimIdx])) { continue; } if (i === 0) { @@ -965,7 +951,7 @@ class LineView extends ChartView { // Create a temporary symbol if it is not exists const x = points[dataIndex * 2]; const y = points[dataIndex * 2 + 1]; - if (isNaN(x) || isNaN(y)) { + if (isPointIllegal(x, y)) { // Null data return; } diff --git a/src/chart/line/helper.ts b/src/chart/line/helper.ts index 13a1c2c01a..ba04dba5cd 100644 --- a/src/chart/line/helper.ts +++ b/src/chart/line/helper.ts @@ -132,3 +132,13 @@ export function getStackedOnPoint( return coordSys.dataToPoint(stackedData); } + +export function isPointIllegal(xOrY: number, yOrX: number) { + // NOTE: + // - `NaN` point x/y may be generated by, e.g., + // original series data `NaN`, '-', `null`, `undefined`, + // negative values in LogScale. + // - `Infinite` point x/y may be generated by, e.g., + // original series data `Infinite`, `0` in LogScale. + return !isFinite(xOrY) || !isFinite(yOrX); +} diff --git a/src/chart/line/poly.ts b/src/chart/line/poly.ts index aa6826de8d..10ca2a7472 100644 --- a/src/chart/line/poly.ts +++ b/src/chart/line/poly.ts @@ -23,14 +23,11 @@ import Path, { PathProps } from 'zrender/src/graphic/Path'; import PathProxy from 'zrender/src/core/PathProxy'; import { cubicRootAt, cubicAt } from 'zrender/src/core/curve'; import tokens from '../../visual/tokens'; +import { isPointIllegal } from './helper'; const mathMin = Math.min; const mathMax = Math.max; -function isPointNull(x: number, y: number) { - return isNaN(x) || isNaN(y); -} - /** * Draw smoothed line in non-monotone, in may cause undesired curve in extreme * situations. This should be used when points are non-monotone neither in x or @@ -63,7 +60,7 @@ function drawSegment( if (idx >= allLen || idx < 0) { break; } - if (isPointNull(x, y)) { + if (isPointIllegal(x, y)) { if (connectNulls) { idx += dir; continue; @@ -106,7 +103,7 @@ function drawSegment( let tmpK = k + 1; if (connectNulls) { // Find next point not null - while (isPointNull(nextX, nextY) && tmpK < segLen) { + while (isPointIllegal(nextX, nextY) && tmpK < segLen) { tmpK++; nextIdx += dir; nextX = points[nextIdx * 2]; @@ -120,7 +117,7 @@ function drawSegment( let nextCpx0; let nextCpy0; // Is last point - if (tmpK >= segLen || isPointNull(nextX, nextY)) { + if (tmpK >= segLen || isPointIllegal(nextX, nextY)) { cpx1 = x; cpy1 = y; } @@ -256,12 +253,12 @@ export class ECPolyline extends Path { if (shape.connectNulls) { // Must remove first and last null values avoid draw error in polygon for (; len > 0; len--) { - if (!isPointNull(points[len * 2 - 2], points[len * 2 - 1])) { + if (!isPointIllegal(points[len * 2 - 2], points[len * 2 - 1])) { break; } } for (; i < len; i++) { - if (!isPointNull(points[i * 2], points[i * 2 + 1])) { + if (!isPointIllegal(points[i * 2], points[i * 2 + 1])) { break; } } @@ -380,12 +377,12 @@ export class ECPolygon extends Path { if (shape.connectNulls) { // Must remove first and last null values avoid draw error in polygon for (; len > 0; len--) { - if (!isPointNull(points[len * 2 - 2], points[len * 2 - 1])) { + if (!isPointIllegal(points[len * 2 - 2], points[len * 2 - 1])) { break; } } for (; i < len; i++) { - if (!isPointNull(points[i * 2], points[i * 2 + 1])) { + if (!isPointIllegal(points[i * 2], points[i * 2 + 1])) { break; } } diff --git a/src/chart/map/MapSeries.ts b/src/chart/map/MapSeries.ts index 90a8299df1..8ede0acd34 100644 --- a/src/chart/map/MapSeries.ts +++ b/src/chart/map/MapSeries.ts @@ -47,7 +47,7 @@ import { createTooltipMarkup } from '../../component/tooltip/tooltipMarkup'; import {createSymbol, ECSymbol} from '../../util/symbol'; import {LegendIconParams} from '../../component/legend/LegendModel'; import {Group} from '../../util/graphic'; -import { CoordinateSystemUsageKind, decideCoordSysUsageKind } from '../../core/CoordinateSystem'; +import { COORD_SYS_USAGE_KIND_BOX, decideCoordSysUsageKind } from '../../core/CoordinateSystem'; import { GeoJSONRegion } from '../../coord/geo/Region'; import tokens from '../../visual/tokens'; @@ -164,8 +164,8 @@ class MapSeries extends SeriesModel { * inner exclusive geo model. */ getHostGeoModel(): GeoModel { - if (decideCoordSysUsageKind(this).kind === CoordinateSystemUsageKind.boxCoordSys) { - // Always use an internal geo if specify a boxCoordSys. + if (decideCoordSysUsageKind(this).kind === COORD_SYS_USAGE_KIND_BOX) { + // Always use an internal geo if specify as `COORD_SYS_USAGE_KIND_BOX`. // Notice that currently we do not support laying out a geo based on // another geo, but preserve the possibility. return; diff --git a/src/chart/sankey/sankeyLayout.ts b/src/chart/sankey/sankeyLayout.ts index 8e9c8d22bb..4b405925b1 100644 --- a/src/chart/sankey/sankeyLayout.ts +++ b/src/chart/sankey/sankeyLayout.ts @@ -25,6 +25,7 @@ import { GraphNode, GraphEdge } from '../../data/Graph'; import { LayoutOrient } from '../../util/types'; import GlobalModel from '../../model/Global'; import { createBoxLayoutReference, getLayoutRect } from '../../util/layout'; +import { asc } from '../../util/number'; export default function sankeyLayout(ecModel: GlobalModel, api: ExtensionAPI) { @@ -290,9 +291,7 @@ function prepareNodesByBreadth(nodes: GraphNode[], orient: LayoutOrient) { const groupResult = groupData(nodes, function (node) { return node.getLayout()[keyAttr] as number; }); - groupResult.keys.sort(function (a, b) { - return a - b; - }); + asc(groupResult.keys); zrUtil.each(groupResult.keys, function (key) { nodesByBreadth.push(groupResult.buckets.get(key)); }); diff --git a/src/chart/scatter/jitterLayout.ts b/src/chart/scatter/jitterLayout.ts index ad2c2e9baf..1da8ce195e 100644 --- a/src/chart/scatter/jitterLayout.ts +++ b/src/chart/scatter/jitterLayout.ts @@ -23,6 +23,8 @@ import type SingleAxis from '../../coord/single/SingleAxis'; import type Axis2D from '../../coord/cartesian/Axis2D'; import type { StageHandler } from '../../util/types'; import createRenderPlanner from '../helper/createRenderPlanner'; +import { COORD_SYS_TYPE_CARTESIAN_2D } from '../../coord/cartesian/GridModel'; +import { COORD_SYS_TYPE_SINGLE_AXIS } from '../../coord/single/AxisModel'; export default function jitterLayout(): StageHandler { return { @@ -32,7 +34,10 @@ export default function jitterLayout(): StageHandler { reset(seriesModel: ScatterSeriesModel) { const coordSys = seriesModel.coordinateSystem; - if (!coordSys || (coordSys.type !== 'cartesian2d' && coordSys.type !== 'single')) { + if (!coordSys || ( + coordSys.type !== COORD_SYS_TYPE_CARTESIAN_2D + && coordSys.type !== COORD_SYS_TYPE_SINGLE_AXIS + )) { return; } const baseAxis = coordSys.getBaseAxis && coordSys.getBaseAxis() as Axis2D | SingleAxis; diff --git a/src/chart/treemap/treemapLayout.ts b/src/chart/treemap/treemapLayout.ts index 5956a3ca75..0862bf8aac 100644 --- a/src/chart/treemap/treemapLayout.ts +++ b/src/chart/treemap/treemapLayout.ts @@ -38,6 +38,7 @@ import ExtensionAPI from '../../core/ExtensionAPI'; import { TreeNode } from '../../data/Tree'; import Model from '../../model/Model'; import { TreemapRenderPayload, TreemapMovePayload, TreemapZoomToNodePayload } from './treemapAction'; +import { initExtentForUnion } from '../../util/model'; import { RoamOptionMixin } from '../../util/types'; import { clampByZoomLimit } from '../../component/helper/roamHelper'; @@ -458,7 +459,7 @@ function statistic( } // Other dimension. else { - dataExtent = [Infinity, -Infinity]; + dataExtent = initExtentForUnion(); each(children, function (child) { const value = child.getValue(dimension) as number; value < dataExtent[0] && (dataExtent[0] = value); diff --git a/src/component/axis/AngleAxisView.ts b/src/component/axis/AngleAxisView.ts index a694c4e491..95dbb59003 100644 --- a/src/component/axis/AngleAxisView.ts +++ b/src/component/axis/AngleAxisView.ts @@ -101,8 +101,8 @@ class AngleAxisView extends AxisView { labelItem = zrUtil.clone(labelItem); const scale = angleAxis.scale; const tickValue = scale.type === 'ordinal' - ? (scale as OrdinalScale).getRawOrdinalNumber(labelItem.tickValue) - : labelItem.tickValue; + ? (scale as OrdinalScale).getRawOrdinalNumber(labelItem.tick.value) + : labelItem.tick.value; labelItem.coord = angleAxis.dataToCoord(tickValue); return labelItem; }); @@ -250,7 +250,7 @@ const angelAxisElementsBuilders: Record(); const getTickInner = makeInner<{ - onBand: AxisTickCoord['onBand'] - tickValue: AxisTickCoord['tickValue'] + onBand: AxisTickCoord['onBand']; + tickValue: AxisTickCoord['tickValue']; }, graphic.Line>(); @@ -709,7 +711,7 @@ const builders: Record = { style: lineStyle, }; - if (axisModel.get(['axisLine', 'breakLine']) && axisModel.axis.scale.hasBreaks()) { + if (axisModel.get(['axisLine', 'breakLine']) && hasBreaks(axisModel.axis.scale)) { getAxisBreakHelper()!.buildAxisBreakLine(axisModel, group, transformGroup, pathBaseProp); } else { @@ -1061,7 +1063,10 @@ function fixMinMaxLabelShow( labelLayoutList: LabelLayoutData[], optionHideOverlap: AxisBaseOption['axisLabel']['hideOverlap'] ) { - if (shouldShowAllLabels(axisModel.axis)) { + const axis = axisModel.axis; + const customValuesOption = axisModel.get(['axisLabel', 'customValues']); + + if (shouldShowAllLabels(axis)) { return; } @@ -1070,7 +1075,7 @@ function fixMinMaxLabelShow( // Assert no ignore in labels. function deal( - showMinMaxLabel: boolean, + showMinMaxLabelOption: AxisShowMinMaxLabelOption, outmostLabelIdx: number, innerLabelIdx: number, ) { @@ -1079,8 +1084,18 @@ function fixMinMaxLabelShow( if (!outmostLabelLayout || !innerLabelLayout) { return; } + if (showMinMaxLabelOption == null) { + if (!optionHideOverlap && customValuesOption) { + // In this case, users are unlikely to expect labels to be hidden. + return; + } + if (isTimeScale(axis.scale) && getLabelInner(outmostLabelLayout.label).labelInfo.tick.notNice) { + // TimeScale does not expand extent to "nice", so eliminate labels that are not nice. + ignoreEl(outmostLabelLayout.label); + } + } - if (showMinMaxLabel === false || outmostLabelLayout.suggestIgnore) { + if (showMinMaxLabelOption === false || outmostLabelLayout.suggestIgnore) { ignoreEl(outmostLabelLayout.label); return; } @@ -1107,7 +1122,7 @@ function fixMinMaxLabelShow( innerLabelLayout = newLabelLayoutWithGeometry({marginForce}, innerLabelLayout); } if (labelIntersect(outmostLabelLayout, innerLabelLayout, null, {touchThreshold})) { - if (showMinMaxLabel) { + if (showMinMaxLabelOption) { ignoreEl(innerLabelLayout.label); } else { @@ -1119,11 +1134,11 @@ function fixMinMaxLabelShow( // If min or max are user set, we need to check // If the tick on min(max) are overlap on their neighbour tick // If they are overlapped, we need to hide the min(max) tick label - const showMinLabel = axisModel.get(['axisLabel', 'showMinLabel']); - const showMaxLabel = axisModel.get(['axisLabel', 'showMaxLabel']); + const showMinLabelOption = axisModel.get(['axisLabel', 'showMinLabel']); + const showMaxLabelOption = axisModel.get(['axisLabel', 'showMaxLabel']); const labelsLen = labelLayoutList.length; - deal(showMinLabel, 0, 1); - deal(showMaxLabel, labelsLen - 1, labelsLen - 2); + deal(showMinLabelOption, 0, 1); + deal(showMaxLabelOption, labelsLen - 1, labelsLen - 2); } // PENDING: Is it necessary to display a tick while the corresponding label is ignored? @@ -1133,7 +1148,7 @@ function syncLabelIgnoreToMajorTicks( tickEls: graphic.Line[], ) { if (cfg.showMinorTicks) { - // It probably unreaasonable to hide major ticks when show minor ticks. + // It probably unreasonable to hide major ticks when show minor ticks. return; } each(labelLayoutList, labelLayout => { @@ -1146,7 +1161,7 @@ function syncLabelIgnoreToMajorTicks( const labelInner = getLabelInner(labelLayout.label); if (tickInner.tickValue != null && !tickInner.onBand - && tickInner.tickValue === labelInner.tickValue + && tickInner.tickValue === labelInner.labelInfo.tick.value ) { ignoreEl(tickEl); return; @@ -1355,9 +1370,11 @@ function buildAxisLabel( let z2Max = -Infinity; each(labels, function (labelItem, index) { + const labelItemTick = labelItem.tick; + const labelItemTickValue = labelItemTick.value; const tickValue = axis.scale.type === 'ordinal' - ? (axis.scale as OrdinalScale).getRawOrdinalNumber(labelItem.tickValue) - : labelItem.tickValue; + ? (axis.scale as OrdinalScale).getRawOrdinalNumber(labelItemTickValue) + : labelItemTickValue; const formattedLabel = labelItem.formattedLabel; const rawLabel = labelItem.rawLabel; @@ -1396,7 +1413,7 @@ function buildAxisLabel( itemLabelModel.getShallow('verticalAlignMaxLabel', true), verticalAlign ); - const z2 = 10 + (labelItem.time?.level || 0); + const z2 = 10 + (labelItemTick.time?.level || 0); z2Min = Math.min(z2Min, z2); z2Max = Math.max(z2Max, z2); @@ -1443,8 +1460,7 @@ function buildAxisLabel( textEl.anid = 'label_' + tickValue; const inner = getLabelInner(textEl); - inner.break = labelItem.break; - inner.tickValue = tickValue; + inner.labelInfo = labelItem; inner.layoutRotation = labelLayout.rotation; graphic.setTooltipConfig({ @@ -1464,11 +1480,13 @@ function buildAxisLabel( eventData.targetType = 'axisLabel'; eventData.value = rawLabel; eventData.tickIndex = index; - if (labelItem.break) { + const labelItemTickBreak = labelItem.tick.break; + if (labelItemTickBreak) { + const labelItemTickBreakParsedBreak = labelItemTickBreak.parsedBreak; eventData.break = { // type: labelItem.break.type, - start: labelItem.break.parsedBreak.vmin, - end: labelItem.break.parsedBreak.vmax, + start: labelItemTickBreakParsedBreak.vmin, + end: labelItemTickBreakParsedBreak.vmax, }; } if (axis.type === 'category') { @@ -1477,8 +1495,8 @@ function buildAxisLabel( getECData(textEl).eventData = eventData; - if (labelItem.break) { - addBreakEventHandler(axisModel, api, textEl, labelItem.break); + if (labelItemTickBreak) { + addBreakEventHandler(axisModel, api, textEl, labelItemTickBreak); } } @@ -1488,7 +1506,7 @@ function buildAxisLabel( const labelLayoutList = map(labelEls, label => ({ label, - priority: getLabelInner(label).break + priority: getLabelInner(label).labelInfo.tick.break ? label.z2 + (z2Max - z2Min + 1) // Make break labels be highest priority. : label.z2, defaultAttr: { @@ -1537,7 +1555,7 @@ function updateAxisLabelChangableProps( labelEl.ignore = false; copyTransform(_tmpLayoutEl, _tmpLayoutElReset); - _tmpLayoutEl.x = axisModel.axis.dataToCoord(inner.tickValue); + _tmpLayoutEl.x = axisModel.axis.dataToCoord(inner.labelInfo.tick.value); _tmpLayoutEl.y = cfg.labelOffset + cfg.labelDirection * labelMargin; _tmpLayoutEl.rotation = inner.layoutRotation; @@ -1590,7 +1608,7 @@ function adjustBreakLabels( } const breakLabelIndexPairs = scaleBreakHelper.retrieveAxisBreakPairs( labelLayoutList, - layoutInfo => layoutInfo && getLabelInner(layoutInfo.label).break, + layoutInfo => layoutInfo && getLabelInner(layoutInfo.label).labelInfo.tick.break, true ); const moveOverlap = axisModel.get(['breakLabelLayout', 'moveOverlap'], true); diff --git a/src/component/axis/axisSplitHelper.ts b/src/component/axis/axisSplitHelper.ts index 7679bbd1e7..9e4e610da1 100644 --- a/src/component/axis/axisSplitHelper.ts +++ b/src/component/axis/axisSplitHelper.ts @@ -26,6 +26,7 @@ import type CartesianAxisView from './CartesianAxisView'; import type SingleAxisModel from '../../coord/single/AxisModel'; import type CartesianAxisModel from '../../coord/cartesian/AxisModel'; import AxisView from './AxisView'; +import type { AxisBaseModel } from '../../coord/AxisBaseModel'; const inner = makeInner<{ // Hash map of color index @@ -35,7 +36,7 @@ const inner = makeInner<{ export function rectCoordAxisBuildSplitArea( axisView: SingleAxisView | CartesianAxisView, axisGroup: graphic.Group, - axisModel: SingleAxisModel | CartesianAxisModel, + axisModel: (SingleAxisModel | CartesianAxisModel) & AxisBaseModel, gridModel: GridModel | SingleAxisModel ) { const axis = axisModel.axis; @@ -44,8 +45,7 @@ export function rectCoordAxisBuildSplitArea( return; } - // TODO: TYPE - const splitAreaModel = (axisModel as CartesianAxisModel).getModel('splitArea'); + const splitAreaModel = axisModel.getModel('splitArea'); const areaStyleModel = splitAreaModel.getModel('areaStyle'); let areaColors = areaStyleModel.get('color'); @@ -107,7 +107,6 @@ export function rectCoordAxisBuildSplitArea( const tickValue = ticksCoords[i - 1].tickValue; tickValue != null && newSplitAreaColors.set(tickValue, colorIndex); - axisGroup.add(new graphic.Rect({ anid: tickValue != null ? 'area_' + tickValue : null, shape: { diff --git a/src/component/axisPointer/AxisPointerView.ts b/src/component/axisPointer/AxisPointerView.ts index fef625a518..956d5b79ca 100644 --- a/src/component/axisPointer/AxisPointerView.ts +++ b/src/component/axisPointer/AxisPointerView.ts @@ -31,7 +31,8 @@ class AxisPointerView extends ComponentView { render(globalAxisPointerModel: AxisPointerModel, ecModel: GlobalModel, api: ExtensionAPI) { const globalTooltipModel = ecModel.getComponent('tooltip') as TooltipModel; const triggerOn = globalAxisPointerModel.get('triggerOn') - || (globalTooltipModel && globalTooltipModel.get('triggerOn') || 'mousemove|click'); + // mousewheel can change view by dataZoom. + || (globalTooltipModel && globalTooltipModel.get('triggerOn') || 'mousemove|click|mousewheel'); // Register global listener in AxisPointerView to enable // AxisPointerView to be independent to Tooltip. diff --git a/src/component/axisPointer/BaseAxisPointer.ts b/src/component/axisPointer/BaseAxisPointer.ts index 5b91847b19..7179103010 100644 --- a/src/component/axisPointer/BaseAxisPointer.ts +++ b/src/component/axisPointer/BaseAxisPointer.ts @@ -33,6 +33,7 @@ import { VerticalAlign, HorizontalAlign, CommonAxisPointerOption } from '../../u import { PathProps } from 'zrender/src/graphic/Path'; import Model from '../../model/Model'; import { TextProps } from 'zrender/src/graphic/Text'; +import { calcBandWidth } from '../../coord/axisBand'; const inner = makeInner<{ lastProp?: DisplayableProps @@ -220,7 +221,7 @@ class BaseAxisPointer implements AxisPointer { if (animation === 'auto' || animation == null) { const animationThreshold = this.animationThreshold; - if (isCategoryAxis && axis.getBandWidth() > animationThreshold) { + if (isCategoryAxis && calcBandWidth(axis).w > animationThreshold) { return true; } @@ -251,7 +252,7 @@ class BaseAxisPointer implements AxisPointer { axisPointerModel: AxisPointerModel, api: ExtensionAPI ) { - // Should be implemenented by sub-class. + // Should be implemented by sub-class. } /** diff --git a/src/component/axisPointer/CartesianAxisPointer.ts b/src/component/axisPointer/CartesianAxisPointer.ts index 6b8b344232..2383a07e54 100644 --- a/src/component/axisPointer/CartesianAxisPointer.ts +++ b/src/component/axisPointer/CartesianAxisPointer.ts @@ -27,6 +27,8 @@ import Grid from '../../coord/cartesian/Grid'; import Axis2D from '../../coord/cartesian/Axis2D'; import { PathProps } from 'zrender/src/graphic/Path'; import Model from '../../model/Model'; +import { mathMax, mathMin } from '../../util/number'; +import type GlobalModel from '../../model/Global'; // Not use top level axisPointer model type AxisPointerModel = Model; @@ -46,13 +48,19 @@ class CartesianAxisPointer extends BaseAxisPointer { const axis = axisModel.axis; const grid = axis.grid; const axisPointerType = axisPointerModel.get('type'); + const thisExtent = axis.getGlobalExtent(); const otherExtent = getCartesian(grid, axis).getOtherAxis(axis).getGlobalExtent(); const pixelValue = axis.toGlobalCoord(axis.dataToCoord(value, true)); if (axisPointerType && axisPointerType !== 'none') { const elStyle = viewHelper.buildElStyle(axisPointerModel); const pointerOption = pointerShapeBuilder[axisPointerType]( - axis, pixelValue, otherExtent + axis, + pixelValue, + thisExtent, + otherExtent, + axisPointerModel.get('seriesDataIndices'), + axisPointerModel.ecModel ); pointerOption.style = elStyle; elOption.graphicKey = pointerOption.type; @@ -105,8 +113,8 @@ class CartesianAxisPointer extends BaseAxisPointer { const currPosition = [transform.x, transform.y]; currPosition[dimIndex] += delta[dimIndex]; - currPosition[dimIndex] = Math.min(axisExtent[1], currPosition[dimIndex]); - currPosition[dimIndex] = Math.max(axisExtent[0], currPosition[dimIndex]); + currPosition[dimIndex] = mathMin(axisExtent[1], currPosition[dimIndex]); + currPosition[dimIndex] = mathMax(axisExtent[0], currPosition[dimIndex]); const cursorOtherValue = (otherExtent[1] + otherExtent[0]) / 2; const cursorPoint = [cursorOtherValue, cursorOtherValue]; @@ -142,7 +150,12 @@ function getCartesian(grid: Grid, axis: Axis2D) { const pointerShapeBuilder = { - line: function (axis: Axis2D, pixelValue: number, otherExtent: number[]): PathProps & { type: 'Line'} { + line: function ( + axis: Axis2D, + pixelValue: number, + thisExtent: number[], + otherExtent: number[] + ): PathProps & { type: 'Line'} { const targetShape = viewHelper.makeLineShape( [pixelValue, otherExtent[0]], [pixelValue, otherExtent[1]], @@ -155,14 +168,23 @@ const pointerShapeBuilder = { }; }, - shadow: function (axis: Axis2D, pixelValue: number, otherExtent: number[]): PathProps & { type: 'Rect'} { - const bandWidth = Math.max(1, axis.getBandWidth()); - const span = otherExtent[1] - otherExtent[0]; + shadow: function ( + axis: Axis2D, + pixelValue: number, + thisExtent: number[], + otherExtent: number[], + seriesDataIndices: CommonAxisPointerOption['seriesDataIndices'], + ecModel: GlobalModel + ): PathProps & { type: 'Rect'} { + + const bandWidth = viewHelper.calcAxisPointerShadowBandWidth(axis, seriesDataIndices, ecModel); + const otherSpan = otherExtent[1] - otherExtent[0]; + const [min, max] = viewHelper.calcAxisPointerShadowEnds(pixelValue, thisExtent, bandWidth); return { type: 'Rect', shape: viewHelper.makeRectShape( - [pixelValue - bandWidth / 2, otherExtent[0]], - [bandWidth, span], + [min, otherExtent[0]], + [max - min, otherSpan], getAxisDimIndex(axis) ) }; diff --git a/src/component/axisPointer/PolarAxisPointer.ts b/src/component/axisPointer/PolarAxisPointer.ts index 0a220342bb..5d56e9a19e 100644 --- a/src/component/axisPointer/PolarAxisPointer.ts +++ b/src/component/axisPointer/PolarAxisPointer.ts @@ -36,6 +36,7 @@ import AngleAxis from '../../coord/polar/AngleAxis'; import RadiusAxis from '../../coord/polar/RadiusAxis'; import { PathProps } from 'zrender/src/graphic/Path'; import Model from '../../model/Model'; +import type GlobalModel from '../../model/Global'; // Not use top level axisPointer model type AxisPointerModel = Model; @@ -59,8 +60,8 @@ class PolarAxisPointer extends BaseAxisPointer { } const polar = axis.polar; - const otherAxis = polar.getOtherAxis(axis); - const otherExtent = otherAxis.getExtent(); + const thisExtent = axis.getExtent(); + const otherExtent = polar.getOtherAxis(axis).getExtent(); const coordValue = axis.dataToCoord(value); @@ -68,7 +69,13 @@ class PolarAxisPointer extends BaseAxisPointer { if (axisPointerType && axisPointerType !== 'none') { const elStyle = viewHelper.buildElStyle(axisPointerModel); const pointerOption = pointerShapeBuilder[axisPointerType]( - axis, polar, coordValue, otherExtent + axis, + polar, + coordValue, + thisExtent, + otherExtent, + axisPointerModel.get('seriesDataIndices'), + axisPointerModel.ecModel ); pointerOption.style = elStyle; elOption.graphicKey = pointerOption.type; @@ -80,7 +87,7 @@ class PolarAxisPointer extends BaseAxisPointer { viewHelper.buildLabelElOption(elOption, axisModel, axisPointerModel, api, labelPos); } - // Do not support handle, utill any user requires it. + // Do not support handle, util any user requires it. }; @@ -139,6 +146,7 @@ const pointerShapeBuilder = { axis: AngleAxis | RadiusAxis, polar: Polar, coordValue: number, + thisExtent: number[], otherExtent: number[] ): PathProps & { type: 'Line' | 'Circle' } { return axis.dim === 'angle' @@ -163,31 +171,44 @@ const pointerShapeBuilder = { axis: AngleAxis | RadiusAxis, polar: Polar, coordValue: number, - otherExtent: number[] + thisExtent: number[], + otherExtent: number[], + seriesDataIndices: CommonAxisPointerOption['seriesDataIndices'], + ecModel: GlobalModel ): PathProps & { type: 'Sector' } { - const bandWidth = Math.max(1, axis.getBandWidth()); + const radian = Math.PI / 180; + const bandWidth = viewHelper.calcAxisPointerShadowBandWidth(axis, seriesDataIndices, ecModel); + let shape; + if (axis.dim === 'angle') { + shape = viewHelper.makeSectorShape( + polar.cx, + polar.cy, + otherExtent[0], + otherExtent[1], + // In ECharts the screen y is negative if angle is positive, + // opposite to zrender shape. + // No need clamp. + (-coordValue - bandWidth / 2) * radian, + (-coordValue + bandWidth / 2) * radian + ); + } + else { + const [min, max] = viewHelper.calcAxisPointerShadowEnds(coordValue, thisExtent, bandWidth); + shape = viewHelper.makeSectorShape( + polar.cx, + polar.cy, + min, + max, + 0, + Math.PI * 2 + ); + } - return axis.dim === 'angle' - ? { - type: 'Sector', - shape: viewHelper.makeSectorShape( - polar.cx, polar.cy, - otherExtent[0], otherExtent[1], - // In ECharts y is negative if angle is positive - (-coordValue - bandWidth / 2) * radian, - (-coordValue + bandWidth / 2) * radian - ) - } - : { - type: 'Sector', - shape: viewHelper.makeSectorShape( - polar.cx, polar.cy, - coordValue - bandWidth / 2, - coordValue + bandWidth / 2, - 0, Math.PI * 2 - ) - }; + return { + type: 'Sector', + shape, + }; } }; diff --git a/src/component/axisPointer/SingleAxisPointer.ts b/src/component/axisPointer/SingleAxisPointer.ts index 8f766dd252..324cd2ae32 100644 --- a/src/component/axisPointer/SingleAxisPointer.ts +++ b/src/component/axisPointer/SingleAxisPointer.ts @@ -27,6 +27,7 @@ import { ScaleDataValue, VerticalAlign, CommonAxisPointerOption } from '../../ut import ExtensionAPI from '../../core/ExtensionAPI'; import SingleAxisModel from '../../coord/single/AxisModel'; import Model from '../../model/Model'; +import type GlobalModel from '../../model/Global'; const XY = ['x', 'y'] as const; const WH = ['width', 'height'] as const; @@ -48,14 +49,21 @@ class SingleAxisPointer extends BaseAxisPointer { ) { const axis = axisModel.axis; const coordSys = axis.coordinateSystem; - const otherExtent = getGlobalExtent(coordSys, 1 - getPointDimIndex(axis)); + const pointDimIndex = getPointDimIndex(axis); + const thisExtent = getGlobalExtent(coordSys, pointDimIndex); + const otherExtent = getGlobalExtent(coordSys, 1 - pointDimIndex); const pixelValue = coordSys.dataToPoint(value)[0]; const axisPointerType = axisPointerModel.get('type'); if (axisPointerType && axisPointerType !== 'none') { const elStyle = viewHelper.buildElStyle(axisPointerModel); const pointerOption = pointerShapeBuilder[axisPointerType]( - axis, pixelValue, otherExtent + axis, + pixelValue, + thisExtent, + otherExtent, + axisPointerModel.get('seriesDataIndices'), + axisPointerModel.ecModel ); pointerOption.style = elStyle; @@ -129,7 +137,12 @@ class SingleAxisPointer extends BaseAxisPointer { const pointerShapeBuilder = { - line: function (axis: SingleAxis, pixelValue: number, otherExtent: number[]): PathProps & { + line: function ( + axis: SingleAxis, + pixelValue: number, + thisExtent: number[], + otherExtent: number[] + ): PathProps & { type: 'Line' } { const targetShape = viewHelper.makeLineShape( @@ -144,16 +157,24 @@ const pointerShapeBuilder = { }; }, - shadow: function (axis: SingleAxis, pixelValue: number, otherExtent: number[]): PathProps & { + shadow: function ( + axis: SingleAxis, + pixelValue: number, + thisExtent: number[], + otherExtent: number[], + seriesDataIndices: CommonAxisPointerOption['seriesDataIndices'], + ecModel: GlobalModel + ): PathProps & { type: 'Rect' } { - const bandWidth = axis.getBandWidth(); + const bandWidth = viewHelper.calcAxisPointerShadowBandWidth(axis, seriesDataIndices, ecModel); const span = otherExtent[1] - otherExtent[0]; + const [min, max] = viewHelper.calcAxisPointerShadowEnds(pixelValue, thisExtent, bandWidth); return { type: 'Rect', shape: viewHelper.makeRectShape( - [pixelValue - bandWidth / 2, otherExtent[0]], - [bandWidth, span], + [min, otherExtent[0]], + [max - min, span], getPointDimIndex(axis) ) }; diff --git a/src/component/axisPointer/axisTrigger.ts b/src/component/axisPointer/axisTrigger.ts index 082b61c0d9..ac83d2307b 100644 --- a/src/component/axisPointer/axisTrigger.ts +++ b/src/component/axisPointer/axisTrigger.ts @@ -26,6 +26,7 @@ import { Dictionary, Payload, CommonAxisPointerOption, HighlightPayload, Downpla import AxisPointerModel, { AxisPointerOption } from './AxisPointerModel'; import { each, curry, bind, extend, Curry1 } from 'zrender/src/core/util'; import { ZRenderType } from 'zrender/src/zrender'; +import { isNullableNumberFinite } from '../../util/number'; const inner = makeInner<{ axisPointerLastHighlights: Dictionary @@ -72,7 +73,7 @@ type CollectedCoordInfo = ReturnType; type CollectedAxisInfo = CollectedCoordInfo['axesInfo'][string]; interface AxisTriggerPayload extends Payload { - currTrigger?: 'click' | 'mousemove' | 'leave' + currTrigger?: 'click' | 'mousemove' | 'leave' | 'mousewheel' /** * x and y, which are mandatory, specify a point to trigger axisPointer and tooltip. */ @@ -288,7 +289,7 @@ function buildPayloadsBySeries(value: AxisValue, axisInfo: CollectedAxisInfo) { seriesNestestValue = series.getData().get(dataDim[0], dataIndices[0]); } - if (seriesNestestValue == null || !isFinite(seriesNestestValue)) { + if (!isNullableNumberFinite(seriesNestestValue)) { return; } @@ -367,7 +368,7 @@ function showTooltip( axisType: axisModel.type, axisId: axisModel.id, value: value as number, - // Caustion: viewHelper.getValueLabel is actually on "view stage", which + // Caution: viewHelper.getValueLabel is actually on "view stage", which // depends that all models have been updated. So it should not be performed // here. Considering axisPointerModel used here is volatile, which is hard // to be retrieve in TooltipView, we prepare parameters here. @@ -452,7 +453,7 @@ function dispatchHighDownActually( ) { // FIXME // highlight status modification should be a stage of main process? - // (Consider confilct (e.g., legend and axisPointer) and setOption) + // (Consider conflict (e.g., legend and axisPointer) and setOption) const zr = api.getZr(); const highDownKey = 'axisPointerLastHighlights' as const; @@ -464,19 +465,26 @@ function dispatchHighDownActually( each(axesInfo, function (axisInfo, key) { const option = axisInfo.axisPointerModel.option; option.status === 'show' && axisInfo.triggerEmphasis && each(option.seriesDataIndices, function (batchItem) { - const key = batchItem.seriesIndex + ' | ' + batchItem.dataIndex; - newHighlights[key] = batchItem; + newHighlights[batchItem.seriesIndex + '|' + batchItem.dataIndex] = batchItem; }); }); // Diff. - const toHighlight: BatchItem[] = []; - const toDownplay: BatchItem[] = []; + const toHighlight: Pick[] = []; + const toDownplay: Pick[] = []; + function makeHighDownItem(batchItem: BatchItem) { + // `dataIndexInside` should be removed, since the last recorded `dataIndexInside` may have + // been changed if `dataZoomInside` changed the view. Only `dataIndex` will suffice. + return { + seriesIndex: batchItem.seriesIndex, + dataIndex: batchItem.dataIndex, + }; + } each(lastHighlights, function (batchItem, key) { - !newHighlights[key] && toDownplay.push(batchItem); + !newHighlights[key] && toDownplay.push(makeHighDownItem(batchItem)); }); each(newHighlights, function (batchItem, key) { - !lastHighlights[key] && toHighlight.push(batchItem); + !lastHighlights[key] && toHighlight.push(makeHighDownItem(batchItem)); }); toDownplay.length && api.dispatchAction({ diff --git a/src/component/axisPointer/globalListener.ts b/src/component/axisPointer/globalListener.ts index af62ccbfe6..00d83d4094 100644 --- a/src/component/axisPointer/globalListener.ts +++ b/src/component/axisPointer/globalListener.ts @@ -28,7 +28,7 @@ import { Dictionary } from 'zrender/src/core/types'; type DispatchActionMethod = ExtensionAPI['dispatchAction']; type Handler = ( - currTrigger: 'click' | 'mousemove' | 'leave', + currTrigger: 'click' | 'mousemove' | 'mousewheel' | 'leave', event: ZRElementEvent, dispatchAction: DispatchActionMethod ) => void; @@ -59,13 +59,6 @@ interface Pendings { const inner = makeInner(); const each = zrUtil.each; -/** - * @param {string} key - * @param {module:echarts/ExtensionAPI} api - * @param {Function} handler - * param: {string} currTrigger - * param: {Array.} point - */ export function register(key: string, api: ExtensionAPI, handler?: Handler) { if (env.node) { return; @@ -89,6 +82,10 @@ function initGlobalListeners(zr: ZRenderType, api?: ExtensionAPI) { useHandler('click', zrUtil.curry(doEnter, 'click')); useHandler('mousemove', zrUtil.curry(doEnter, 'mousemove')); + // For example, dataZoom may update series layout while mousewheel, + // axisPointer and tooltip need to follow that updates, otherwise, + // highlighted items (by axisPointer) may have no chance to downplay. + useHandler('mousewheel', zrUtil.curry(doEnter, 'mousewheel')); // useHandler('mouseout', onLeave); useHandler('globalout', onLeave); @@ -134,7 +131,7 @@ function onLeave( } function doEnter( - currTrigger: 'click' | 'mousemove' | 'leave', + currTrigger: 'click' | 'mousemove' | 'mousewheel' | 'leave', record: Record, e: ZRElementEvent, dispatchAction: DispatchActionMethod diff --git a/src/component/axisPointer/modelHelper.ts b/src/component/axisPointer/modelHelper.ts index a0635b9c68..2a600d526a 100644 --- a/src/component/axisPointer/modelHelper.ts +++ b/src/component/axisPointer/modelHelper.ts @@ -373,8 +373,7 @@ export function fixValue(axisModel: AxisBaseModel) { option.status = useHandle ? 'show' : 'hide'; } - const extent = scale.getExtent().slice(); - extent[0] > extent[1] && extent.reverse(); + const extent = scale.getExtent(); if (// Pick a value on axis when initializing. value == null diff --git a/src/component/axisPointer/viewHelper.ts b/src/component/axisPointer/viewHelper.ts index 04b18e0daf..762869fdc2 100644 --- a/src/component/axisPointer/viewHelper.ts +++ b/src/component/axisPointer/viewHelper.ts @@ -40,6 +40,8 @@ import Model from '../../model/Model'; import { PathStyleProps } from 'zrender/src/graphic/Path'; import { createTextStyle } from '../../label/labelStyle'; import type SingleAxisModel from '../../coord/single/AxisModel'; +import { calcBandWidth } from '../../coord/axisBand'; +import { mathMax, mathMin } from '../../util/number'; export interface AxisTransformedPositionLayoutInfo { position: VectorArray @@ -262,3 +264,38 @@ export function makeSectorShape( clockwise: true }; } + +export function calcAxisPointerShadowBandWidth( + axis: Axis, + seriesDataIndices: CommonAxisPointerOption['seriesDataIndices'], + ecModel: GlobalModel +): number { + return calcBandWidth(axis, { + fromStat: { + sers: zrUtil.map(seriesDataIndices, function (item) { + return ecModel.getSeriesByIndex(item.seriesIndex); + }) + }, + min: 1 + }).w; +} + +/** + * Return a [min, max] in pixel clampped by `axisExtent`. + */ +export function calcAxisPointerShadowEnds( + val: number, + axisExtent: number[], + bandWidth: number +): number[] { + return [ + mathMax( + mathMin(axisExtent[0], axisExtent[1]), + val - bandWidth / 2 + ), + mathMin( + val + bandWidth / 2, + mathMax(axisExtent[0], axisExtent[1]) + ) + ]; +} diff --git a/src/component/brush/preprocessor.ts b/src/component/brush/preprocessor.ts index 5bbbfc8cc2..4c55cf319f 100644 --- a/src/component/brush/preprocessor.ts +++ b/src/component/brush/preprocessor.ts @@ -23,7 +23,7 @@ import { ECUnitOption, Dictionary } from '../../util/types'; import { BrushOption, BrushToolboxIconType } from './BrushModel'; import { ToolboxOption } from '../toolbox/ToolboxModel'; import { ToolboxBrushFeatureOption } from '../toolbox/feature/Brush'; -import { normalizeToArray } from '../../util/model'; +import { normalizeToArray, removeDuplicates } from '../../util/model'; const DEFAULT_TOOLBOX_BTNS: BrushToolboxIconType[] = ['rect', 'polygon', 'keep', 'clear']; @@ -61,20 +61,9 @@ export default function brushPreprocessor(option: ECUnitOption, isNew: boolean): brushTypes.push.apply(brushTypes, brushComponentSpecifiedBtns); - removeDuplicate(brushTypes); + removeDuplicates(brushTypes, item => item + '', null); if (isNew && !brushTypes.length) { brushTypes.push.apply(brushTypes, DEFAULT_TOOLBOX_BTNS); } } - -function removeDuplicate(arr: string[]): void { - const map = {} as Dictionary; - zrUtil.each(arr, function (val) { - map[val] = 1; - }); - arr.length = 0; - zrUtil.each(map, function (flag, val) { - arr.push(val); - }); -} diff --git a/src/component/brush/visualEncoding.ts b/src/component/brush/visualEncoding.ts index 6a221eb218..7f8a421ad1 100644 --- a/src/component/brush/visualEncoding.ts +++ b/src/component/brush/visualEncoding.ts @@ -32,6 +32,7 @@ import SeriesModel from '../../model/Series'; import ParallelSeriesModel from '../../chart/parallel/ParallelSeries'; import { ZRenderType } from 'zrender/src/zrender'; import { BrushType, BrushDimensionMinMax } from '../helper/BrushController'; +import { initExtentForUnion } from '../../util/model'; type BrushVisualState = 'inBrush' | 'outOfBrush'; @@ -319,7 +320,7 @@ const boundingRectBuilders: Partial> const range = area.range as BrushDimensionMinMax[]; for (let i = 0, len = range.length; i < len; i++) { - minMax = minMax || [[Infinity, -Infinity], [Infinity, -Infinity]]; + minMax = minMax || [initExtentForUnion(), initExtentForUnion()]; const rg = range[i]; rg[0] < minMax[0][0] && (minMax[0][0] = rg[0]); rg[0] > minMax[0][1] && (minMax[0][1] = rg[0]); diff --git a/src/component/dataZoom/AxisProxy.ts b/src/component/dataZoom/AxisProxy.ts index 11d5f6c742..f84880d1c9 100644 --- a/src/component/dataZoom/AxisProxy.ts +++ b/src/component/dataZoom/AxisProxy.ts @@ -17,23 +17,27 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; -import * as numberUtil from '../../util/number'; +import {clone, defaults, each, map} from 'zrender/src/core/util'; +import { + asc, getAcceptableTickPrecision, linearMap, mathAbs, mathCeil, mathFloor, mathMax, mathMin, round +} 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'; +import { Dictionary, NullUndefined } 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'; +import { isOrdinalScale, isTimeScale } from '../../scale/helper'; +import { + AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, scaleRawExtentInfoCreate, + ScaleRawExtentResultForZoom, +} from '../../coord/scaleRawExtentInfo'; +import { discourageOnAxisZero } from '../../coord/axisHelper'; -const each = zrUtil.each; -const asc = numberUtil.asc; interface MinMaxSpan { minSpan: number @@ -42,6 +46,19 @@ interface MinMaxSpan { maxValueSpan: number } +export interface AxisProxyWindow { + // NOTE: May include non-effective portion. + value: number[]; + noZoomEffMM: ScaleRawExtentResultForZoom['noZoomEffMM']; + percent: number[]; + // Percent invert from "value window", which may be slightly different from "percent window" due to some + // handling such as rounding. The difference may be magnified in cases like "alignTicks", so we use + // `percentInverted` in these cases. + // But we retain the original input percent in `percent` whenever possible, since they have been used in views. + percentInverted: number[]; + valuePrecision: number; +} + /** * Operate single axis. * One axis can only operated by one axis operator. @@ -53,16 +70,22 @@ class AxisProxy { ecModel: GlobalModel; + // NOTICE: The lifetime of `AxisProxy` instance is different from `Axis` instance. + // It is recreated in each run of "ec prepare". + private _dimName: DataZoomAxisDimension; private _axisIndex: number; - private _valueWindow: [number, number]; - private _percentWindow: [number, number]; + private _window: AxisProxyWindow; - private _dataExtent: [number, number]; + private _extent: ScaleRawExtentResultForZoom; private _minMaxSpan: MinMaxSpan; + /** + * The host `dataZoom` model. An axis may be controlled by multiple `dataZoom`s, + * but only the first declared `dataZoom` is the host. + */ private _dataZoomModel: DataZoomModel; constructor( @@ -94,17 +117,10 @@ class AxisProxy { } /** - * @return Value can only be NaN or finite value. + * @return `getWindow().value` can only have NaN or finite value. */ - getDataValueWindow() { - return this._valueWindow.slice() as [number, number]; - } - - /** - * @return {Array.} - */ - getDataPercentWindow() { - return this._percentWindow.slice() as [number, number]; + getWindow(): AxisProxyWindow { + return clone(this._window); } getTargetSeriesModels() { @@ -128,34 +144,53 @@ class AxisProxy { } getMinMaxSpan() { - return zrUtil.clone(this._minMaxSpan); + return clone(this._minMaxSpan); } /** - * Only calculate by given range and this._dataExtent, do not change anything. + * [CAVEAT] Keep this method pure, so that it can be called multiple times. + * + * Only calculate by given range and cumulative series data extent, 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(); + calculateDataWindow( + opt: { + start?: number // percent, 0 ~ 100 + end?: number // percent, 0 ~ 100 + startValue?: number | string | Date + endValue?: number | string | Date + } + ): AxisProxyWindow { + const {noZoomMapMM: dataExtent, noZoomEffMM} = this._extent; + const axis = this.getAxisModel().axis; + const scale = axis.scale; + const dataZoomModel = this._dataZoomModel; + const rangePropMode = dataZoomModel.getRangePropMode(); const percentExtent = [0, 100]; const percentWindow = [] as unknown as [number, number]; const valueWindow = [] as unknown as [number, number]; let hasPropModeValue; + const needRound = [false, false]; + + // NOTE: + // The current percentage base calculation strategy: + // - If the window boundary is NOT at 0% or 100%, boundary values are derived from the raw extent + // (series data + axis.min/max; see `ScaleRawExtentInfo['makeForZoom']`). Any subsequent "nice" + // expansion are excluded. + // - If the window boundary is at 0% or 100%, the "nice"-expanded portion is included. + // Pros: + // - The effect may be preferable when users intend to quickly narrow down to data details, + // especially when "nice strategy" excessively expands the extent. + // - It simplifies the logic, otherwise, "nice strategy" would need to be applied twice (full window + // + current window). + // Cons: + // - This strategy causes jitter when switching dataZoom to/from 0%/100% (though generally acceptable). 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). + // NOTE: dataZoom is based either on `percentProp` ('start', 'end') or + // on `valueProp` ('startValue', 'endValue'). // 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). @@ -169,24 +204,19 @@ class AxisProxy { 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 - )); + boundValue = linearMap(boundPercent, percentExtent, dataExtent); + needRound[idx] = true; } else { hasPropModeValue = true; + // NOTE: `scale.parse` can also round input for 'time' or 'ordinal' scale. 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 + // This calculation can not be inverted, because all of values that // are overflow the `dataExtent` will be calculated to percent '100%' - boundPercent = numberUtil.linearMap( - boundValue, dataExtent, percentExtent - ); + boundPercent = 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] @@ -199,11 +229,17 @@ class AxisProxy { 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. + // The windows specified from `dispatchAction` or `setOption` may: + // (1) be out of the extent, or + // (2) do not comply with `minSpan/maxSpan`, `minValueSpan/maxValueSpan`. + // So we clamp them here. + // 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. + // PENDING: For historical reason, the option design is partially incompatible: + // If `option.start` and `option.endValue` are specified, and when we choose whether + // `min/maxValueSpan` or `minSpan/maxSpan` is applied, neither one is intuitive. + // (Currently using `minValueSpan/maxValueSpan`.) const spans = this._minMaxSpan; hasPropModeValue ? restrictSet(valueWindow, percentWindow, dataExtent, percentExtent, false) @@ -223,14 +259,75 @@ class AxisProxy { 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])); + toWindow[i] = linearMap(fromWindow[i], fromExtent, toExtent, true); + if (toValue) { + toWindow[i] = toWindow[i]; + needRound[i] = true; + } + } + simplyEnsureAsc(toWindow); + } + + // - In 'time' and 'ordinal' scale, rounding by 0 is required. + // - In 'interval' and 'log' scale, we round values for acceptable display with acceptable accuracy loose. + // "Values" can be rounded only if they are generated from `percent`, since user-specified "value" + // should be respected, and `DataZoomSelect` already performs its own rounding. + // - Currently we only round "value" but not "percent", since there is no need so far. + // - MEMO: See also #3228 and commit a89fd0d7f1833ecf08a4a5b7ecf651b4a0d8da41 + // - PENDING: The rounding result may slightly overflow the restriction from `min/maxSpan`, + // but it is acceptable so far. + const isScaleOrdinalOrTime = isOrdinalScale(scale) || isTimeScale(scale); + // Typically pxExtent has been ready in coordSys create. (See `create` of `Grid.ts`) + const pxExtent = axis.getExtent(); + // NOTICE: this pxSpan may be not accurate yet due to "outerBounds" logic, but acceptable. + const pxSpan = mathAbs(pxExtent[1] - pxExtent[0]); + const precision = isScaleOrdinalOrTime + ? 0 + // NOTICE: We deliberately do not allow specifying this precision by users, until real requirements + // occur. Otherwise, unnecessary complexity and bad case may be introduced. A small precision may + // cause the rounded ends overflow the expected min/max significantly. And this precision effectively + // determines the size of a roaming step, and a big step would likely constantly cut through series + // shapes in an unexpected place and cause visual artifacts (e.g., for bar series). Although + // theroetically that defect can be resolved by introducing extra spaces between axis min/max tick + // and axis boundary (see `SCALE_EXTENT_KIND_MAPPING`), it's complicated and unnecessary. + : getAcceptableTickPrecision(valueWindow, pxSpan, 0.5); + each([[0, mathCeil], [1, mathFloor]] as const, function ([idx, ceilOrFloor]) { + if (!needRound[idx] || !isFinite(precision)) { + return; + } + valueWindow[idx] = round(valueWindow[idx], precision); + valueWindow[idx] = mathMin(dataExtent[1], mathMax(dataExtent[0], valueWindow[idx])); // Clamp. + if (percentWindow[idx] === percentExtent[idx]) { + // When `percent` is 0 or 100, `value` must be `dataExtent[0]` or `dataExtent[1]` + // regardless of the calculated precision. + // NOTE: `percentWindow` is never over [0, 100] at this moment. + valueWindow[idx] = dataExtent[idx]; + if (isScaleOrdinalOrTime) { + // In case that dataExtent[idx] is not an integer (may occur since it comes from user input) + valueWindow[idx] = ceilOrFloor(valueWindow[idx]); + } + } + }); + simplyEnsureAsc(valueWindow); + + const percentInvertedWindow = [ + linearMap(valueWindow[0], dataExtent, percentExtent, true), + linearMap(valueWindow[1], dataExtent, percentExtent, true), + ] as [number, number]; + simplyEnsureAsc(percentInvertedWindow); + + function simplyEnsureAsc(window: number[]): void { + if (window[0] > window[1]) { + window[0] = window[1]; } } return { - valueWindow: valueWindow, - percentWindow: percentWindow + value: valueWindow, + noZoomEffMM: noZoomEffMM.slice(), + percent: percentWindow, + percentInverted: percentInvertedWindow, + valuePrecision: precision, }; } @@ -239,36 +336,61 @@ class AxisProxy { * so it is recommended to be called in "process stage" but not "model init * stage". */ - reset(dataZoomModel: DataZoomModel) { - if (dataZoomModel !== this._dataZoomModel) { + reset(dataZoomModel: DataZoomModel, alignToPercentInverted: number[] | NullUndefined) { + if (!this.hostedBy(dataZoomModel)) { return; } - const targetSeries = this.getTargetSeriesModels(); - // Culculate data window and data extent, and record them. - this._dataExtent = calculateDataExtent(this, this._dimName, targetSeries); + // 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 axis = this.getAxisModel().axis; + scaleRawExtentInfoCreate(this.ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM); + + discourageOnAxisZero(axis, {dz: true}); + + const rawExtentInfo = axis.scale.rawExtentInfo; + this._extent = rawExtentInfo.makeNoZoom(); // `calculateDataWindow` uses min/maxSpan. this._updateMinMaxSpan(); - const dataWindow = this.calculateDataWindow(dataZoomModel.settledOption); - - this._valueWindow = dataWindow.valueWindow; - this._percentWindow = dataWindow.percentWindow; + let opt = dataZoomModel.settledOption; + if (alignToPercentInverted) { + opt = defaults({ + start: alignToPercentInverted[0], + end: alignToPercentInverted[1], + }, opt); + } + const {percent, value} = this._window = this.calculateDataWindow(opt); - // Update axis setting then. - this._setAxisModel(); + // 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. + if (percent[0] !== 0) { + rawExtentInfo.setZoomMinMax(0, value[0]); + } + if (percent[1] !== 100) { + rawExtentInfo.setZoomMinMax(1, value[1]); + } } filterData(dataZoomModel: DataZoomModel, api: ExtensionAPI) { - if (dataZoomModel !== this._dataZoomModel) { + if (!this.hostedBy(dataZoomModel)) { return; } const axisDim = this._dimName; const seriesModels = this.getTargetSeriesModels(); const filterMode = dataZoomModel.get('filterMode'); - const valueWindow = this._valueWindow; + const valueWindow = this._window.value; if (filterMode === 'none') { return; @@ -278,8 +400,8 @@ class AxisProxy { // 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 + // In fact, it is not a big deal that do not support filterMode-'filter' + // when using toolbox#dataZoom, util 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, @@ -305,7 +427,7 @@ class AxisProxy { if (filterMode === 'weakFilter') { const store = seriesData.getStore(); - const dataDimIndices = zrUtil.map(dataDims, dim => seriesData.getDimensionIndex(dim), seriesData); + const dataDimIndices = map(dataDims, dim => seriesData.getDimensionIndex(dim), seriesData); seriesData.filterSelf(function (dataIndex) { let leftOut; let rightOut; @@ -337,17 +459,17 @@ class AxisProxy { } else { const range: Dictionary<[number, number]> = {}; - range[dim] = valueWindow; + range[dim] = valueWindow as [number, number]; - // console.time('select'); + // console.time('AxisProxy_selectRange'); seriesData.selectRange(range); - // console.timeEnd('select'); + // console.timeEnd('AxisProxy_selectRange'); } }); } each(dataDims, function (dim) { - seriesData.setApproximateExtent(valueWindow, dim); + seriesData.setApproximateExtent(valueWindow as [number, number], dim); }); }); @@ -359,7 +481,7 @@ class AxisProxy { private _updateMinMaxSpan() { const minMaxSpan = this._minMaxSpan = {} as MinMaxSpan; const dataZoomModel = this._dataZoomModel; - const dataExtent = this._dataExtent; + const dataExtent = this._extent.noZoomMapMM; each(['min', 'max'], function (minMax) { let percentSpan = dataZoomModel.get(minMax + 'Span' as 'minSpan' | 'maxSpan'); @@ -368,12 +490,12 @@ class AxisProxy { // minValueSpan and maxValueSpan has higher priority than minSpan and maxSpan if (valueSpan != null) { - percentSpan = numberUtil.linearMap( + percentSpan = linearMap( dataExtent[0] + valueSpan, dataExtent, [0, 100], true ); } else if (percentSpan != null) { - valueSpan = numberUtil.linearMap( + valueSpan = linearMap( percentSpan, [0, 100], dataExtent, true ) - dataExtent[0]; } @@ -382,57 +504,6 @@ class AxisProxy { 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; diff --git a/src/component/dataZoom/DataZoomModel.ts b/src/component/dataZoom/DataZoomModel.ts index 279c06a13b..52d93dc3a3 100644 --- a/src/component/dataZoom/DataZoomModel.ts +++ b/src/component/dataZoom/DataZoomModel.ts @@ -23,13 +23,14 @@ import ComponentModel from '../../model/Component'; import { LayoutOrient, ComponentOption, - LabelOption + LabelOption, } from '../../util/types'; import Model from '../../model/Model'; import GlobalModel from '../../model/Global'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; import { - getAxisMainType, DATA_ZOOM_AXIS_DIMENSIONS, DataZoomAxisDimension + getAxisMainType, DATA_ZOOM_AXIS_DIMENSIONS, DataZoomAxisDimension, + getAxisProxyFromModel } from './helper'; import SingleAxisModel from '../../coord/single/AxisModel'; import { MULTIPLE_REFERRING, SINGLE_REFERRING, ModelFinderIndexQuery, ModelFinderIdQuery } from '../../util/model'; @@ -131,15 +132,11 @@ export interface DataZoomOption extends ComponentOption { type RangeOption = Pick; -export type DataZoomExtendedAxisBaseModel = AxisBaseModel & { - __dzAxisProxy: AxisProxy -}; - class DataZoomAxisInfo { indexList: number[] = []; indexMap: boolean[] = []; - add(axisCmptIdx: number) { + add(axisCmptIdx: ComponentModel['componentIndex']): void { // Remove duplication. if (!this.indexMap[axisCmptIdx]) { this.indexList.push(axisCmptIdx); @@ -456,10 +453,7 @@ class DataZoomModel extends Compon * @return If not found, return null/undefined. */ getAxisProxy(axisDim: DataZoomAxisDimension, axisIndex: number): AxisProxy { - const axisModel = this.getAxisModel(axisDim, axisIndex); - if (axisModel) { - return (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy; - } + return getAxisProxyFromModel(this.getAxisModel(axisDim, axisIndex)); } /** @@ -510,7 +504,7 @@ class DataZoomModel extends Compon getPercentRange(): number[] { const axisProxy = this.findRepresentativeAxisProxy(); if (axisProxy) { - return axisProxy.getDataPercentWindow(); + return axisProxy.getWindow().percent; } } @@ -523,11 +517,11 @@ class DataZoomModel extends Compon if (axisDim == null && axisIndex == null) { const axisProxy = this.findRepresentativeAxisProxy(); if (axisProxy) { - return axisProxy.getDataValueWindow(); + return axisProxy.getWindow().value; } } else { - return this.getAxisProxy(axisDim, axisIndex).getDataValueWindow(); + return this.getAxisProxy(axisDim, axisIndex).getWindow().value; } } @@ -537,7 +531,7 @@ class DataZoomModel extends Compon */ findRepresentativeAxisProxy(axisModel?: AxisBaseModel): AxisProxy { if (axisModel) { - return (axisModel as DataZoomExtendedAxisBaseModel).__dzAxisProxy; + return getAxisProxyFromModel(axisModel); } // Find the first hosted axisProxy @@ -576,6 +570,7 @@ class DataZoomModel extends Compon } } + /** * Retrieve those raw params from option, which will be cached separately, * because they will be overwritten by normalized/calculated values in the main diff --git a/src/component/dataZoom/SliderZoomModel.ts b/src/component/dataZoom/SliderZoomModel.ts index 80d206842a..53f2d9a54f 100644 --- a/src/component/dataZoom/SliderZoomModel.ts +++ b/src/component/dataZoom/SliderZoomModel.ts @@ -107,7 +107,11 @@ export interface SliderDataZoomOption * Height of handle rect. Can be a percent string relative to the slider height. */ moveHandleSize?: number - + /** + * The precision only used on displayed labels. + * NOTICE: Specifying the "value precision" or "roaming step" is not allowed. + * `getAcceptableTickPrecision` is used for that. See `AxisProxy` for reasons. + */ labelPrecision?: number | 'auto' labelFormatter?: string | ((value: number, valueStr: string) => string) diff --git a/src/component/dataZoom/SliderZoomView.ts b/src/component/dataZoom/SliderZoomView.ts index 30422c9c52..ef8ddc4d29 100644 --- a/src/component/dataZoom/SliderZoomView.ts +++ b/src/component/dataZoom/SliderZoomView.ts @@ -22,7 +22,7 @@ import * as eventTool from 'zrender/src/core/event'; import * as graphic from '../../util/graphic'; import * as throttle from '../../util/throttle'; import DataZoomView from './DataZoomView'; -import { linearMap, asc, parsePercent } from '../../util/number'; +import { linearMap, asc, parsePercent, round, mathMax, mathMin } from '../../util/number'; import * as layout from '../../util/layout'; import sliderMove from '../helper/sliderMove'; import GlobalModel from '../../model/Global'; @@ -35,7 +35,7 @@ import { RectLike } from 'zrender/src/core/BoundingRect'; import Axis from '../../coord/Axis'; import SeriesModel from '../../model/Series'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; -import { getAxisMainType, collectReferCoordSysModelInfo } from './helper'; +import { getAxisMainType, collectReferCoordSysModelInfo, getAlignTo } from './helper'; import { enableHoverEmphasis } from '../../util/states'; import { createSymbol, symbolBuildProxies } from '../../util/symbol'; import { deprecateLog } from '../../util/log'; @@ -44,6 +44,11 @@ import Displayable from 'zrender/src/graphic/Displayable'; import { createTextStyle } from '../../label/labelStyle'; import SeriesData from '../../data/SeriesData'; import tokens from '../../visual/tokens'; +import { isOrdinalScale, isTimeScale } from '../../scale/helper'; +import { AxisProxyWindow } from './AxisProxy'; +import type Scale from '../../scale/Scale'; +import { SCALE_EXTENT_KIND_EFFECTIVE } from '../../scale/scaleMapper'; + const Rect = graphic.Rect; @@ -117,6 +122,8 @@ class SliderZoomView extends DataZoomView { private _brushing: boolean; + private _isOverDataInfoTriggerArea: boolean; + private _dataShadowInfo: { thisAxis: Axis series: SeriesModel @@ -609,8 +616,8 @@ class SliderZoomView extends DataZoomView { draggable: true, drift: bind(this._onDragMove, this, handleIndex), ondragend: bind(this._onDragEnd, this), - onmouseover: bind(this._showDataInfo, this, true), - onmouseout: bind(this._showDataInfo, this, false), + onmouseover: bind(this._onOverDataInfoTriggerArea, this, true), + onmouseout: bind(this._onOverDataInfoTriggerArea, this, false), z2: 5 }); @@ -705,12 +712,12 @@ class SliderZoomView extends DataZoomView { actualMoveZone.attr({ draggable: true, - cursor: 'default', - drift: bind(this._onDragMove, this, 'all'), - ondragstart: bind(this._showDataInfo, this, true), - ondragend: bind(this._onDragEnd, this), - onmouseover: bind(this._showDataInfo, this, true), - onmouseout: bind(this._showDataInfo, this, false) + cursor: 'grab', + drift: bind(this._onActualMoveZoneDrift, this), + ondragstart: bind(this._onActualMoveZoneDragStart, this), + ondragend: bind(this._onActualMoveZoneDragEnd, this), + onmouseover: bind(this._onOverDataInfoTriggerArea, this, true), + onmouseout: bind(this._onOverDataInfoTriggerArea, this, false) }); } @@ -816,30 +823,36 @@ class SliderZoomView extends DataZoomView { private _updateDataInfo(nonRealtime?: boolean) { const dataZoomModel = this.dataZoomModel; - const displaybles = this._displayables; - const handleLabels = displaybles.handleLabels; + const displayables = this._displayables; + const handleLabels = displayables.handleLabels; const orient = this._orient; let labelTexts = ['', '']; - // FIXME - // date型,支持formatter,autoformatter(ec2 date.getAutoFormatter) if (dataZoomModel.get('showDetail')) { const axisProxy = dataZoomModel.findRepresentativeAxisProxy(); + const scale = axisProxy.getAxisModel().axis.scale; if (axisProxy) { - const axis = axisProxy.getAxisModel().axis; const range = this._range; - const dataInterval = nonRealtime + let window: AxisProxyWindow; + if (nonRealtime) { // See #4434, data and axis are not processed and reset yet in non-realtime mode. - ? axisProxy.calculateDataWindow({ - start: range[0], end: range[1] - }).valueWindow - : axisProxy.getDataValueWindow(); + let calcWinInput = {start: range[0], end: range[1]}; + const alignTo = getAlignTo(dataZoomModel, axisProxy); + if (alignTo) { + const alignToWindow = alignTo.calculateDataWindow(calcWinInput).percentInverted; + calcWinInput = {start: alignToWindow[0], end: alignToWindow[1]}; + } + window = axisProxy.calculateDataWindow(calcWinInput); + } + else { + window = axisProxy.getWindow(); + } labelTexts = [ - this._formatLabel(dataInterval[0], axis), - this._formatLabel(dataInterval[1], axis) + formatLabel(dataZoomModel, 0, window, scale), + formatLabel(dataZoomModel, 1, window, scale) ]; } } @@ -854,7 +867,7 @@ class SliderZoomView extends DataZoomView { // Text should not transform by barGroup. // Ignore handlers transform const barTransform = graphic.getTransform( - displaybles.handles[handleIndex].parent, this.group + displayables.handles[handleIndex].parent, this.group ); const direction = graphic.transformDirection( handleIndex === 0 ? 'right' : 'left', barTransform @@ -877,30 +890,9 @@ class SliderZoomView extends DataZoomView { } } - private _formatLabel(value: ParsedValue, axis: Axis) { - const dataZoomModel = this.dataZoomModel; - const labelFormatter = dataZoomModel.get('labelFormatter'); - - let labelPrecision = dataZoomModel.get('labelPrecision'); - if (labelPrecision == null || labelPrecision === 'auto') { - labelPrecision = axis.getPixelPrecision(); - } - - const valueStr = (value == null || isNaN(value as number)) - ? '' - // FIXME Glue code - : (axis.type === 'category' || axis.type === 'time') - ? axis.scale.getLabel({ - value: Math.round(value as number) - }) - // param of toFixed should less then 20. - : (value as number).toFixed(Math.min(labelPrecision as number, 20)); - - return isFunction(labelFormatter) - ? labelFormatter(value as number, valueStr) - : isString(labelFormatter) - ? labelFormatter.replace('{value}', valueStr) - : valueStr; + private _onOverDataInfoTriggerArea(isOver: boolean): void { + this._isOverDataInfoTriggerArea = isOver; + this._showDataInfo(isOver); } /** @@ -925,6 +917,21 @@ class SliderZoomView extends DataZoomView { && this.api[toShow ? 'enterEmphasis' : 'leaveEmphasis'](displayables.moveHandle, 1); } + private _onActualMoveZoneDrift(dx: number, dy: number, event: ZRElementEvent) { + this.api.getZr().setCursorStyle('grabbing'); + this._onDragMove('all', dx, dy, event); + } + + private _onActualMoveZoneDragStart(event: ZRElementEvent) { + (event.target as Displayable).attr('cursor', 'grabbing'); + this._showDataInfo(true); + } + + private _onActualMoveZoneDragEnd(event: ZRElementEvent) { + (event.target as Displayable).attr('cursor', 'grab'); + this._onDragEnd(); + } + private _onDragMove(handleIndex: 0 | 1 | 'all', dx: number, dy: number, event: ZRElementEvent) { this._dragging = true; @@ -948,7 +955,11 @@ class SliderZoomView extends DataZoomView { private _onDragEnd() { this._dragging = false; - this._showDataInfo(false); + + if (!this._isOverDataInfoTriggerArea) { + // Drag end may occur on draggable bars, where data info should be still shown. + this._showDataInfo(false); + } // While in realtime mode and stream mode, dispatch action when // drag end will cause the whole view rerender, which is unnecessary. @@ -1117,6 +1128,42 @@ class SliderZoomView extends DataZoomView { } +function formatLabel( + dataZoomModel: SliderZoomModel, + extentIdx: 0 | 1, + window: AxisProxyWindow, + scale: Scale +): string { + const labelFormatter = dataZoomModel.get('labelFormatter'); + + let labelPrecision = dataZoomModel.get('labelPrecision'); + if (labelPrecision == null || labelPrecision === 'auto') { + labelPrecision = window.valuePrecision; + } + + // Do not display values out of `SCALE_EXTENT_KIND_EFFECTIVE` - generally they are meaningless. + // For example, `scaleExtent[0]` is often `0`, and negative values are unlikely to be meaningful. + // That is, "nice" expansion and `SCALE_EXTENT_KIND_MAPPING` expansion are always not display in labels. + const value = (extentIdx ? mathMin : mathMax)( + window.value[extentIdx], + window.noZoomEffMM[extentIdx], + ); + + const valueStr = (value == null || isNaN(value)) + ? '' + : (isOrdinalScale(scale) || isTimeScale(scale)) + ? scale.getLabel({value: Math.round(value)}) + : isFinite(labelPrecision) + ? round(value, labelPrecision, true) + : value + ''; + + return isFunction(labelFormatter) + ? labelFormatter(value, valueStr) + : isString(labelFormatter) + ? labelFormatter.replace('{value}', valueStr) + : valueStr; +} + function getOtherDim(thisDim: 'x' | 'y' | 'radius' | 'angle' | 'single' | 'z') { // FIXME // 这个逻辑和getOtherAxis里一致,但是写在这里是否不好 diff --git a/src/component/dataZoom/dataZoomProcessor.ts b/src/component/dataZoom/dataZoomProcessor.ts index f511aaed31..811b6a264d 100644 --- a/src/component/dataZoom/dataZoomProcessor.ts +++ b/src/component/dataZoom/dataZoomProcessor.ts @@ -19,13 +19,19 @@ import {createHashMap, each} from 'zrender/src/core/util'; import SeriesModel from '../../model/Series'; -import DataZoomModel, { DataZoomExtendedAxisBaseModel } from './DataZoomModel'; -import { getAxisMainType, DataZoomAxisDimension } from './helper'; +import DataZoomModel from './DataZoomModel'; +import { + getAxisMainType, DataZoomAxisDimension, getAlignTo, getAxisProxyFromModel, setAxisProxyToModel +} from './helper'; import AxisProxy from './AxisProxy'; import { StageHandler } from '../../util/types'; +import { AxisBaseModel } from '../../coord/AxisBaseModel'; + const dataZoomProcessor: StageHandler = { + dirtyOnOverallProgress: true, + // `dataZoomProcessor` will only be performed in needed series. Consider if // there is a line series and a pie series, it is better not to update the // line series if only pie series is needed to be updated. @@ -35,31 +41,27 @@ const dataZoomProcessor: StageHandler = { cb: ( axisDim: DataZoomAxisDimension, axisIndex: number, - axisModel: DataZoomExtendedAxisBaseModel, + axisModel: AxisBaseModel, dataZoomModel: DataZoomModel ) => void ) { ecModel.eachComponent('dataZoom', function (dataZoomModel: DataZoomModel) { dataZoomModel.eachTargetAxis(function (axisDim, axisIndex) { const axisModel = ecModel.getComponent(getAxisMainType(axisDim), axisIndex); - cb(axisDim, axisIndex, axisModel as DataZoomExtendedAxisBaseModel, dataZoomModel); + cb(axisDim, axisIndex, axisModel as AxisBaseModel, dataZoomModel); }); }); } // FIXME: it brings side-effect to `getTargetSeries`. - // Prepare axis proxies. - eachAxisModel(function (axisDim, axisIndex, axisModel, dataZoomModel) { - // dispose all last axis proxy, in case that some axis are deleted. - axisModel.__dzAxisProxy = null; - }); const proxyList: AxisProxy[] = []; eachAxisModel(function (axisDim, axisIndex, axisModel, dataZoomModel) { - // Different dataZooms may constrol the same axis. In that case, + // Different dataZooms may control the same axis. In that case, // an axisProxy serves both of them. - if (!axisModel.__dzAxisProxy) { + if (!getAxisProxyFromModel(axisModel)) { // Use the first dataZoomModel as the main model of axisProxy. - axisModel.__dzAxisProxy = new AxisProxy(axisDim, axisIndex, dataZoomModel, ecModel); - proxyList.push(axisModel.__dzAxisProxy); + const axisProxy = new AxisProxy(axisDim, axisIndex, dataZoomModel, ecModel); + proxyList.push(axisProxy); + setAxisProxyToModel(axisModel, axisProxy); } }); @@ -82,8 +84,19 @@ const dataZoomProcessor: StageHandler = { // We calculate window and reset axis here but not in model // init stage and not after action dispatch handler, because // reset should be called after seriesData.restoreData. + const axisProxyNeedAlign: [AxisProxy, AxisProxy][] = []; dataZoomModel.eachTargetAxis(function (axisDim, axisIndex) { - dataZoomModel.getAxisProxy(axisDim, axisIndex).reset(dataZoomModel); + const axisProxy = dataZoomModel.getAxisProxy(axisDim, axisIndex); + const alignToAxisProxy = getAlignTo(dataZoomModel, axisProxy); + if (alignToAxisProxy) { + axisProxyNeedAlign.push([axisProxy, alignToAxisProxy]); + } + else { + axisProxy.reset(dataZoomModel, null); + } + }); + each(axisProxyNeedAlign, function (item) { + item[0].reset(dataZoomModel, item[1].getWindow().percentInverted); }); // Caution: data zoom filtering is order sensitive when using @@ -110,18 +123,17 @@ const dataZoomProcessor: StageHandler = { // is able to get them from chart.getOption(). const axisProxy = dataZoomModel.findRepresentativeAxisProxy(); if (axisProxy) { - const percentRange = axisProxy.getDataPercentWindow(); - const valueRange = axisProxy.getDataValueWindow(); + const {percent, value} = axisProxy.getWindow(); dataZoomModel.setCalculatedRange({ - start: percentRange[0], - end: percentRange[1], - startValue: valueRange[0], - endValue: valueRange[1] + start: percent[0], + end: percent[1], + startValue: value[0], + endValue: value[1] }); } }); } }; -export default dataZoomProcessor; \ No newline at end of file +export default dataZoomProcessor; diff --git a/src/component/dataZoom/helper.ts b/src/component/dataZoom/helper.ts index 4b0a8a31e4..9fb7c3b580 100644 --- a/src/component/dataZoom/helper.ts +++ b/src/component/dataZoom/helper.ts @@ -17,13 +17,17 @@ * under the License. */ -import { Payload } from '../../util/types'; +import { NullUndefined, Payload } from '../../util/types'; import GlobalModel from '../../model/Global'; import DataZoomModel from './DataZoomModel'; import { indexOf, createHashMap, assert, HashMap } from 'zrender/src/core/util'; import SeriesModel from '../../model/Series'; import { CoordinateSystemHostModel } from '../../coord/CoordinateSystem'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; +import type AxisProxy from './AxisProxy'; +import { makeInner } from '../../util/model'; +import type ComponentModel from '../../model/Component'; +import { getCachePerECPrepare, GlobalModelCachePerECPrepare } from '../../util/cycleCache'; export interface DataZoomPayloadBatchItem { @@ -36,8 +40,8 @@ export interface DataZoomPayloadBatchItem { export interface DataZoomReferCoordSysInfo { model: CoordinateSystemHostModel; - // Notice: if two dataZooms refer the same coordinamte system model, - // (1) The axis they refered may different + // Notice: if two dataZooms refer the same coordinate system model, + // (1) The axis they referred may different // (2) The sequence the axisModels matters, may different in // different dataZooms. axisModels: AxisBaseModel[]; @@ -56,6 +60,12 @@ type DataZoomAxisIdPropName = 'xAxisId' | 'yAxisId' | 'radiusAxisId' | 'angleAxisId' | 'singleAxisId'; export type DataZoomCoordSysMainType = 'polar' | 'grid' | 'singleAxis'; +const ecModelCacheInner = makeInner<{ + axisProxyMap: AxisProxyMap; +}, GlobalModelCachePerECPrepare>(); + +type AxisProxyMap = HashMap; + // Supported coords. // FIXME: polar has been broken (but rarely used). const SERIES_COORDS = ['cartesian2d', 'polar', 'singleAxis'] as const; @@ -205,3 +215,42 @@ export function collectReferCoordSysModelInfo(dataZoomModel: DataZoomModel): { return coordSysInfoWrap; } + +function ensureAxisProxyMap(ecModel: GlobalModel): AxisProxyMap { + // Consider some axes may be deleted, and dataZoom options may be changed at and only at each run of + // "ec prepare", we save axis proxies to a cache that is auto-cleared for each run of "ec prepare". + const store = ecModelCacheInner(getCachePerECPrepare(ecModel)); + return store.axisProxyMap || (store.axisProxyMap = createHashMap()); +} + +export function getAxisProxyFromModel(axisModel: AxisBaseModel | NullUndefined): AxisProxy | NullUndefined { + if (!axisModel) { + return; + } + if (__DEV__) { + assert(axisModel.ecModel); + } + return ensureAxisProxyMap(axisModel.ecModel).get(axisModel.uid); +} + +export function setAxisProxyToModel(axisModel: AxisBaseModel, axisProxy: AxisProxy): void { + if (__DEV__) { + assert(axisModel.ecModel); + } + ensureAxisProxyMap(axisModel.ecModel).set(axisModel.uid, axisProxy); +} + +/** + * NOTICE: If `axis_a` aligns to `axis_b`, but they are not controlled by + * the same `dataZoom`, do not consider `axis_b` as `alignTo` and + * then do not input it into `AxisProxy#reset`. + */ +export function getAlignTo(dataZoomModel: DataZoomModel, axisProxy: AxisProxy): AxisProxy | NullUndefined { + const alignToAxis = axisProxy.getAxisModel().axis.__alignTo; + return ( + alignToAxis && dataZoomModel.getAxisProxy( + alignToAxis.dim as DataZoomAxisDimension, + alignToAxis.model.componentIndex + ) + ) ? getAxisProxyFromModel(alignToAxis.model) : null; +} diff --git a/src/component/dataZoom/installCommon.ts b/src/component/dataZoom/installCommon.ts index 4c70aad1e7..8a667e8124 100644 --- a/src/component/dataZoom/installCommon.ts +++ b/src/component/dataZoom/installCommon.ts @@ -20,20 +20,20 @@ import { EChartsExtensionInstallRegisters } from '../../extension'; import dataZoomProcessor from './dataZoomProcessor'; import installDataZoomAction from './dataZoomAction'; +import { makeCallOnlyOnce } from '../../util/model'; + +const callOnlyOnce = makeCallOnlyOnce(); -let installed = false; export default function installCommon(registers: EChartsExtensionInstallRegisters) { - if (installed) { - return; - } - installed = true; - registers.registerProcessor(registers.PRIORITY.PROCESSOR.FILTER, dataZoomProcessor); + callOnlyOnce(registers, function () { + registers.registerProcessor(registers.PRIORITY.PROCESSOR.FILTER, dataZoomProcessor); - installDataZoomAction(registers); + installDataZoomAction(registers); - registers.registerSubTypeDefaulter('dataZoom', function () { - // Default 'slider' when no type specified. - return 'slider'; + registers.registerSubTypeDefaulter('dataZoom', function () { + // Default 'slider' when no type specified. + return 'slider'; + }); }); } \ No newline at end of file diff --git a/src/component/dataZoom/roams.ts b/src/component/dataZoom/roams.ts index 38800b8c35..acd1b30fee 100644 --- a/src/component/dataZoom/roams.ts +++ b/src/component/dataZoom/roams.ts @@ -18,7 +18,7 @@ */ // Only create one roam controller for each coordinate system. -// one roam controller might be refered by two inside data zoom +// one roam controller might be referred by two inside data zoom // components (for example, one for x and one for y). When user // pan or zoom, only dispatch one action for those data zoom // components. @@ -39,7 +39,6 @@ import { CoordinateSystemHostModel } from '../../coord/CoordinateSystem'; import { DataZoomGetRangeHandlers } from './InsideZoomView'; import { EChartsExtensionInstallRegisters } from '../../extension'; - interface DataZoomInfo { getRange: DataZoomGetRangeHandlers; model: InsideZoomModel; @@ -240,71 +239,69 @@ function mergeControllerParams( export function installDataZoomRoamProcessor(registers: EChartsExtensionInstallRegisters) { - registers.registerProcessor( - registers.PRIORITY.PROCESSOR.FILTER, - function (ecModel: GlobalModel, api: ExtensionAPI): void { - const apiInner = inner(api); - const coordSysRecordMap = apiInner.coordSysRecordMap - || (apiInner.coordSysRecordMap = createHashMap()); - - coordSysRecordMap.each(function (coordSysRecord) { - // `coordSysRecordMap` always exists (because it holds the `roam controller`, which should - // better not re-create each time), but clear `dataZoomInfoMap` each round of the workflow. - coordSysRecord.dataZoomInfoMap = null; - }); + registers.registerUpdateLifecycle('coordsys:aftercreate', (ecModel, api) => { + const apiInner = inner(api); + const coordSysRecordMap = apiInner.coordSysRecordMap + || (apiInner.coordSysRecordMap = createHashMap()); - ecModel.eachComponent( - { mainType: 'dataZoom', subType: 'inside' }, - function (dataZoomModel: InsideZoomModel) { - const dzReferCoordSysWrap = collectReferCoordSysModelInfo(dataZoomModel); - - each(dzReferCoordSysWrap.infoList, function (dzCoordSysInfo) { - - const coordSysUid = dzCoordSysInfo.model.uid; - const coordSysRecord = coordSysRecordMap.get(coordSysUid) - || coordSysRecordMap.set(coordSysUid, createCoordSysRecord(api, dzCoordSysInfo.model)); - - const dataZoomInfoMap = coordSysRecord.dataZoomInfoMap - || (coordSysRecord.dataZoomInfoMap = createHashMap()); - // Notice these props might be changed each time for a single dataZoomModel. - dataZoomInfoMap.set(dataZoomModel.uid, { - dzReferCoordSysInfo: dzCoordSysInfo, - model: dataZoomModel, - getRange: null - }); - }); - } - ); + coordSysRecordMap.each(function (coordSysRecord) { + // `coordSysRecordMap` always exists (because it holds the `roam controller`, which should + // better not re-create each time), but clear `dataZoomInfoMap` each round of the workflow. + coordSysRecord.dataZoomInfoMap = null; + }); - // (1) Merge dataZoom settings for each coord sys and set to the roam controller. - // (2) Clear coord sys if not refered by any dataZoom. - coordSysRecordMap.each(function (coordSysRecord) { - const controller = coordSysRecord.controller; - let firstDzInfo: DataZoomInfo; - const dataZoomInfoMap = coordSysRecord.dataZoomInfoMap; - - if (dataZoomInfoMap) { - const firstDzKey = dataZoomInfoMap.keys()[0]; - if (firstDzKey != null) { - firstDzInfo = dataZoomInfoMap.get(firstDzKey); - } - } + ecModel.eachComponent( + { mainType: 'dataZoom', subType: 'inside' }, + function (dataZoomModel: InsideZoomModel) { + const dzReferCoordSysWrap = collectReferCoordSysModelInfo(dataZoomModel); - if (!firstDzInfo) { - disposeCoordSysRecord(coordSysRecordMap, coordSysRecord); - return; + each(dzReferCoordSysWrap.infoList, function (dzCoordSysInfo) { + + const coordSysUid = dzCoordSysInfo.model.uid; + const coordSysRecord = coordSysRecordMap.get(coordSysUid) + || coordSysRecordMap.set(coordSysUid, createCoordSysRecord(api, dzCoordSysInfo.model)); + + const dataZoomInfoMap = coordSysRecord.dataZoomInfoMap + || (coordSysRecord.dataZoomInfoMap = createHashMap()); + // Notice these props might be changed each time for a single dataZoomModel. + dataZoomInfoMap.set(dataZoomModel.uid, { + dzReferCoordSysInfo: dzCoordSysInfo, + model: dataZoomModel, + getRange: null + }); + }); + } + ); + + // (1) Merge dataZoom settings for each coord sys and set to the roam controller. + // (2) Clear coord sys if not referred by any dataZoom. + coordSysRecordMap.each(function (coordSysRecord) { + const controller = coordSysRecord.controller; + let firstDzInfo: DataZoomInfo; + const dataZoomInfoMap = coordSysRecord.dataZoomInfoMap; + + if (dataZoomInfoMap) { + const firstDzKey = dataZoomInfoMap.keys()[0]; + if (firstDzKey != null) { + firstDzInfo = dataZoomInfoMap.get(firstDzKey); } + } + + if (!firstDzInfo) { + disposeCoordSysRecord(coordSysRecordMap, coordSysRecord); + return; + } - const controllerParams = mergeControllerParams(dataZoomInfoMap, coordSysRecord, api); - controller.enable(controllerParams.controlType, controllerParams.opt); + const controllerParams = mergeControllerParams(dataZoomInfoMap, coordSysRecord, api); + controller.enable(controllerParams.controlType, controllerParams.opt); - throttleUtil.createOrUpdate( - coordSysRecord, - 'dispatchAction', - firstDzInfo.model.get('throttle', true), - 'fixRate' - ); - }); + throttleUtil.createOrUpdate( + coordSysRecord, + 'dispatchAction', + firstDzInfo.model.get('throttle', true), + 'fixRate' + ); + }); }); } diff --git a/src/component/helper/BrushTargetManager.ts b/src/component/helper/BrushTargetManager.ts index 9a378fd001..4fb7170c98 100644 --- a/src/component/helper/BrushTargetManager.ts +++ b/src/component/helper/BrushTargetManager.ts @@ -38,7 +38,8 @@ import { Dictionary } from '../../util/types'; import { ModelFinderObject, ModelFinder, parseFinder as modelUtilParseFinder, - ParsedModelFinderKnown + ParsedModelFinderKnown, + initExtentForUnion } from '../../util/model'; type COORD_CONVERTS_INDEX = 0 | 1; @@ -442,7 +443,7 @@ const coordConvert: Record = { values: BrushDimensionMinMax[], xyMinMax: BrushDimensionMinMax[] } { - const xyMinMax = [[Infinity, -Infinity], [Infinity, -Infinity]]; + const xyMinMax = [initExtentForUnion(), initExtentForUnion()]; const values = map(rangeOrCoordRange, function (item) { const p = to ? coordSys.pointToData(item, clamp) : coordSys.dataToPoint(item, clamp); xyMinMax[0][0] = Math.min(xyMinMax[0][0], p[0]); diff --git a/src/component/helper/RoamController.ts b/src/component/helper/RoamController.ts index 4c057412e5..2a87b993c6 100644 --- a/src/component/helper/RoamController.ts +++ b/src/component/helper/RoamController.ts @@ -180,7 +180,7 @@ class RoamController extends Eventful { controlType = true; } - // A handy optimization for repeatedly calling `enable` during roaming. + // A quick optimization for repeatedly calling `enable` during roaming. // Assert `disable` is only affected by `controlType`. if (!this._enabled || this._controlType !== controlType) { this._enabled = true; diff --git a/src/component/helper/sliderMove.ts b/src/component/helper/sliderMove.ts index 902312d301..721f6704a2 100644 --- a/src/component/helper/sliderMove.ts +++ b/src/component/helper/sliderMove.ts @@ -17,6 +17,8 @@ * under the License. */ +import { addSafe } from '../../util/number'; + /** * Calculate slider move result. * Usage: @@ -24,6 +26,9 @@ * maxSpan and the same as `Math.abs(handleEnd[1] - handleEnds[0])`. * (2) If handle0 is forbidden to cross handle1, set minSpan as `0`. * + * [CAVEAT] + * This method is inefficient due to the use of `addSafe`. + * * @param delta Move length. * @param handleEnds handleEnds[0] can be bigger then handleEnds[1]. * handleEnds will be modified in this method. @@ -48,7 +53,9 @@ export default function sliderMove( delta = delta || 0; - const extentSpan = extent[1] - extent[0]; + // Consider `7.1e-9 - 7e-9` get `1.0000000000000007e-10`, so use `addSafe` + // to remove rounding error whenever possible. + const extentSpan = addSafe(extent[1], -extent[0]); // Notice maxSpan and minSpan can be null/undefined. if (minSpan != null) { @@ -58,7 +65,7 @@ export default function sliderMove( maxSpan = Math.max(maxSpan, minSpan != null ? minSpan : 0); } if (handleIndex === 'all') { - let handleSpan = Math.abs(handleEnds[1] - handleEnds[0]); + let handleSpan = Math.abs(addSafe(handleEnds[1], -handleEnds[0])); handleSpan = restrict(handleSpan, [0, extentSpan]); minSpan = maxSpan = restrict(handleSpan, [minSpan, maxSpan]); handleIndex = 0; @@ -74,7 +81,9 @@ export default function sliderMove( // Restrict in extent. const extentMinSpan = minSpan || 0; const realExtent = extent.slice(); - originalDistSign.sign < 0 ? (realExtent[0] += extentMinSpan) : (realExtent[1] -= extentMinSpan); + originalDistSign.sign < 0 + ? (realExtent[0] = addSafe(realExtent[0], extentMinSpan)) + : (realExtent[1] = addSafe(realExtent[1], -extentMinSpan)); handleEnds[handleIndex] = restrict(handleEnds[handleIndex], realExtent); // Expand span. @@ -84,13 +93,13 @@ export default function sliderMove( currDistSign.sign !== originalDistSign.sign || currDistSign.span < minSpan )) { // If minSpan exists, 'cross' is forbidden. - handleEnds[1 - handleIndex] = handleEnds[handleIndex] + originalDistSign.sign * minSpan; + handleEnds[1 - handleIndex] = addSafe(handleEnds[handleIndex], originalDistSign.sign * minSpan); } // Shrink span. currDistSign = getSpanSign(handleEnds, handleIndex); if (maxSpan != null && currDistSign.span > maxSpan) { - handleEnds[1 - handleIndex] = handleEnds[handleIndex] + currDistSign.sign * maxSpan; + handleEnds[1 - handleIndex] = addSafe(handleEnds[handleIndex], currDistSign.sign * maxSpan); } return handleEnds; diff --git a/src/component/legend/LegendView.ts b/src/component/legend/LegendView.ts index 2dc195fcae..142bb32d68 100644 --- a/src/component/legend/LegendView.ts +++ b/src/component/legend/LegendView.ts @@ -724,25 +724,13 @@ function dispatchSelectAction( dispatchHighlightAction(seriesName, dataName, api, excludeSeriesId); } -function isUseHoverLayer(api: ExtensionAPI) { - const list = api.getZr().storage.getDisplayList(); - let emphasisState: DisplayableState; - let i = 0; - const len = list.length; - while (i < len && !(emphasisState = list[i].states.emphasis)) { - i++; - } - return emphasisState && emphasisState.hoverLayer; -} - function dispatchHighlightAction( seriesName: string, dataName: string, api: ExtensionAPI, excludeSeriesId: string[] ) { - // If element hover will move to a hoverLayer. - if (!isUseHoverLayer(api)) { + if (!api.usingTHL()) { api.dispatchAction({ type: 'highlight', seriesName: seriesName, @@ -758,8 +746,7 @@ function dispatchDownplayAction( api: ExtensionAPI, excludeSeriesId: string[] ) { - // If element hover will move to a hoverLayer. - if (!isUseHoverLayer(api)) { + if (!api.usingTHL()) { api.dispatchAction({ type: 'downplay', seriesName: seriesName, diff --git a/src/component/matrix/MatrixView.ts b/src/component/matrix/MatrixView.ts index ae5ff13cec..ce2e45adea 100644 --- a/src/component/matrix/MatrixView.ts +++ b/src/component/matrix/MatrixView.ts @@ -273,7 +273,7 @@ function createMatrixCell( tooltipOption: MatrixOption['tooltip'], targetType: MatrixTargetType ): void { - // Do not use getModel for handy performance optimization. + // Do not use getModel - a quick performance optimization. _tmpCellItemStyleModel.option = cellOption ? cellOption.itemStyle : null; _tmpCellItemStyleModel.parentModel = parentItemStyleModel; _tmpCellModel.option = cellOption; diff --git a/src/component/polar/install.ts b/src/component/polar/install.ts index fbd61fa45f..e2cde4209e 100644 --- a/src/component/polar/install.ts +++ b/src/component/polar/install.ts @@ -34,7 +34,8 @@ import AngleAxisView from '../axis/AngleAxisView'; import RadiusAxisView from '../axis/RadiusAxisView'; import ComponentView from '../../view/Component'; import { curry } from 'zrender/src/core/util'; -import barLayoutPolar from '../../layout/barPolar'; +import { barLayoutPolar, registerBarPolarAxisHandlers } from '../../layout/barPolar'; +import { BAR_SERIES_TYPE } from '../../layout/barCommon'; const angleAxisExtraOption: AngleAxisOption = { @@ -44,6 +45,9 @@ const angleAxisExtraOption: AngleAxisOption = { splitNumber: 12, + // A round axis is not suitable for `containShape` in most cases. + containShape: false, + axisLabel: { rotate: 0 } @@ -76,5 +80,7 @@ export function install(registers: EChartsExtensionInstallRegisters) { registers.registerComponentView(AngleAxisView); registers.registerComponentView(RadiusAxisView); - registers.registerLayout(curry(barLayoutPolar, 'bar')); + registers.registerLayout(curry(barLayoutPolar, BAR_SERIES_TYPE)); + + registerBarPolarAxisHandlers(registers, BAR_SERIES_TYPE); } \ No newline at end of file diff --git a/src/component/singleAxis/install.ts b/src/component/singleAxis/install.ts index b2e6b8dc88..c949a1e532 100644 --- a/src/component/singleAxis/install.ts +++ b/src/component/singleAxis/install.ts @@ -21,7 +21,7 @@ import { EChartsExtensionInstallRegisters, use } from '../../extension'; import ComponentView from '../../view/Component'; import SingleAxisView from '../axis/SingleAxisView'; import axisModelCreator from '../../coord/axisModelCreator'; -import SingleAxisModel from '../../coord/single/AxisModel'; +import SingleAxisModel, { COORD_SYS_TYPE_SINGLE_AXIS } from '../../coord/single/AxisModel'; import singleCreator from '../../coord/single/singleCreator'; import {install as installAxisPointer} from '../axisPointer/install'; import AxisView from '../axis/AxisView'; @@ -43,7 +43,7 @@ export function install(registers: EChartsExtensionInstallRegisters) { registers.registerComponentView(SingleAxisView); registers.registerComponentModel(SingleAxisModel); - axisModelCreator(registers, 'single', SingleAxisModel, SingleAxisModel.defaultOption); + axisModelCreator(registers, COORD_SYS_TYPE_SINGLE_AXIS, SingleAxisModel, SingleAxisModel.defaultOption); registers.registerCoordinateSystem('single', singleCreator); } \ No newline at end of file diff --git a/src/component/thumbnail/ThumbnailBridgeImpl.ts b/src/component/thumbnail/ThumbnailBridgeImpl.ts index 9b33cec8c5..f155613dcd 100644 --- a/src/component/thumbnail/ThumbnailBridgeImpl.ts +++ b/src/component/thumbnail/ThumbnailBridgeImpl.ts @@ -51,7 +51,7 @@ export class ThumbnailBridgeImpl implements ThumbnailBridge { } reset(api: ExtensionAPI) { - this._renderVersion = api.getMainProcessVersion(); + this._renderVersion = api.getECMainCycleVersion(); } renderContent(opt: { diff --git a/src/component/thumbnail/ThumbnailView.ts b/src/component/thumbnail/ThumbnailView.ts index 617f6dfda1..a08f1470b2 100644 --- a/src/component/thumbnail/ThumbnailView.ts +++ b/src/component/thumbnail/ThumbnailView.ts @@ -70,7 +70,7 @@ export class ThumbnailView extends ComponentView { return; } - this._renderVersion = api.getMainProcessVersion(); + this._renderVersion = api.getECMainCycleVersion(); const group = this.group; group.removeAll(); diff --git a/src/component/timeline/SliderTimelineView.ts b/src/component/timeline/SliderTimelineView.ts index 0a547abd75..371e5456e8 100644 --- a/src/component/timeline/SliderTimelineView.ts +++ b/src/component/timeline/SliderTimelineView.ts @@ -23,7 +23,7 @@ import * as graphic from '../../util/graphic'; import { createTextStyle } from '../../label/labelStyle'; import * as layout from '../../util/layout'; import TimelineView from './TimelineView'; -import TimelineAxis from './TimelineAxis'; +import TimelineAxis, { TimelineAxisType } from './TimelineAxis'; import {createSymbol, normalizeSymbolOffset, normalizeSymbolSize} from '../../util/symbol'; import * as numberUtil from '../../util/number'; import GlobalModel from '../../model/Global'; @@ -35,10 +35,6 @@ import TimelineModel, { TimelineDataItemOption, TimelineCheckpointStyle } from ' import { TimelineChangePayload, TimelinePlayChangePayload } from './timelineAction'; import Model from '../../model/Model'; import { PathProps, PathStyleProps } from 'zrender/src/graphic/Path'; -import Scale from '../../scale/Scale'; -import OrdinalScale from '../../scale/Ordinal'; -import TimeScale from '../../scale/Time'; -import IntervalScale from '../../scale/Interval'; import { VectorArray } from 'zrender/src/core/vector'; import { parsePercent } from 'zrender/src/contain/text'; import { makeInner } from '../../util/model'; @@ -46,6 +42,8 @@ import { getECData } from '../../util/innerStore'; import { enableHoverEmphasis } from '../../util/states'; import { createTooltipMarkup } from '../tooltip/tooltipMarkup'; import Displayable from 'zrender/src/graphic/Displayable'; +import { createScaleByModel } from '../../coord/axisHelper'; +import { scaleCalcNiceDirectly } from '../../coord/axisNiceTicks'; const PI = Math.PI; @@ -338,9 +336,12 @@ class SliderTimelineView extends TimelineView { private _createAxis(layoutInfo: LayoutInfo, timelineModel: SliderTimelineModel) { const data = timelineModel.getData(); - const axisType = timelineModel.get('axisType'); + let axisType = timelineModel.get('axisType') || timelineModel.get('type') as TimelineAxisType; + if (axisType !== 'category' && axisType !== 'time') { + axisType = 'value'; + } - const scale = createScaleByModel(timelineModel, axisType); + const scale = createScaleByModel(timelineModel, axisType, false); // Customize scale. The `tickValue` is `dataIndex`. scale.getTicks = function () { @@ -351,7 +352,7 @@ class SliderTimelineView extends TimelineView { const dataExtent = data.getDataExtent('value'); scale.setExtent(dataExtent[0], dataExtent[1]); - scale.calcNiceTicks(); + scaleCalcNiceDirectly(scale, {fixMinMax: [true, true]}); const axis = new TimelineAxis('value', scale, layoutInfo.axisExtent as [number, number], axisType); axis.model = timelineModel; @@ -471,14 +472,14 @@ class SliderTimelineView extends TimelineView { each(labels, (labelItem) => { // The tickValue is dataIndex, see the customized scale. - const dataIndex = labelItem.tickValue; + const dataIndex = labelItem.tick.value; const itemModel = data.getItemModel(dataIndex); const normalLabelModel = itemModel.getModel('label'); const hoverLabelModel = itemModel.getModel(['emphasis', 'label']); const progressLabelModel = itemModel.getModel(['progress', 'label']); - const tickCoord = axis.dataToCoord(labelItem.tickValue); + const tickCoord = axis.dataToCoord(dataIndex); const textEl = new graphic.Text({ x: tickCoord, y: 0, @@ -730,28 +731,6 @@ class SliderTimelineView extends TimelineView { } } -function createScaleByModel(model: SliderTimelineModel, axisType?: string): Scale { - axisType = axisType || model.get('type'); - if (axisType) { - switch (axisType) { - // Buildin scale - case 'category': - return new OrdinalScale({ - ordinalMeta: model.getCategories(), - extent: [Infinity, -Infinity] - }); - case 'time': - return new TimeScale({ - locale: model.ecModel.getLocaleModel(), - useUTC: model.ecModel.get('useUTC') - }); - default: - // default to be value - return new IntervalScale(); - } - } -} - function getViewRect(model: SliderTimelineModel, api: ExtensionAPI) { return layout.getLayoutRect( diff --git a/src/component/timeline/TimelineAxis.ts b/src/component/timeline/TimelineAxis.ts index e0f21156ae..59e9284c53 100644 --- a/src/component/timeline/TimelineAxis.ts +++ b/src/component/timeline/TimelineAxis.ts @@ -23,12 +23,14 @@ import TimelineModel from './TimelineModel'; import { LabelOption } from '../../util/types'; import Model from '../../model/Model'; +export type TimelineAxisType = 'category' | 'time' | 'value'; + /** * Extend axis 2d */ class TimelineAxis extends Axis { - type: 'category' | 'time' | 'value'; + type: TimelineAxisType; // @ts-ignore model: TimelineModel; @@ -37,7 +39,7 @@ class TimelineAxis extends Axis { dim: string, scale: Scale, coordExtent: [number, number], - axisType: 'category' | 'time' | 'value' + axisType: TimelineAxisType ) { super(dim, scale, coordExtent); this.type = axisType || 'value'; diff --git a/src/component/timeline/TimelineModel.ts b/src/component/timeline/TimelineModel.ts index d366589d99..ad6de3166b 100644 --- a/src/component/timeline/TimelineModel.ts +++ b/src/component/timeline/TimelineModel.ts @@ -292,10 +292,6 @@ class TimelineModel extends ComponentModel { return this._data; } - /** - * @public - * @return {Array.} categoreis - */ getCategories() { if (this.get('axisType') === 'category') { return this._names.slice(); diff --git a/src/component/toolbox/ToolboxModel.ts b/src/component/toolbox/ToolboxModel.ts index 49debe77b5..27037e3ae9 100644 --- a/src/component/toolbox/ToolboxModel.ts +++ b/src/component/toolbox/ToolboxModel.ts @@ -17,7 +17,6 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; import * as featureManager from './featureManager'; import ComponentModel from '../../model/Component'; import { @@ -31,9 +30,13 @@ import { CommonTooltipOption, Dictionary, ComponentOnCalendarOptionMixin, - ComponentOnMatrixOptionMixin + ComponentOnMatrixOptionMixin, + NullUndefined } from '../../util/types'; import tokens from '../../visual/tokens'; +import type GlobalModel from '../../model/Global'; +import type Model from '../../model/Model'; +import { each, extend, merge } from 'zrender/src/core/util'; export interface ToolboxTooltipFormatterParams { @@ -93,19 +96,49 @@ class ToolboxModel extends ComponentModel { ignoreSize: true } as const; - optionUpdated() { - super.optionUpdated.apply(this, arguments as any); - const {ecModel} = this; + private _themeFeatureOption: ToolboxOption['feature']; + + init(option: ToolboxOption, parentModel: Model, ecModel: GlobalModel): void { + // An historical behavior: + // An initial ec option + // chart.setOption( {toolbox: {feature: { featureA: {}, featureB: {}, }} } ) + // indicates the declared toolbox features need to be enabled regardless of whether property + // "show" is explicity specified. But the subsequent `setOption` in merge mode requires property + // "show: false" to be explicity specified if intending to remove features, for example: + // chart.setOption( {toolbox: {feature: { featureA: {show: false}, featureC: {} } ) + // We keep backward compatibility and perform specific processing to prevent theme + // settings from breaking it. + const toolboxOptionInTheme = ecModel.getTheme().get('toolbox'); + const themeFeatureOption = toolboxOptionInTheme ? toolboxOptionInTheme.feature : null; + if (themeFeatureOption) { + // Use extend - the first level of the feature option will be modified later. + this._themeFeatureOption = extend({}, themeFeatureOption); + toolboxOptionInTheme.feature = {}; + } - zrUtil.each(this.option.feature, function (featureOpt, featureName) { + super.init(option, parentModel, ecModel); // merge theme is performed inside it. + + if (themeFeatureOption) { + toolboxOptionInTheme.feature = themeFeatureOption; // Recover + } + } + + optionUpdated() { + each(this.option.feature, function (featureOpt, featureName) { + const themeFeatureOption = this._themeFeatureOption; const Feature = featureManager.getFeature(featureName); if (Feature) { if (Feature.getDefaultOption) { - Feature.defaultOption = Feature.getDefaultOption(ecModel); + Feature.defaultOption = Feature.getDefaultOption(this.ecModel); + } + if (themeFeatureOption && themeFeatureOption[featureName]) { + merge(featureOpt, themeFeatureOption[featureName]); + // Follow the previous behavior, theme is only be merged once. + themeFeatureOption[featureName] = null; } - zrUtil.merge(featureOpt, Feature.defaultOption); + merge(featureOpt, Feature.defaultOption); } - }); + }, this); } static defaultOption: ToolboxOption = { diff --git a/src/component/toolbox/ToolboxView.ts b/src/component/toolbox/ToolboxView.ts index d3f96d1b40..8b7c82421a 100644 --- a/src/component/toolbox/ToolboxView.ts +++ b/src/component/toolbox/ToolboxView.ts @@ -17,7 +17,6 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; import * as textContain from 'zrender/src/contain/text'; import * as graphic from '../../util/graphic'; import { enterEmphasis, leaveEmphasis } from '../../util/states'; @@ -28,7 +27,7 @@ import ComponentView from '../../view/Component'; import ToolboxModel from './ToolboxModel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; -import { DisplayState, Dictionary, Payload } from '../../util/types'; +import { DisplayState, Dictionary, Payload, NullUndefined } from '../../util/types'; import { ToolboxFeature, getFeature, @@ -42,8 +41,10 @@ import ZRText from 'zrender/src/graphic/Text'; import { getFont } from '../../label/labelStyle'; import { box, createBoxLayoutReference, getLayoutRect, positionElement } from '../../util/layout'; import tokens from '../../visual/tokens'; +import { bind, createHashMap, curry, each, filter, HashMap, isFunction, isString } from 'zrender/src/core/util'; type IconPath = ToolboxFeatureModel['iconPaths'][string]; +type FeatureName = string; type ExtendedPath = IconPath & { __title: string @@ -52,9 +53,15 @@ type ExtendedPath = IconPath & { class ToolboxView extends ComponentView { static type = 'toolbox' as const; - _features: Dictionary; + /** + * Current enabled features, including only features having `show: true`. + */ + _features: HashMap; - _featureNames: string[]; + /** + * Current enabled feature names, including only features having `show: true`. + */ + _featureNames: FeatureName[]; render( toolboxModel: ToolboxModel, @@ -74,35 +81,46 @@ class ToolboxView extends ComponentView { const itemSize = +toolboxModel.get('itemSize'); const isVertical = toolboxModel.get('orient') === 'vertical'; const featureOpts = toolboxModel.get('feature') || {}; - const features = this._features || (this._features = {}); + const features = this._features || (this._features = createHashMap()); - const featureNames: string[] = []; - zrUtil.each(featureOpts, function (opt, name) { - featureNames.push(name); + const newFeatureNames: FeatureName[] = []; // Includes both `show: true/false`. + each(featureOpts, function (opt, name) { + newFeatureNames.push(name); }); - (new DataDiffer(this._featureNames || [], featureNames)) + // Diff by feature name. + (new DataDiffer(this._featureNames || [], newFeatureNames)) .add(processFeature) .update(processFeature) - .remove(zrUtil.curry(processFeature, null)) + .remove(curry(processFeature, null)) .execute(); // Keep for diff. - this._featureNames = featureNames; + this._featureNames = filter(newFeatureNames, function (name) { + return features.hasKey(name); + }); + + function processFeature(newIndex: number | NullUndefined, oldIndex?: number | NullUndefined) { + const isDiffAdd = newIndex != null && oldIndex == null; + const isDiffUpdate = newIndex != null && oldIndex != null; + const isDiffRemove = newIndex == null; - function processFeature(newIndex: number, oldIndex?: number) { - const featureName = featureNames[newIndex]; - const oldName = featureNames[oldIndex]; + const featureName = (isDiffAdd || isDiffUpdate) + ? newFeatureNames[newIndex] + : newFeatureNames[oldIndex]; const featureOpt = featureOpts[featureName]; - const featureModel = new Model(featureOpt, toolboxModel, toolboxModel.ecModel) as ToolboxFeatureModel; - let feature: ToolboxFeature | UserDefinedToolboxFeature; + const featureModel = (isDiffAdd || isDiffUpdate) + ? new Model(featureOpt, toolboxModel, ecModel) as ToolboxFeatureModel + : null; + // `.get('show')` Also considered UserDefinedToolboxFeature + const isFeatureShow = featureModel && featureModel.get('show'); - // FIX#11236, merge feature title from MagicType newOption. TODO: consider seriesIndex ? - if (payload && payload.newTitle != null && payload.featureName === featureName) { - featureOpt.title = payload.newTitle; - } + let feature: ToolboxFeature | UserDefinedToolboxFeature; - if (featureName && !oldName) { // Create + if (isDiffAdd) { // DIFF_ADD + if (!isFeatureShow) { + return; + } if (isUserFeatureName(featureName)) { feature = { onclick: featureModel.option.onclick, @@ -116,35 +134,33 @@ class ToolboxView extends ComponentView { } feature = new Feature(); } - features[featureName] = feature; + features.set(featureName, feature); } - else { - feature = features[oldName]; - // If feature does not exist. - if (!feature) { - return; - } + else { // DIFF_UPDATE or DIFF_REMOVE + feature = features.get(featureName); } - feature.uid = getUID('toolbox-feature'); - feature.model = featureModel; - feature.ecModel = ecModel; - feature.api = api; - const isToolboxFeature = feature instanceof ToolboxFeature; - if (!featureName && oldName) { - isToolboxFeature - && (feature as ToolboxFeature).dispose - && (feature as ToolboxFeature).dispose(ecModel, api); + if (isDiffRemove || !isFeatureShow) { + if (isTooltipFeature(feature) && feature.dispose) { + feature.dispose(ecModel, api); + } + features.removeKey(featureName); return; } - if (!featureModel.get('show') || (isToolboxFeature && (feature as ToolboxFeature).unusable)) { - isToolboxFeature - && (feature as ToolboxFeature).remove - && (feature as ToolboxFeature).remove(ecModel, api); - return; + // FIX#11236, merge feature title from MagicType newOption. TODO: consider seriesIndex ? + if (payload && payload.newTitle != null && payload.featureName === featureName) { + // FIXME: ec option should not be modified here. + featureOpt.title = payload.newTitle; } + if (isDiffAdd) { + feature.uid = getUID('toolbox-feature'); + } + feature.model = featureModel; + feature.ecModel = ecModel; + feature.api = api; + createIconPaths(featureModel, feature, featureName); featureModel.setIconStatus = function (this: ToolboxFeatureModel, iconName: string, status: DisplayState) { @@ -157,10 +173,8 @@ class ToolboxView extends ComponentView { } }; - if (feature instanceof ToolboxFeature) { - if (feature.render) { - feature.render(featureModel, ecModel, api, payload); - } + if (isTooltipFeature(feature) && feature.render) { + feature.render(featureModel, ecModel, api, payload); } } @@ -188,14 +202,14 @@ class ToolboxView extends ComponentView { const titles = featureModel.get('title') || {}; let iconsMap: Dictionary; let titlesMap: Dictionary; - if (zrUtil.isString(icons)) { + if (isString(icons)) { iconsMap = {}; iconsMap[featureName] = icons; } else { iconsMap = icons; } - if (zrUtil.isString(titles)) { + if (isString(titles)) { titlesMap = {}; titlesMap[featureName] = titles as string; } @@ -203,7 +217,7 @@ class ToolboxView extends ComponentView { titlesMap = titles; } const iconPaths: ToolboxFeatureModel['iconPaths'] = featureModel.iconPaths = {}; - zrUtil.each(iconsMap, function (iconStr, iconName) { + each(iconsMap, function (iconStr, iconName) { const path = graphic.createIcon( iconStr, {}, @@ -286,7 +300,7 @@ class ToolboxView extends ComponentView { (featureModel.get(['iconStatus', iconName]) === 'emphasis' ? enterEmphasis : leaveEmphasis)(path); group.add(path); - (path as graphic.Path).on('click', zrUtil.bind( + (path as graphic.Path).on('click', bind( feature.onclick, feature, ecModel, api, iconName )); @@ -331,7 +345,7 @@ class ToolboxView extends ComponentView { const textContent = icon.getTextContent(); const emphasisTextState = textContent && textContent.ensureState('emphasis'); // May be background element - if (emphasisTextState && !zrUtil.isFunction(emphasisTextState) && titleText) { + if (emphasisTextState && !isFunction(emphasisTextState) && titleText) { const emphasisTextStyle = emphasisTextState.style || (emphasisTextState.style = {}); const rect = textContain.getBoundingRect( titleText, ZRText.makeFont(emphasisTextStyle) @@ -363,30 +377,20 @@ class ToolboxView extends ComponentView { api: ExtensionAPI, payload: unknown ) { - zrUtil.each(this._features, function (feature) { - feature instanceof ToolboxFeature - && feature.updateView && feature.updateView(feature.model, ecModel, api, payload); - }); - } - - // updateLayout(toolboxModel, ecModel, api, payload) { - // zrUtil.each(this._features, function (feature) { - // feature.updateLayout && feature.updateLayout(feature.model, ecModel, api, payload); - // }); - // }, - - remove(ecModel: GlobalModel, api: ExtensionAPI) { - zrUtil.each(this._features, function (feature) { - feature instanceof ToolboxFeature - && feature.remove && feature.remove(ecModel, api); + each(this._features, function (feature) { + feature + && feature instanceof ToolboxFeature + && feature.updateView + && feature.updateView(feature.model, ecModel, api, payload); }); - this.group.removeAll(); } dispose(ecModel: GlobalModel, api: ExtensionAPI) { - zrUtil.each(this._features, function (feature) { - feature instanceof ToolboxFeature - && feature.dispose && feature.dispose(ecModel, api); + each(this._features, function (feature) { + feature + && feature instanceof ToolboxFeature + && feature.dispose + && feature.dispose(ecModel, api); }); } } @@ -395,4 +399,9 @@ class ToolboxView extends ComponentView { function isUserFeatureName(featureName: string): boolean { return featureName.indexOf('my') === 0; } + +function isTooltipFeature(feature: ToolboxFeature | UserDefinedToolboxFeature): feature is ToolboxFeature { + return feature instanceof ToolboxFeature; +} + export default ToolboxView; diff --git a/src/component/toolbox/feature/DataView.ts b/src/component/toolbox/feature/DataView.ts index 300417a7b3..7f4c506fc0 100644 --- a/src/component/toolbox/feature/DataView.ts +++ b/src/component/toolbox/feature/DataView.ts @@ -31,6 +31,7 @@ import Axis from '../../../coord/Axis'; import Cartesian2D from '../../../coord/cartesian/Cartesian2D'; import { warn } from '../../../util/log'; import tokens from '../../../visual/tokens'; +import { getCartesianAxisHashKey } from '../../../coord/cartesian/cartesianAxisHelper'; /* global document */ @@ -74,10 +75,10 @@ function groupSeries(ecModel: GlobalModel) { const coordSys = seriesModel.coordinateSystem; if (coordSys && (coordSys.type === 'cartesian2d' || coordSys.type === 'polar')) { - // TODO: TYPE Consider polar? Include polar may increase unecessary bundle size. + // TODO: TYPE Consider polar? Include polar may increase unnecessary bundle size. const baseAxis = (coordSys as Cartesian2D).getBaseAxis(); if (baseAxis.type === 'category') { - const key = baseAxis.dim + '_' + baseAxis.index; + const key = getCartesianAxisHashKey(baseAxis); if (!seriesGroupByCategoryAxis[key]) { seriesGroupByCategoryAxis[key] = { categoryAxis: baseAxis, @@ -446,12 +447,8 @@ class DataView extends ToolboxFeature { this._dom = root; } - remove(ecModel: GlobalModel, api: ExtensionAPI) { - this._dom && api.getDom().removeChild(this._dom); - } - dispose(ecModel: GlobalModel, api: ExtensionAPI) { - this.remove(ecModel, api); + this._dom && api.getDom().removeChild(this._dom); } static getDefaultOption(ecModel: GlobalModel) { diff --git a/src/component/toolbox/feature/DataZoom.ts b/src/component/toolbox/feature/DataZoom.ts index e51ace23cf..0c57324def 100644 --- a/src/component/toolbox/feature/DataZoom.ts +++ b/src/component/toolbox/feature/DataZoom.ts @@ -35,9 +35,7 @@ import { Payload, Dictionary, ComponentOption, ItemStyleOption } from '../../../ import Cartesian2D from '../../../coord/cartesian/Cartesian2D'; import CartesianAxisModel from '../../../coord/cartesian/AxisModel'; import DataZoomModel from '../../dataZoom/DataZoomModel'; -import { - DataZoomPayloadBatchItem, DataZoomAxisDimension -} from '../../dataZoom/helper'; +import {DataZoomPayloadBatchItem} from '../../dataZoom/helper'; import { ModelFinderObject, ModelFinderIndexQuery, makeInternalComponentId, ModelFinderIdQuery, parseFinder, ParsedModelFinderKnown @@ -46,6 +44,8 @@ import ToolboxModel from '../ToolboxModel'; import { registerInternalOptionCreator } from '../../../model/internalComponentCreator'; import ComponentModel from '../../../model/Component'; import tokens from '../../../visual/tokens'; +import BoundingRect from 'zrender/src/core/BoundingRect'; +import { getAcceptableTickPrecision, round } from '../../../util/number'; const each = zrUtil.each; @@ -55,6 +55,9 @@ const DATA_ZOOM_ID_BASE = makeInternalComponentId('toolbox-dataZoom_'); const ICON_TYPES = ['zoom', 'back'] as const; type IconType = typeof ICON_TYPES[number]; +const XY2WH = {x: 'width', y: 'height'} as const; + + export interface ToolboxDataZoomFeatureOption extends ToolboxFeatureOption { type?: IconType[] icon?: {[key in IconType]?: string} @@ -101,13 +104,6 @@ class DataZoomFeature extends ToolboxFeature { handlers[type].call(this); } - remove( - ecModel: GlobalModel, - api: ExtensionAPI - ) { - this._brushController && this._brushController.unmount(); - } - dispose( ecModel: GlobalModel, api: ExtensionAPI @@ -135,15 +131,18 @@ class DataZoomFeature extends ToolboxFeature { return; } + const coordSysRect = coordSys.master.getRect().clone(); + const brushType = area.brushType; if (brushType === 'rect') { - setBatch('x', coordSys, (coordRange as BrushDimensionMinMax[])[0]); - setBatch('y', coordSys, (coordRange as BrushDimensionMinMax[])[1]); + setBatch('x', coordSys, coordSysRect, (coordRange as BrushDimensionMinMax[])[0]); + setBatch('y', coordSys, coordSysRect, (coordRange as BrushDimensionMinMax[])[1]); } else { setBatch( ({lineX: 'x', lineY: 'y'} as const)[brushType as 'lineX' | 'lineY'], coordSys, + coordSysRect, coordRange as BrushDimensionMinMax ); } @@ -153,29 +152,42 @@ class DataZoomFeature extends ToolboxFeature { this._dispatchZoomAction(snapshot); - function setBatch(dimName: DataZoomAxisDimension, coordSys: Cartesian2D, minMax: number[]) { + function setBatch( + dimName: 'x' | 'y', + coordSys: Cartesian2D, + coordSysRect: BoundingRect, + minMax: number[] + ) { const axis = coordSys.getAxis(dimName); const axisModel = axis.model; const dataZoomModel = findDataZoom(dimName, axisModel, ecModel); // Restrict range. const minMaxSpan = dataZoomModel.findRepresentativeAxisProxy(axisModel).getMinMaxSpan(); + const scaleExtent = axis.scale.getExtent(); if (minMaxSpan.minValueSpan != null || minMaxSpan.maxValueSpan != null) { minMax = sliderMove( - 0, minMax.slice(), axis.scale.getExtent(), 0, + 0, minMax.slice(), scaleExtent, 0, minMaxSpan.minValueSpan, minMaxSpan.maxValueSpan ); } + // Round for displayable. + const precision = getAcceptableTickPrecision( + scaleExtent, + coordSysRect[XY2WH[dimName]], + 0.5 + ); + dataZoomModel && (snapshot[dataZoomModel.id] = { dataZoomId: dataZoomModel.id, - startValue: minMax[0], - endValue: minMax[1] + startValue: isFinite(precision) ? round(minMax[0], precision) : minMax[0], + endValue: isFinite(precision) ? round(minMax[1], precision) : minMax[1] }); } function findDataZoom( - dimName: DataZoomAxisDimension, axisModel: CartesianAxisModel, ecModel: GlobalModel + dimName: 'x' | 'y', axisModel: CartesianAxisModel, ecModel: GlobalModel ): DataZoomModel { let found; ecModel.eachComponent({mainType: 'dataZoom', subType: 'select'}, function (dzModel: DataZoomModel) { diff --git a/src/component/toolbox/featureManager.ts b/src/component/toolbox/featureManager.ts index 5b16acf618..a1f554355f 100644 --- a/src/component/toolbox/featureManager.ts +++ b/src/component/toolbox/featureManager.ts @@ -73,9 +73,8 @@ interface ToolboxFeature { @@ -84,11 +83,6 @@ abstract class ToolboxFeature; ecModel: GlobalModel; api: ExtensionAPI; - - /** - * If toolbox feature can't be used on some platform. - */ - unusable?: boolean; } export {ToolboxFeature}; diff --git a/src/component/tooltip/TooltipModel.ts b/src/component/tooltip/TooltipModel.ts index f48d64144f..a7123274cc 100644 --- a/src/component/tooltip/TooltipModel.ts +++ b/src/component/tooltip/TooltipModel.ts @@ -106,7 +106,7 @@ class TooltipModel extends ComponentModel { trigger: 'item', // 'click' | 'mousemove' | 'none' - triggerOn: 'mousemove|click', + triggerOn: 'mousemove|click|mousewheel', alwaysShowContent: false, diff --git a/src/component/tooltip/TooltipView.ts b/src/component/tooltip/TooltipView.ts index 11d965f8ec..368fac7135 100644 --- a/src/component/tooltip/TooltipView.ts +++ b/src/component/tooltip/TooltipView.ts @@ -808,7 +808,7 @@ class TooltipView extends ComponentView { } private _showTooltipContent( - // Use Model insteadof TooltipModel because this model may be from series or other options. + // Use Model instead of TooltipModel because this model may be from series or other options. // Instead of top level tooltip. tooltipModel: Model, defaultHtml: string, diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index 6226e96b26..038aacbf22 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -18,7 +18,7 @@ */ import {each, map} from 'zrender/src/core/util'; -import {linearMap, getPixelPrecision, round} from '../util/number'; +import {linearMap, round} from '../util/number'; import { createAxisTicks, createAxisLabels, @@ -28,11 +28,13 @@ import { createAxisLabelsComputingContext, } from './axisTickLabelBuilder'; import Scale, { ScaleGetTicksOpt } from '../scale/Scale'; -import { DimensionName, ScaleDataValue, ScaleTick } from '../util/types'; +import { DimensionName, NullUndefined, ScaleDataValue, ScaleTick } from '../util/types'; import OrdinalScale from '../scale/Ordinal'; import Model from '../model/Model'; import { AxisBaseOption, CategoryAxisBaseOption, OptionAxisType } from './axisCommonTypes'; import { AxisBaseModel } from './AxisBaseModel'; +import { isOrdinalScale } from '../scale/helper'; +import { calcBandWidth } from './axisBand'; const NORMALIZED_EXTENT = [0, 1] as [number, number]; @@ -48,7 +50,7 @@ export interface AxisTickCoord { * Base class of Axis. * * Lifetime: recreate for each main process. - * [NOTICE]: Some caches is stored on the axis instance (see `axisTickLabelBuilder.ts`) + * [NOTICE]: Some caches is stored on the axis instance (e.g., `axisTickLabelBuilder.ts`, `scaleRawExtentInfo.ts`), * which is based on this lifetime. */ class Axis { @@ -63,6 +65,9 @@ class Axis { type: OptionAxisType; // Axis dimension. Such as 'x', 'y', 'z', 'angle', 'radius'. + // The name must be globally unique across different coordinate systems. + // But they may be not enumerable, e.g., in Radar and Parallel, axis + // number is not static. readonly dim: DimensionName; // Axis scale @@ -75,11 +80,15 @@ class Axis { // Injected outside model: AxisBaseModel; + // NOTICE: Must ensure `true` is only available on 'category' axis. onBand: CategoryAxisBaseOption['boundaryGap'] = false; // Make sure that `extent[0] > extent[1]` only if `inverse: true`. // `inverse` can be inferred by `extent` unless `extent[0] === extent[1]`. inverse: AxisBaseOption['inverse'] = false; + // To be injected outside. May change - do not use it outside of echarts. + __alignTo: Axis | NullUndefined; + constructor(dim: DimensionName, scale: Scale, extent: [number, number]) { this.dim = dim; @@ -111,16 +120,6 @@ class Axis { return this._extent.slice() as [number, number]; } - /** - * Get precision used for formatting - */ - getPixelPrecision(dataExtent?: [number, number]): number { - return getPixelPrecision( - dataExtent || this.scale.getExtent(), - this._extent - ); - } - /** * Set coord extent */ @@ -134,32 +133,16 @@ class Axis { * Convert data to coord. Data is the rank if it has an ordinal scale */ dataToCoord(data: ScaleDataValue, clamp?: boolean): number { - let extent = this._extent; const scale = this.scale; data = scale.normalize(scale.parse(data)); - - if (this.onBand && scale.type === 'ordinal') { - extent = extent.slice() as [number, number]; - fixExtentWithBands(extent, (scale as OrdinalScale).count()); - } - - return linearMap(data, NORMALIZED_EXTENT, extent, clamp); + return linearMap(data, NORMALIZED_EXTENT, makeExtentWithBands(this), clamp); } /** * Convert coord to data. Data is the rank if it has an ordinal scale */ coordToData(coord: number, clamp?: boolean): number { - let extent = this._extent; - const scale = this.scale; - - if (this.onBand && scale.type === 'ordinal') { - extent = extent.slice() as [number, number]; - fixExtentWithBands(extent, (scale as OrdinalScale).count()); - } - - const t = linearMap(coord, extent, NORMALIZED_EXTENT, clamp); - + const t = linearMap(coord, makeExtentWithBands(this), NORMALIZED_EXTENT, clamp); return this.scale.scale(t); } @@ -198,7 +181,7 @@ class Axis { const ticksCoords = map(ticks, function (tickVal) { return { coord: this.dataToCoord( - this.scale.type === 'ordinal' + isOrdinalScale(this.scale) ? (this.scale as OrdinalScale).getRawOrdinalNumber(tickVal) : tickVal ), @@ -216,7 +199,7 @@ class Axis { } getMinorTicksCoords(): AxisTickCoord[][] { - if (this.scale.type === 'ordinal') { + if (isOrdinalScale(this.scale)) { // Category axis doesn't support minor ticks return []; } @@ -262,19 +245,11 @@ class Axis { } /** - * Get width of band + * @deprecated Use `calcBandWidth` instead. */ getBandWidth(): number { - const axisExtent = this._extent; - const dataExtent = this.scale.getExtent(); - - let len = dataExtent[1] - dataExtent[0] + (this.onBand ? 1 : 0); - // Fix #2728, avoid NaN when only one data. - len === 0 && (len = 1); - - const size = Math.abs(axisExtent[1] - axisExtent[0]); - - return Math.abs(size) / len; + return calcBandWidth(this, {min: 1}).w; + // NOTICE: Do not add logic here. Implement everthing in `calcBandWidth`. } /** @@ -285,7 +260,7 @@ class Axis { /** * Only be called in category axis. * Can be overridden, consider other axes like in 3D. - * @return Auto interval for cateogry axis tick and label + * @return Auto interval for category axis tick and label */ calculateCategoryInterval(ctx?: AxisLabelsComputingContext): number { ctx = ctx || createAxisLabelsComputingContext(AxisTickLabelComputingKind.determine); @@ -294,12 +269,15 @@ class Axis { } -function fixExtentWithBands(extent: [number, number], nTick: number): void { - const size = extent[1] - extent[0]; - const len = nTick; - const margin = size / len / 2; - extent[0] += margin; - extent[1] -= margin; +function makeExtentWithBands(axis: Axis): number[] { + const extent = axis.getExtent(); + if (axis.onBand) { + const size = extent[1] - extent[0]; + const margin = size / (axis.scale as OrdinalScale).count() / 2; + extent[0] += margin; + extent[1] -= margin; + } + return extent; } // If axis has labels [1, 2, 3, 4]. Bands on the axis are @@ -367,8 +345,8 @@ function fixOnBandTicksCoords( function littleThan(a: number, b: number): boolean { // Avoid rounding error cause calculated tick coord different with extent. // It may cause an extra unnecessary tick added. - a = round(a); - b = round(b); + a = round(a, 10); + b = round(b, 10); return inverse ? a > b : a < b; } } diff --git a/src/coord/AxisBaseModel.ts b/src/coord/AxisBaseModel.ts index 9b482f0cb0..1909edb25f 100644 --- a/src/coord/AxisBaseModel.ts +++ b/src/coord/AxisBaseModel.ts @@ -31,5 +31,5 @@ export interface AxisBaseModel, AxisModelExtendedInCreator { - axis: Axis + axis: Axis; } \ No newline at end of file diff --git a/src/coord/CoordinateSystem.ts b/src/coord/CoordinateSystem.ts index cc0e8158e7..9a444e60c3 100644 --- a/src/coord/CoordinateSystem.ts +++ b/src/coord/CoordinateSystem.ts @@ -189,6 +189,9 @@ export interface CoordinateSystem { getAxis?: (dim?: DimensionName) => Axis; + /** + * FIXME: Remove this method? See details in `Cartesian2D['getBaseAxis']` + */ getBaseAxis?: () => Axis; getOtherAxis?: (baseAxis: Axis) => Axis; diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index 5810d01f78..61f11ed20f 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -17,124 +17,342 @@ * under the License. */ -import { NumericAxisBaseOptionCommon } from './axisCommonTypes'; -import { getPrecisionSafe, round } from '../util/number'; +import { + getAcceptableTickPrecision, + isNullableNumberFinite, + mathAbs, mathCeil, mathFloor, mathMax, mathRound, nice, NICE_MODE_MIN, quantity, round +} from '../util/number'; import IntervalScale from '../scale/Interval'; -import { getScaleExtent, retrieveAxisBreaksOption } from './axisHelper'; -import { AxisBaseModel } from './AxisBaseModel'; import LogScale from '../scale/Log'; +import type Scale from '../scale/Scale'; +import { updateIntervalOrLogScaleForNiceOrAligned } from './axisHelper'; import { warn } from '../util/log'; -import { logTransform, increaseInterval, isValueNice } from '../scale/helper'; +import { + increaseInterval, isLogScale, getIntervalPrecision, intervalScaleEnsureValidExtent, +} from '../scale/helper'; +import { assert } from 'zrender/src/core/util'; +import { adoptScaleRawExtentInfoAndPrepare } from './scaleRawExtentInfo'; +import { hasBreaks } from '../scale/break'; +import type Axis from './Axis'; -export function alignScaleTicks( - scale: IntervalScale | LogScale, - axisModel: AxisBaseModel>, +/** + * NOTE: See the summary of the process of extent determination in the comment of `scaleMapper.setExtent`. + */ +export function scaleCalcAlign( + targetAxis: Axis, alignToScale: IntervalScale | LogScale -) { - - const intervalScaleProto = IntervalScale.prototype; - - // NOTE: There is a precondition for log scale here: - // In log scale we store _interval and _extent of exponent value. - // So if we use the method of InternalScale to set/get these data. - // It process the exponent value, which is linear and what we want here. - const alignToTicks = intervalScaleProto.getTicks.call(alignToScale); - const alignToNicedTicks = intervalScaleProto.getTicks.call(alignToScale, {expandToNicedExtent: true}); - const alignToSplitNumber = alignToTicks.length - 1; - const alignToInterval = intervalScaleProto.getInterval.call(alignToScale); - - const scaleExtent = getScaleExtent(scale, axisModel); - let rawExtent = scaleExtent.extent; - const isMinFixed = scaleExtent.fixMin; - const isMaxFixed = scaleExtent.fixMax; - - if (scale.type === 'log') { - rawExtent = logTransform((scale as LogScale).base, rawExtent, true); - } - scale.setBreaksFromOption(retrieveAxisBreaksOption(axisModel)); - scale.setExtent(rawExtent[0], rawExtent[1]); - scale.calcNiceExtent({ - splitNumber: alignToSplitNumber, - fixMin: isMinFixed, - fixMax: isMaxFixed - }); - const extent = intervalScaleProto.getExtent.call(scale); - - // Need to update the rawExtent. - // Because value in rawExtent may be not parsed. e.g. 'dataMin', 'dataMax' - if (isMinFixed) { - rawExtent[0] = extent[0]; +): void { + + const targetScale = targetAxis.scale as (IntervalScale | LogScale) & Scale; + const targetAxisModel = targetAxis.model; + if (__DEV__) { + assert(targetScale && targetAxisModel + && (targetScale instanceof IntervalScale || targetScale instanceof LogScale) + && (alignToScale instanceof IntervalScale || alignToScale instanceof LogScale) + ); } - if (isMaxFixed) { - rawExtent[1] = extent[1]; + const targetExtentInfo = adoptScaleRawExtentInfoAndPrepare( + targetScale, targetAxisModel, targetAxisModel.ecModel, targetAxis, null + ); + + // FIXME: + // (1) Axis inverse is not considered yet. + // (2) `SCALE_EXTENT_KIND_MAPPING` is not considered yet. + + const isTargetLogScale = isLogScale(targetScale); + const alignToScaleLinear = isLogScale(alignToScale) ? alignToScale.intervalStub : alignToScale; + const targetIntervalStub = isTargetLogScale ? targetScale.intervalStub : targetScale; + + const targetLogScaleBase = (targetScale as LogScale).base; + const alignToTicks = alignToScaleLinear.getTicks(); + const alignToExpNiceTicks = alignToScaleLinear.getTicks({expandToNicedExtent: true}); + const alignToSegCount = alignToTicks.length - 1; + + if (__DEV__) { + // This is guards for future changes of `Interval#getTicks`. + assert(!hasBreaks(alignToScale) && !hasBreaks(targetScale)); + assert(alignToSegCount > 0); // Ticks length >= 2 even on a blank scale. + assert(alignToExpNiceTicks.length === alignToTicks.length); + assert(alignToTicks[0].value <= alignToTicks[alignToSegCount].value); + assert( + alignToExpNiceTicks[0].value <= alignToTicks[0].value + && alignToTicks[alignToSegCount].value <= alignToExpNiceTicks[alignToSegCount].value + ); + if (alignToSegCount >= 2) { + assert(alignToExpNiceTicks[1].value === alignToTicks[1].value); + assert(alignToExpNiceTicks[alignToSegCount - 1].value === alignToTicks[alignToSegCount - 1].value); + } } - let interval = intervalScaleProto.getInterval.call(scale); - let min: number = rawExtent[0]; - let max: number = rawExtent[1]; + // The Current strategy: Find a proper interval and an extent for the target scale to derive ticks + // matching exactly to ticks of `alignTo` scale. - if (isMinFixed && isMaxFixed) { - // User set min, max, divide to get new interval - interval = (max - min) / alignToSplitNumber; + // Adjust min, max based on the extent of alignTo. When min or max is set in alignTo scale + let t0: number; // diff ratio on min not-nice segment. 0 <= t0 < 1 + let t1: number; // diff ratio on max not-nice segment. 0 <= t1 < 1 + let alignToNiceSegCount: number; // >= 1 + // Consider ticks of `alignTo`, only these cases below may occur: + if (alignToSegCount === 1) { + // `alignToTicks` is like: + // |--| + // In this case, we make the corresponding 2 target ticks "nice". + t0 = t1 = 0; + alignToNiceSegCount = 1; } - else if (isMinFixed) { - max = rawExtent[0] + interval * alignToSplitNumber; - // User set min, expand extent on the other side - while (max < rawExtent[1] && isFinite(max) && isFinite(rawExtent[1])) { - interval = increaseInterval(interval); - max = rawExtent[0] + interval * alignToSplitNumber; + else if (alignToSegCount === 2) { + // `alignToTicks` is like: + // |-|-----| or + // |-----|-| or + // |-----|-----| + // Notices that nice ticks do not necessarily exist in this case. + // In this case, we choose the larger segment as the "nice segment" and + // the corresponding target ticks are made "nice". + const interval0 = mathAbs(alignToTicks[0].value - alignToTicks[1].value); + const interval1 = mathAbs(alignToTicks[1].value - alignToTicks[2].value); + t0 = t1 = 0; + if (interval0 === interval1) { + alignToNiceSegCount = 2; } - } - else if (isMaxFixed) { - // User set max, expand extent on the other side - min = rawExtent[1] - interval * alignToSplitNumber; - while (min > rawExtent[0] && isFinite(min) && isFinite(rawExtent[0])) { - interval = increaseInterval(interval); - min = rawExtent[1] - interval * alignToSplitNumber; + else { + alignToNiceSegCount = 1; + if (interval0 < interval1) { + t0 = interval0 / interval1; + } + else { + t1 = interval1 / interval0; + } } } - else { - const nicedSplitNumber = scale.getTicks().length - 1; - if (nicedSplitNumber > alignToSplitNumber) { - interval = increaseInterval(interval); - } + else { // alignToSegCount >= 3 + // `alignToTicks` is like: + // |-|-----|-----|-| or + // |-----|-----|-| or + // |-|-----|-----| or ... + // At least one nice segment is present, and not-nice segments are only present on + // the start and/or the end. + // In this case, ticks corresponding to nice segments are made "nice". + const alignToInterval = alignToScaleLinear.getConfig().interval; + t0 = ( + 1 - (alignToTicks[0].value - alignToExpNiceTicks[0].value) / alignToInterval + ) % 1; + t1 = ( + 1 - (alignToExpNiceTicks[alignToSegCount].value - alignToTicks[alignToSegCount].value) / alignToInterval + ) % 1; + alignToNiceSegCount = alignToSegCount - (t0 ? 1 : 0) - (t1 ? 1 : 0); + } + + if (__DEV__) { + assert(alignToNiceSegCount >= 1); + } + + // NOTE: + // Consider a case: + // dataZoom controls all Y axes; + // dataZoom end is 90% (maxFixed: true, dataZoomFixMinMax[0]: true); + // but dataZoom start is 0% (minFixed: false, dataZoomFixMinMax[1]: false); + // In this case, + // - `Interval#calcNiceTicks` only uses `targetExtentInfo.max` as the upper bound, but expand the + // lower bound to a "nice" tick and can get an acceptable result. + // - `scaleCalcAlign` has to use both `targetExtentInfo.min/max` as the bounds without any expansion, + // otherwise the lower bound may become negative unexpectedly, especially for all positive series data. + const dataZoomFixMinMax = targetExtentInfo.zoomFixMM; + const hasDataZoomFixMinMax = dataZoomFixMinMax[0] || dataZoomFixMinMax[1]; + const targetMinMaxFixed = [ + targetExtentInfo.fixMM[0] || hasDataZoomFixMinMax, + targetExtentInfo.fixMM[1] || hasDataZoomFixMinMax + ]; + // MEMO: When only `xxxAxis.min` or `xxxAxis.max` is fixed, + // - Even a "nice" interval can be calculated, ticks accumulated based on `min`/`max` can be "nice" only if + // `min` or `max` is a "nice" number. + // - Generating a "nice" interval may cause the extent have both positive and negative ticks, which may be + // not preferable for all positive (very common) or all negative series data. But it can be simply resolved + // by specifying `xxxAxis.min: 0`/`xxxAxis.max: 0`, so we do not specially handle this case here. + // Therefore, we prioritize generating "nice" interval over preventing from crossing zero. + // e.g., if series data are all positive and the max data is `11739`, + // If setting `yAxis.max: 'dataMax'`, ticks may be like: + // `11739, 8739, 5739, 2739, -1739` (not "nice" enough) + // If setting `yAxis.max: 'dataMax', yAxis.min: 0`, ticks may be like: + // `11739, 8805, 5870, 2935, 0` (not "nice" enough but may be acceptable) + // If setting `yAxis.max: 12000, yAxis.min: 0`, ticks may be like: + // `12000, 9000, 6000, 3000, 0` ("nice") - const range = interval * alignToSplitNumber; - max = Math.ceil(rawExtent[1] / interval) * interval; - min = round(max - range); - // Not change the result that crossing zero. - if (min < 0 && rawExtent[0] >= 0) { - min = 0; - max = round(range); + const targetOldOutermostExtent = (targetScale as Scale).getExtent(); + const targetOldIntervalExtent = targetIntervalStub.getExtent(); + const targetExtent = intervalScaleEnsureValidExtent(targetOldIntervalExtent, targetMinMaxFixed); + + let min: number; + let max: number; + let interval: number; + let intervalPrecision: number; + let maxNice: number; + let minNice: number; + + function loopIncreaseInterval(cb: () => boolean) { + // Typically this loop runs less than 5 times. But we still + // use a safeguard for future changes. + const LOOP_MAX = 50; + let loopGuard = 0; + for (; loopGuard < LOOP_MAX; loopGuard++) { + if (cb()) { + break; + } + interval = isTargetLogScale + // TODO: `mathMax(base, 2)` is a guardcode to avoid infinite loop, + // but probably it should be guranteed by `LogScale` itself. + ? interval * mathMax(targetLogScaleBase, 2) + : increaseInterval(interval); + intervalPrecision = getIntervalPrecision(interval); } - else if (max > 0 && rawExtent[1] <= 0) { - max = 0; - min = -round(range); + if (__DEV__) { + if (loopGuard >= LOOP_MAX) { + warn('incorrect impl in `scaleCalcAlign`.'); + } } + } + function updateMinFromMinNice() { + min = round(minNice - interval * t0, intervalPrecision); + } + function updateMaxFromMaxNice() { + max = round(maxNice + interval * t1, intervalPrecision); + } + function updateMinNiceFromMinT0Interval() { + minNice = t0 ? round(min + interval * t0, intervalPrecision) : min; + } + function updateMaxNiceFromMaxT1Interval() { + maxNice = t1 ? round(max - interval * t1, intervalPrecision) : max; } - // Adjust min, max based on the extent of alignTo. When min or max is set in alignTo scale - const t0 = (alignToTicks[0].value - alignToNicedTicks[0].value) / alignToInterval; - const t1 = (alignToTicks[alignToSplitNumber].value - alignToNicedTicks[alignToSplitNumber].value) / alignToInterval; - - // NOTE: Must in setExtent -> setInterval -> setNiceExtent order. - intervalScaleProto.setExtent.call(scale, min + interval * t0, max + interval * t1); - intervalScaleProto.setInterval.call(scale, interval); - if (t0 || t1) { - intervalScaleProto.setNiceExtent.call(scale, min + interval, max - interval); + // NOTE: The new calculated `min`/`max` must NOT shrink the original extent; otherwise some series + // data may be outside of the extent. They can expand the original extent slightly to align with + // ticks of `alignTo`. In this case, more blank space is added but visually fine. + + if (targetMinMaxFixed[0] && targetMinMaxFixed[1]) { + // Both `min` and `max` are specified (via dataZoom or ec option; consider both Cartesian, radar and + // other possible axes). In this case, "nice" ticks can hardly be calculated, but reasonable ticks should + // still be calculated whenever possible, especially `intervalPrecision` should be tuned for better + // appearance and lower cumulative error. + + min = targetExtent[0]; + max = targetExtent[1]; + interval = (max - min) / (alignToNiceSegCount + t0 + t1); + // Typically axis pixel extent is ready here. See `create` in `Grid.ts`. + const axisPxExtent = targetAxis.getExtent(); + // NOTICE: this pxSpan may be not accurate yet due to "outerBounds" logic, but acceptable so far. + const pxSpan = mathAbs(axisPxExtent[1] - axisPxExtent[0]); + // We imperically choose `pxDiffAcceptable` as `0.5 / alignToNiceSegCount` for reduce cumulative + // error, otherwise a discernible misalign (> 1px) may occur. + // PENDING: We do not find a acceptable precision for LogScale here. + // Theoretically it can be addressed but introduce more complexity. Is it necessary? + intervalPrecision = getAcceptableTickPrecision([max, min], pxSpan, 0.5 / alignToNiceSegCount); + updateMinNiceFromMinT0Interval(); + updateMaxNiceFromMaxT1Interval(); + if (isNullableNumberFinite(intervalPrecision)) { + interval = round(interval, intervalPrecision); + } } + else { + // Make a minimal enough `interval`, increase it later. + // It is a similar logic as `IntervalScale#calcNiceTicks` and `LogScale#calcNiceTicks`. + // Axis break is not supported, which is guranteed by the caller of this function. + const targetSpan = targetExtent[1] - targetExtent[0]; + interval = isTargetLogScale + ? mathMax(quantity(targetSpan), 1) + : nice(targetSpan / alignToNiceSegCount, NICE_MODE_MIN); + intervalPrecision = getIntervalPrecision(interval); - if (__DEV__) { - const ticks = intervalScaleProto.getTicks.call(scale); - if (ticks[1] - && (!isValueNice(interval) || getPrecisionSafe(ticks[1].value) > getPrecisionSafe(interval))) { - warn( - `The ticks may be not readable when set min: ${axisModel.get('min')}, max: ${axisModel.get('max')}` - + ` and alignTicks: true. (${axisModel.axis?.dim}AxisIndex: ${axisModel.componentIndex})`, - true - ); + if (targetMinMaxFixed[0]) { + min = targetExtent[0]; + loopIncreaseInterval(function () { + updateMinNiceFromMinT0Interval(); + maxNice = round(minNice + interval * alignToNiceSegCount, intervalPrecision); + updateMaxFromMaxNice(); + if (max >= targetExtent[1]) { + return true; + } + }); + } + else if (targetMinMaxFixed[1]) { + max = targetExtent[1]; + loopIncreaseInterval(function () { + updateMaxNiceFromMaxT1Interval(); + minNice = round(maxNice - interval * alignToNiceSegCount, intervalPrecision); + updateMinFromMinNice(); + if (min <= targetExtent[0]) { + return true; + } + }); + } + else { + loopIncreaseInterval(function () { + minNice = round(mathCeil(targetExtent[0] / interval) * interval, intervalPrecision); + maxNice = round(mathFloor(targetExtent[1] / interval) * interval, intervalPrecision); + // NOTE: + // - `maxNice - minNice >= -interval` here. + // - While `interval` increases, `currIntervalCount` decreases, minimum `-1`. + const currIntervalCount = mathRound((maxNice - minNice) / interval); + if (currIntervalCount <= alignToNiceSegCount) { + const moreCount = alignToNiceSegCount - currIntervalCount; + // Consider cases that negative tick do not make sense (or vice versa), users can simply + // specify `xxxAxis.min/max: 0` to avoid negative. But we still automatically handle it + // for some common cases whenever possible: + // - When ec option is `xxxAxis.scale: false` (the default), it is usually unexpected if + // negative (or positive) ticks are introduced. + // - In LogScale, series data are usually either all > 1 or all < 1, rather than both, + // that is, logarithm result is typically either all positive or all negative. + let moreCountPair: number[]; + const mayEnhanceZero = targetExtentInfo.needCrossZero || isTargetLogScale; + // `bounds < 0` or `bounds > 0` may require more complex handling, so we only auto handle + // `bounds === 0`. + if (mayEnhanceZero && targetExtent[0] === 0) { + // 0 has been included in extent and all positive. + moreCountPair = [0, moreCount]; + } + else if (mayEnhanceZero && targetExtent[1] === 0) { + // 0 has been included in extent and all negative. + moreCountPair = [moreCount, 0]; + } + else { + // Try to center ticks in axis space whenever possible, which is especially preferable + // in `LogScale`. + const lessHalfCount = mathFloor(moreCount / 2); + moreCountPair = moreCount % 2 === 0 ? [lessHalfCount, lessHalfCount] + : (min + max) < (targetExtent[0] + targetExtent[1]) ? [lessHalfCount, lessHalfCount + 1] + : [lessHalfCount + 1, lessHalfCount]; + } + minNice = round(minNice - interval * moreCountPair[0], intervalPrecision); + maxNice = round(maxNice + interval * moreCountPair[1], intervalPrecision); + updateMinFromMinNice(); + updateMaxFromMaxNice(); + if (min <= targetExtent[0] && max >= targetExtent[1]) { + return true; + } + } + }); } } + + updateIntervalOrLogScaleForNiceOrAligned( + targetScale, + targetMinMaxFixed, + targetOldIntervalExtent, + [min, max], + targetOldOutermostExtent, + { + // NOTE: Even in LogScale, `interval` should not be in log space. + interval, + // Force ticks count, otherwise cumulative error may cause more unexpected ticks to be generated. + // Though the overlapping tick labels may be auto-ignored, but probably unexpected, e.g., the min + // tick label is ignored but the secondary min tick label is shown, which is unexpected when + // `axis.min` is user-specified or dataZoom-specified. + intervalCount: alignToNiceSegCount, + intervalPrecision, + niceExtent: [minNice, maxNice], + }, + ); + + if (__DEV__) { + (targetScale as Scale).freeze(); + } } diff --git a/src/coord/axisBand.ts b/src/coord/axisBand.ts new file mode 100644 index 0000000000..d7bc54007a --- /dev/null +++ b/src/coord/axisBand.ts @@ -0,0 +1,200 @@ +/* +* 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 { assert, each } from 'zrender/src/core/util'; +import { NullUndefined } from '../util/types'; +import type Axis from './Axis'; +import type Scale from '../scale/Scale'; +import { isOrdinalScale } from '../scale/helper'; +import { isNullableNumberFinite, mathAbs, mathMax } from '../util/number'; +import { + AxisStatKey, getAxisStat, getAxisStatBySeries, +} from './axisStatistics'; +import { getScaleLinearSpanForMapping } from '../scale/scaleMapper'; +import type SeriesModel from '../model/Series'; + + +// Arbitrary, leave some space to avoid overflowing when dataZoom moving. +const FALLBACK_BAND_WIDTH_RATIO = 0.8; + +export type AxisBandWidthResult = { + // The result `bandWidth`. In pixel. + // Never be null/undefined. + // May be NaN if no meaningfull `bandWidth`. But it's unlikely to be NaN, since edge cases + // are handled internally whenever possible. + w: number; + // Exists if `bandWidth` is calculated by `fromStat`. + fromStat?: { + // This is a ratio from pixel span to data span; The conversion can not be performed + // if it is not valid, typically when only one or no series data item exists on the axis. + invRatio?: number | NullUndefined; + }; +}; + +/** + * PENDING: Should the `bandWidth` strategy be chosen by users, or auto-determined basesd on + * performance? + */ +type CalculateBandWidthOpt = { + // Only used on non-'category' axes. Calculate `bandWidth` based on statistics. + // Require `requireAxisStatistics` to be called. + fromStat?: { + // Either `axisStatKey` or `series` is required. + // If multiple axis statistics can be queried by `series`, currently we only support to return a + // maximum `bandWidth`, which is suitable for cases like "axis pointer shadow". + sers?: (SeriesModel | NullUndefined)[] | NullUndefined; + key?: AxisStatKey; + }; + // It also act as a fallback for NaN/null/undefined result. + min?: number; +}; + +/** + * NOTICE: + * - Require the axis pixel extent and the scale extent as inputs. But they + * can be not precise for approximation. + * - Can only be called after "data processing" stage. + * + * PENDING: + * Currently `bandWidth` can not be specified by users explicitly. But if we + * allow that in future, these issues must be considered: + * - Can only allow specifying a band width in data scale rather than pixel. + * - LogScale needs to be considered - band width can only be specified on linear + * (but before break) scale, similar to `axis.interval`. + * + * A band is required on: + * - series group band width in bar/boxplot/candlestick/...; + * - tooltip axisPointer type "shadow"; + * - etc. + */ +export function calcBandWidth( + axis: Axis, + opt?: CalculateBandWidthOpt | NullUndefined +): AxisBandWidthResult { + opt = opt || {}; + const out: AxisBandWidthResult = {w: NaN}; + const scale = axis.scale; + const fromStat = opt.fromStat; + const min = opt.min; + + if (isOrdinalScale(scale)) { + calcBandWidthForCategoryAxis(out, axis, scale); + } + else if (fromStat) { + calcBandWidthForNumericAxis(out, axis, scale, fromStat); + } + else if (min == null) { + if (__DEV__) { + assert(false); + } + } + + if (min != null) { + out.w = isNullableNumberFinite(out.w) + ? mathMax(min, out.w) : min; + } + + return out; +} + +/** + * Only reasonable on 'category'. + * + * It can be used as a fallback, as it does not produce a significant negative impact + * on non-category axes. + * + * @see CalculateBandWidthOpt + */ +function calcBandWidthForCategoryAxis( + out: AxisBandWidthResult, + axis: Axis, + scale: Scale +): void { + const axisExtent = axis.getExtent(); + const dataExtent = scale.getExtent(); + + let len = dataExtent[1] - dataExtent[0] + (axis.onBand ? 1 : 0); + // Fix #2728, avoid NaN when only one data. + len === 0 && (len = 1); + + out.w = mathAbs(axisExtent[1] - axisExtent[0]) / len; +} + +/** + * @see CalculateBandWidthOpt + */ +function calcBandWidthForNumericAxis( + out: AxisBandWidthResult, + axis: Axis, + scale: Scale, + fromStat: CalculateBandWidthOpt['fromStat'], +): void { + + let bandWidth = NaN; + let invRatio: number | NullUndefined; + + if (__DEV__) { + assert(fromStat); + } + + let allSingularOrNone: boolean | NullUndefined; + let bandWidthInData = -Infinity; + each( + fromStat.key + ? [getAxisStat(axis, fromStat.key)] + : getAxisStatBySeries(axis, fromStat.sers || []), + function (stat) { + const liPosMinGap = stat.liPosMinGap; + // `liPosMinGap == null` may indicate that `requireAxisStatistics` + // is not used by the relevant series. We conservatively do not + // consider it as a "singular" case. + if (liPosMinGap != null && allSingularOrNone == null) { + allSingularOrNone = true; + } + if (isNullableNumberFinite(liPosMinGap)) { + if (liPosMinGap > bandWidthInData) { + bandWidthInData = liPosMinGap; + } + allSingularOrNone = false; + } + } + ); + + const axisExtent = axis.getExtent(); + // Always use a new pxSpan because it may be changed in `grid` contain label calculation. + const pxSpan = mathAbs(axisExtent[1] - axisExtent[0]); + const linearScaleSpan = getScaleLinearSpanForMapping(scale); + // `linearScaleSpan` may be `0` or `Infinity` or `NaN`, since normalizers like + // `intervalScaleEnsureValidExtent` may not have been called yet. + if (isNullableNumberFinite(linearScaleSpan) && linearScaleSpan > 0 + && isNullableNumberFinite(bandWidthInData) + ) { + // NOTE: even when the `bandWidth` is far smaller than `1`, we should still preserve the + // precision, because it is required to convert back to data space by `invRatio` for + // displaying of zoomed ticks and band. + bandWidth = pxSpan / linearScaleSpan * bandWidthInData; + invRatio = linearScaleSpan / pxSpan; + } + else if (allSingularOrNone) { + bandWidth = pxSpan * FALLBACK_BAND_WIDTH_RATIO; + } + + out.w = bandWidth; + out.fromStat = {invRatio}; +} diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index 4291a0729c..7c040e9384 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -34,6 +34,9 @@ import type { PrimaryTimeUnit } from '../util/time'; export const AXIS_TYPES = {value: 1, category: 1, time: 1, log: 1} as const; export type OptionAxisType = keyof typeof AXIS_TYPES; +// `scale/Ordinal` | `scale/Interval` | `scale/Log` | `scale/Time` +export type AxisScaleType = 'ordinal' | 'interval' | 'log' | 'time'; + export interface AxisBaseOptionCommon extends ComponentOption, AnimationOptionMixin { type?: OptionAxisType; @@ -131,13 +134,21 @@ export interface AxisBaseOptionCommon extends ComponentOption, } } +/** + * The gap at both ends of the axis. `[GAP, GAP]`. + */ +type NumericAxisBoundaryGapOption = [NumericAxisBoundaryGapOptionItemValue, NumericAxisBoundaryGapOptionItemValue]; +// It can be an absolute pixel number (like `35`), or percent (like `'30%'`) +export type NumericAxisBoundaryGapOptionItemValue = number | string | NullUndefined; + export interface NumericAxisBaseOptionCommon extends AxisBaseOptionCommon { - /* - * The gap at both ends of the axis. - * [GAP, GAP], where - * `GAP` can be an absolute pixel number (like `35`), or percent (like `'30%'`) - */ - boundaryGap?: [number | string, number | string] + + boundaryGap?: NumericAxisBoundaryGapOption; + + // The axis contains the series shapes if possible, instead of overlowing at the edges. + // Key is series type, like 'bar', 'pictorialBar'. + // null/undefined means `true`. + containShape?: boolean; /** * AxisTick and axisLabel and splitLine are calculated based on splitNumber. @@ -167,14 +178,12 @@ export interface NumericAxisBaseOptionCommon extends AxisBaseOptionCommon { /** * Data min value to be included in axis extent calculation. * The final min value will be the minimum of this value and the data min. - * Only works for value axis. */ dataMin?: ScaleDataValue; /** * Data max value to be included in axis extent calculation. * The final max value will be the maximum of this value and the data max. - * Only works for value axis. */ dataMax?: ScaleDataValue; } @@ -210,10 +219,10 @@ export interface ValueAxisBaseOption extends NumericAxisBaseOptionCommon { /** * Optional value can be: - * + `false`: always include value 0. + * + `false`: always include value 0 if not conflict with `axis.min/max` setting. * + `true`: the axis may not contain zero position. */ - scale?: boolean; + scale?: boolean; } export interface LogAxisBaseOption extends NumericAxisBaseOptionCommon { type?: 'log'; @@ -230,7 +239,7 @@ interface AxisNameTextStyleOption extends LabelCommonOption { interface AxisLineOption { show?: boolean | 'auto', - onZero?: boolean, + onZero?: boolean | 'auto', onZeroAxisIndex?: number, // The arrow at both ends the the axis. symbol?: string | [string, string], @@ -248,7 +257,7 @@ interface AxisTickOption { // The length of axisTick. length?: number, lineStyle?: LineStyleOption, - customValues?: (number | string | Date)[] + customValues?: AxisTickLabelCustomValuesOption } export type AxisLabelValueFormatter = ( @@ -314,10 +323,8 @@ interface AxisLabelBaseOption extends LabelCommonOption extends AxisLabelBaseOption { formatter?: LabelFormatters[TType] interval?: TType extends 'category' diff --git a/src/coord/axisDefault.ts b/src/coord/axisDefault.ts index 7ca31a2bbc..ddb33ee33f 100644 --- a/src/coord/axisDefault.ts +++ b/src/coord/axisDefault.ts @@ -59,7 +59,7 @@ const defaultOption: AxisBaseOption = { axisLine: { show: true, - onZero: true, + onZero: 'auto', onZeroAxisIndex: null, lineStyle: { color: tokens.color.axisLine, @@ -212,9 +212,9 @@ const valueAxis: AxisBaseOption = zrUtil.merge({ const timeAxis: AxisBaseOption = zrUtil.merge({ splitNumber: 6, axisLabel: { - // To eliminate labels that are not nice - showMinLabel: false, - showMaxLabel: false, + // The default value of TimeScale is determined in `AxisBuilder` + // showMinLabel: false, + // showMaxLabel: false, rich: { primary: { fontWeight: 'bold' diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 24cb773ee3..3f8563c373 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -19,13 +19,8 @@ import * as zrUtil from 'zrender/src/core/util'; import OrdinalScale from '../scale/Ordinal'; -import IntervalScale from '../scale/Interval'; +import IntervalScale, { IntervalScaleConfig } from '../scale/Interval'; import Scale from '../scale/Scale'; -import { - prepareLayoutBarSeries, - makeColumnLayout, - retrieveColumnLayout -} from '../layout/barGrid'; import TimeScale from '../scale/Time'; import Model from '../model/Model'; @@ -41,191 +36,145 @@ import { AxisLabelCategoryFormatter, AxisLabelValueFormatter, AxisLabelFormatterExtraParams, + OptionAxisType, + AXIS_TYPES, } from './axisCommonTypes'; -import CartesianAxisModel from './cartesian/AxisModel'; import SeriesData from '../data/SeriesData'; import { getStackedDimension } from '../data/helper/dataStackHelper'; -import { Dictionary, DimensionName, ScaleTick } from '../util/types'; -import { ensureScaleRawExtentInfo } from './scaleRawExtentInfo'; +import { Dictionary, DimensionName, NullUndefined, ScaleTick } from '../util/types'; +import { ScaleExtentFixMinMax } from './scaleRawExtentInfo'; import { parseTimeAxisLabelFormatter } from '../util/time'; import { getScaleBreakHelper } from '../scale/break'; import { error } from '../util/log'; - - -type BarWidthAndOffset = ReturnType; - -/** - * Get axis scale extent before niced. - * Item of returned array can only be number (including Infinity and NaN). - * - * Caution: - * Precondition of calling this method: - * The scale extent has been initialized using series data extent via - * `scale.setExtent` or `scale.unionExtentFromData`; - */ -export function getScaleExtent(scale: Scale, model: AxisBaseModel) { - const scaleType = scale.type; - const rawExtentResult = ensureScaleRawExtentInfo(scale, model, scale.getExtent()).calculate(); - - scale.setBlank(rawExtentResult.isBlank); - - let min = rawExtentResult.min; - let max = rawExtentResult.max; - - // If bars are placed on a base axis of type time or interval account for axis boundary overflow and current axis - // is base axis - // FIXME - // (1) Consider support value axis, where below zero and axis `onZero` should be handled properly. - // (2) Refactor the logic with `barGrid`. Is it not need to `makeBarWidthAndOffsetInfo` twice with different extent? - // Should not depend on series type `bar`? - // (3) Fix that might overlap when using dataZoom. - // (4) Consider other chart types using `barGrid`? - // See #6728, #4862, `test/bar-overflow-time-plot.html` - const ecModel = model.ecModel; - if (ecModel && (scaleType === 'time' /* || scaleType === 'interval' */)) { - const barSeriesModels = prepareLayoutBarSeries('bar', ecModel); - let isBaseAxisAndHasBarSeries = false; - - zrUtil.each(barSeriesModels, function (seriesModel) { - isBaseAxisAndHasBarSeries = isBaseAxisAndHasBarSeries || seriesModel.getBaseAxis() === model.axis; - }); - - if (isBaseAxisAndHasBarSeries) { - // Calculate placement of bars on axis. TODO should be decoupled - // with barLayout - const barWidthAndOffset = makeColumnLayout(barSeriesModels); - - // Adjust axis min and max to account for overflow - const adjustedScale = adjustScaleForOverflow(min, max, model as CartesianAxisModel, barWidthAndOffset); - min = adjustedScale.min; - max = adjustedScale.max; - } - } - - return { - extent: [min, max], - // "fix" means "fixed", the value should not be - // changed in the subsequent steps. - fixMin: rawExtentResult.minFixed, - fixMax: rawExtentResult.maxFixed - }; -} - -function adjustScaleForOverflow( - min: number, - max: number, - model: CartesianAxisModel, // Only support cartesian coord yet. - barWidthAndOffset: BarWidthAndOffset -) { - - // Get Axis Length - const axisExtent = model.axis.getExtent(); - const axisLength = Math.abs(axisExtent[1] - axisExtent[0]); - - // Get bars on current base axis and calculate min and max overflow - const barsOnCurrentAxis = retrieveColumnLayout(barWidthAndOffset, model.axis); - if (barsOnCurrentAxis === undefined) { - return {min: min, max: max}; +import { + extentDiffers, isLogScale, isOrdinalScale +} from '../scale/helper'; +import { AxisModelExtendedInCreator } from './axisModelCreator'; +import { initExtentForUnion, isValidBoundsForExtent, makeInner } from '../util/model'; +import { + getScaleExtentForMappingUnsafe, SCALE_EXTENT_KIND_EFFECTIVE, SCALE_MAPPER_DEPTH_OUT_OF_BREAK +} from '../scale/scaleMapper'; +import ComponentModel from '../model/Component'; + + +const axisInner = makeInner<{ + noOnMyZero: DiscourageOnAxisZeroCondition; +}, Axis>(); + +type DiscourageOnAxisZeroCondition = { + dz?: boolean; + base?: boolean; +}; + + +export function determineAxisType( + model: Model> +): OptionAxisType { + let type = model.get('type') as OptionAxisType; + if (// In ec option, `xxxAxis.type` may be undefined. + type == null + // PENDING: Theoretically, a customized `Scale` is probably impossible, since + // the interface of `Scale` does not guarantee stability. But we still literally + // support it for backward compat, though type incorrect. + || (!zrUtil.hasOwn(AXIS_TYPES, type) && !Scale.getClass(type)) + ) { + type = 'value'; } - - let minOverflow = Infinity; - zrUtil.each(barsOnCurrentAxis, function (item) { - minOverflow = Math.min(item.offset, minOverflow); - }); - let maxOverflow = -Infinity; - zrUtil.each(barsOnCurrentAxis, function (item) { - maxOverflow = Math.max(item.offset + item.width, maxOverflow); - }); - minOverflow = Math.abs(minOverflow); - maxOverflow = Math.abs(maxOverflow); - const totalOverFlow = minOverflow + maxOverflow; - - // Calculate required buffer based on old range and overflow - const oldRange = max - min; - const oldRangePercentOfNew = (1 - (minOverflow + maxOverflow) / axisLength); - const overflowBuffer = ((oldRange / oldRangePercentOfNew) - oldRange); - - max += overflowBuffer * (maxOverflow / totalOverFlow); - min -= overflowBuffer * (minOverflow / totalOverFlow); - - return {min: min, max: max}; + return type; } -// Precondition of calling this method: -// The scale extent has been initialized using series data extent via -// `scale.setExtent` or `scale.unionExtentFromData`; -export function niceScaleExtent( - scale: Scale, - inModel: AxisBaseModel -) { - const model = inModel as AxisBaseModel; - const extentInfo = getScaleExtent(scale, model); - const extent = extentInfo.extent; - const splitNumber = model.get('splitNumber'); - - if (scale instanceof LogScale) { - scale.base = model.get('logBase'); +export function createScaleByModel( + model: + Model< + // Expect `Pick`, + // but be lenient for user's invalid input. + {type?: string} + & Pick + & Pick + > + & Partial>, + type: OptionAxisType, + coordSysSupportAxisBreaks: boolean, +): Scale { + + const breakHelper = getScaleBreakHelper(); + let breakOption; + if (breakHelper) { + breakOption = retrieveAxisBreaksOption(model, type, coordSysSupportAxisBreaks); } - const scaleType = scale.type; - const interval = model.get('interval'); - const isIntervalOrTime = scaleType === 'interval' || scaleType === 'time'; - - scale.setBreaksFromOption(retrieveAxisBreaksOption(model)); - scale.setExtent(extent[0], extent[1]); - scale.calcNiceExtent({ - splitNumber: splitNumber, - fixMin: extentInfo.fixMin, - fixMax: extentInfo.fixMax, - minInterval: isIntervalOrTime ? model.get('minInterval') : null, - maxInterval: isIntervalOrTime ? model.get('maxInterval') : null - }); - - // If some one specified the min, max. And the default calculated interval - // is not good enough. He can specify the interval. It is often appeared - // in angle axis with angle 0 - 360. Interval calculated in interval scale is hard - // to be 60. - // FIXME - if (interval != null) { - (scale as IntervalScale).setInterval && (scale as IntervalScale).setInterval(interval); + switch (type) { + case 'category': + return new OrdinalScale({ + ordinalMeta: model.getOrdinalMeta + ? model.getOrdinalMeta() + : model.getCategories(), + extent: initExtentForUnion(), + }); + case 'time': + return new TimeScale({ + locale: model.ecModel.getLocaleModel(), + useUTC: model.ecModel.get('useUTC'), + breakOption, + }); + case 'log': + // See also #3749 + return new LogScale({ + logBase: model.get('logBase'), + breakOption, + }); + case 'value': + return new IntervalScale({ + breakOption + }); + default: + // case others. + return new (Scale.getClass(type) || IntervalScale)({}); } } /** - * @param axisType Default retrieve from model.type + * Check if the axis cross a specific value. */ -export function createScaleByModel(model: AxisBaseModel, axisType?: string): Scale { - axisType = axisType || model.get('type'); - if (axisType) { - switch (axisType) { - // Buildin scale - case 'category': - return new OrdinalScale({ - ordinalMeta: model.getOrdinalMeta - ? model.getOrdinalMeta() - : model.getCategories(), - extent: [Infinity, -Infinity] - }); - case 'time': - return new TimeScale({ - locale: model.ecModel.getLocaleModel(), - useUTC: model.ecModel.get('useUTC'), - }); - default: - // case 'value'/'interval', 'log', or others. - return new (Scale.getClass(axisType) || IntervalScale)(); - } - } +export function getScaleValuePositionKind( + scale: Scale, value: number, considerMappingExtent: boolean +): ScaleValuePositionKind { + const dataExtent = considerMappingExtent + ? getScaleExtentForMappingUnsafe(scale, null) + : scale.getExtentUnsafe(SCALE_EXTENT_KIND_EFFECTIVE, null); + const min = dataExtent[0]; + const max = dataExtent[1]; + return !isValidBoundsForExtent(min, max) ? SCALE_VALUE_POSITION_KIND_OUTSIDE + : (min === value || max === value) ? SCALE_VALUE_POSITION_KIND_EDGE + : (min < value || max > value) ? SCALE_VALUE_POSITION_KIND_INSIDE + : SCALE_VALUE_POSITION_KIND_OUTSIDE; +} +export type ScaleValuePositionKind = + typeof SCALE_VALUE_POSITION_KIND_INSIDE + | typeof SCALE_VALUE_POSITION_KIND_EDGE + | typeof SCALE_VALUE_POSITION_KIND_OUTSIDE; +export const SCALE_VALUE_POSITION_KIND_INSIDE = 1; +export const SCALE_VALUE_POSITION_KIND_EDGE = 2; +export const SCALE_VALUE_POSITION_KIND_OUTSIDE = 3; + + +export function discourageOnAxisZero(axis: Axis, reason: Partial): void { + zrUtil.defaults(axisInner(axis).noOnMyZero || (axisInner(axis).noOnMyZero = {}), reason); } /** - * Check if the axis cross 0 + * `true`: Prevent orthoganal axes from positioning at the zero point of this axis. */ -export function ifAxisCrossZero(axis: Axis) { - const dataExtent = axis.scale.getExtent(); - const min = dataExtent[0]; - const max = dataExtent[1]; - return !((min > 0 && max > 0) || (min < 0 && max < 0)); +export function isOnAxisZeroDiscouraged(axis: Axis): boolean { + const noOnMyZero = axisInner(axis).noOnMyZero; + // Empirically, onZero causes weird effect when dataZoom is used on an "base axis". Consider + // bar series as an example. And also consider when `SCALE_EXTENT_KIND_MAPPING` is used, where + // the axis line is likely to cross the series shapes unexpectedly. + // Conservatively, we use "&&" rather than "||" here. + return noOnMyZero && noOnMyZero.dz && noOnMyZero.base; } /** @@ -298,12 +247,12 @@ export function getAxisRawValue(axis: Axis, tick: S // In category axis with data zoom, tick is not the original // index of axis.data. So tick should not be exposed to user // in category axis. - return axis.type === 'category' ? axis.scale.getLabel(tick) : tick.value as any; + const scale = axis.scale; + return (isOrdinalScale(scale) ? scale.getLabel(tick) : tick.value) as any; } /** * @param model axisLabelModel or axisTickModel - * @return {number|String} Can be null|'auto'|number|function */ export function getOptionCategoryInterval( model: Model @@ -340,16 +289,6 @@ export function getDataDimensionsOnAxis(data: SeriesData, axisDim: string): Dime return zrUtil.keys(dataDimMap); } -export function unionAxisExtentFromData(dataExtent: number[], data: SeriesData, axisDim: string): void { - if (data) { - zrUtil.each(getDataDimensionsOnAxis(data, axisDim), function (dim) { - const seriesExtent = data.getApproximateExtent(dim); - seriesExtent[0] < dataExtent[0] && (dataExtent[0] = seriesExtent[0]); - seriesExtent[1] > dataExtent[1] && (dataExtent[1] = seriesExtent[1]); - }); - } -} - export function isNameLocationCenter(nameLocation: AxisBaseOptionCommon['nameLocation']) { return nameLocation === 'middle' || nameLocation === 'center'; } @@ -358,7 +297,11 @@ export function shouldAxisShow(axisModel: AxisBaseModel): boolean { return axisModel.getShallow('show'); } -export function retrieveAxisBreaksOption(model: AxisBaseModel): AxisBaseOptionCommon['breaks'] { +export function retrieveAxisBreaksOption( + model: Model>, + axisType: OptionAxisType, + coordSysSupportAxisBreaks: boolean, +): AxisBaseOptionCommon['breaks'] { const option = model.get('breaks', true); if (option != null) { if (!getScaleBreakHelper()) { @@ -369,9 +312,12 @@ export function retrieveAxisBreaksOption(model: AxisBaseModel): AxisBaseOptionCo } return undefined; } - if (!isSupportAxisBreak(model.axis)) { - if (__DEV__) { - error(`Axis '${model.axis.dim}'-'${model.axis.type}' does not support break.`); + if (!coordSysSupportAxisBreaks || !isAxisTypeSupportAxisBreak(axisType)) { + if (__DEV__) { // Users have provided `breaks` in ec option but not supported. + const axisInfo = (model instanceof ComponentModel) + ? ` ${model.type}[${model.componentIndex}]` + : ''; + error(`Axis${axisInfo} does not support break.`); } return undefined; } @@ -379,8 +325,45 @@ export function retrieveAxisBreaksOption(model: AxisBaseModel): AxisBaseOptionCo } } -function isSupportAxisBreak(axis: Axis): boolean { - // The polar radius axis can also support break feasibly. Do not do it until the requirements are met. - return (axis.dim === 'x' || axis.dim === 'y' || axis.dim === 'z' || axis.dim === 'single') - && axis.type !== 'category'; +function isAxisTypeSupportAxisBreak(axisType: OptionAxisType): boolean { + return axisType !== 'category'; +} + +export function updateIntervalOrLogScaleForNiceOrAligned( + scale: IntervalScale | LogScale, + fixMinMax: ScaleExtentFixMinMax, + oldIntervalExtent: number[], + newIntervalExtent: number[], + oldOutermostExtent: number[] | NullUndefined, + cfg: IntervalScaleConfig +): void { + const isTargetLogScale = isLogScale(scale); + const intervalStub = isTargetLogScale ? scale.intervalStub : scale; + intervalStub.setExtent(newIntervalExtent[0], newIntervalExtent[1]); + + if (isTargetLogScale) { + // Sync intervalStub extent to the outermost extent (i.e., `powStub` for `LogScale`). + const powStub = scale.powStub; + const opt = {depth: SCALE_MAPPER_DEPTH_OUT_OF_BREAK} as const; + let minPow = scale.transformOut(newIntervalExtent[0], opt); + let maxPow = scale.transformOut(newIntervalExtent[1], opt); + // Log transform is probably not inversible by rounding error, which causes min/max tick may be + // displayed as `5.999999999999999` unexpectedly when min/max are required to be fixed (specified + // by users or by dataZoom). Therefore we set `powStub` with respect to `oldOutermostExtent` if + // interval extent is not changed. But `intervalStub` should not be inversely changed by this + // handling, otherwise its monotonicity between `niceExtent` and `extent` may be broken and cause + // unexpected ticks generation. + const extentChanged = extentDiffers(oldIntervalExtent, newIntervalExtent); + // NOTE: extent may still be changed even when min/max are required to be fixed, + // e.g., by `intervalScaleEnsureValidExtent`. + if (fixMinMax[0] && !extentChanged[0]) { + minPow = oldOutermostExtent[0]; + } + if (fixMinMax[1] && !extentChanged[1]) { + maxPow = oldOutermostExtent[1]; + } + powStub.setExtent(minPow, maxPow); + } + + intervalStub.setConfig(cfg); } diff --git a/src/coord/axisModelCommonMixin.ts b/src/coord/axisModelCommonMixin.ts index 57adacfb1b..ed8b7c45a2 100644 --- a/src/coord/axisModelCommonMixin.ts +++ b/src/coord/axisModelCommonMixin.ts @@ -31,8 +31,7 @@ interface AxisModelCommonMixin extends Pick { getNeedCrossZero(): boolean { - const option = this.option as ValueAxisBaseOption; - return !option.scale; + return !(this.option as ValueAxisBaseOption).scale; } /** diff --git a/src/coord/axisNiceTicks.ts b/src/coord/axisNiceTicks.ts new file mode 100644 index 0000000000..186459e8a3 --- /dev/null +++ b/src/coord/axisNiceTicks.ts @@ -0,0 +1,267 @@ +/* +* 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 { assert, noop } from 'zrender/src/core/util'; +import { + ensureValidSplitNumber, getIntervalPrecision, + intervalScaleEnsureValidExtent, + isIntervalScale, isLogScale, isTimeScale, +} from '../scale/helper'; +import IntervalScale, { IntervalScaleConfig } from '../scale/Interval'; +import { mathCeil, mathFloor, mathMax, nice, quantity, round } from '../util/number'; +import type { AxisBaseModel } from './AxisBaseModel'; +import type { AxisScaleType, NumericAxisBaseOptionCommon } from './axisCommonTypes'; +import { + updateIntervalOrLogScaleForNiceOrAligned +} from './axisHelper'; +import { calcNiceForTimeScale } from '../scale/Time'; +import type LogScale from '../scale/Log'; +import Scale from '../scale/Scale'; +import { + adoptScaleExtentKindMapping, adoptScaleRawExtentInfoAndPrepare, + ScaleExtentFixMinMax +} from './scaleRawExtentInfo'; +import { getScaleLinearSpanEffective } from '../scale/scaleMapper'; +import { NullUndefined } from '../util/types'; +import type GlobalModel from '../model/Global'; +import type Axis from './Axis'; + + +// ------ START: LinearIntervalScaleStub Nice ------ + +function calcNiceForIntervalOrLogScale( + scale: (IntervalScale | LogScale) & Scale, + opt: ScaleCalcNiceMethodOpt, +): void { + // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. + + const isTargetLogScale = isLogScale(scale); + const intervalStub = isTargetLogScale ? scale.intervalStub : scale; + + const fixMinMax = opt.fixMinMax || []; + const oldOutermostExtent = isTargetLogScale ? scale.getExtent() : null; + const oldIntervalExtent = intervalStub.getExtent(); + + let newIntervalExtent = intervalScaleEnsureValidExtent(oldIntervalExtent, fixMinMax); + + intervalStub.setExtent(newIntervalExtent[0], newIntervalExtent[1]); + newIntervalExtent = intervalStub.getExtent(); + + const config = isTargetLogScale + ? logScaleCalcNiceTicks(intervalStub, opt) + : intervalScaleCalcNiceTicks(intervalStub, opt); + const interval = config.interval; + const intervalPrecision = config.intervalPrecision; + + if (!fixMinMax[0]) { + newIntervalExtent[0] = round(mathFloor(newIntervalExtent[0] / interval) * interval, intervalPrecision); + } + if (!fixMinMax[1]) { + newIntervalExtent[1] = round(mathCeil(newIntervalExtent[1] / interval) * interval, intervalPrecision); + } + + updateIntervalOrLogScaleForNiceOrAligned( + scale, + fixMinMax, + oldIntervalExtent, + newIntervalExtent, + oldOutermostExtent, + config, + ); +} + +// ------ END: LinearIntervalScaleStub Nice ------ + + +// ------ START: IntervalScale Nice ------ + +function intervalScaleCalcNiceTicks( + scale: IntervalScale, + opt: Pick +): IntervalScaleConfig { + const splitNumber = ensureValidSplitNumber(opt.splitNumber, 5); + // Use the span in the innermost linear space to calculate nice ticks. + const span = getScaleLinearSpanEffective(scale); + + if (__DEV__) { + assert(isFinite(span) && span > 0); // It should have been ensured by `intervalScaleEnsureValidExtent`. + } + + const minInterval = opt.minInterval; + const maxInterval = opt.maxInterval; + + let interval = nice(span / splitNumber, true); + if (minInterval != null && interval < minInterval) { + interval = minInterval; + } + if (maxInterval != null && interval > maxInterval) { + interval = maxInterval; + } + const intervalPrecision = getIntervalPrecision(interval); + const extent = scale.getExtent(); + // By design, the `niceExtent` is inside the original extent + const niceExtent = [ + round(mathCeil(extent[0] / interval) * interval, intervalPrecision), + round(mathFloor(extent[1] / interval) * interval, intervalPrecision) + ]; + + return {interval, intervalPrecision, niceExtent}; +}; + +// ------ END: IntervalScale Nice ------ + + +// ------ START: LogScale Nice ------ + +function logScaleCalcNiceTicks( + intervalStub: IntervalScale, + opt: Pick +): IntervalScaleConfig { + // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. + + const splitNumber = ensureValidSplitNumber(opt.splitNumber, 10); + // Find nice ticks in the "logarithmic space". Notice that "logarithmic space" is a middle space + // rather than the innermost linear space when axis breaks exist. + const intervalExtent = intervalStub.getExtent(); + // But use the span in the innermost linear space to calculate nice ticks. + const span = getScaleLinearSpanEffective(intervalStub); + + if (__DEV__) { + assert(isFinite(span) && span > 0); // It should be ensured by `intervalScaleEnsureValidExtent`. + } + + // Interval should be integer + let interval = mathMax(quantity(span), 1); + + const err = splitNumber / span * interval; + + // Filter ticks to get closer to the desired count. + if (err <= 0.5) { + // TODO: support other bases other than 10? + interval *= 10; + } + + const intervalPrecision = getIntervalPrecision(interval); + // For LogScale, we use a `niceExtent` in the "logarithmic space" rather than + // the original "pow space", because it is used in `intervalStub.getTicks()` thereafter. + const niceExtent = [ + round(mathCeil(intervalExtent[0] / interval) * interval, intervalPrecision), + round(mathFloor(intervalExtent[1] / interval) * interval, intervalPrecision) + ] as [number, number]; + + return {intervalPrecision, interval, niceExtent}; +}; + +// ------ END: LogScale Nice ------ + + +// ------ START: scaleCalcNice Entry ------ + +export type ScaleCalcNiceMethod = ( + scale: Scale, + opt: ScaleCalcNiceMethodOpt +) => void; + +type ScaleCalcNiceMethodOpt = { + splitNumber?: number; + minInterval?: number; + maxInterval?: number; + fixMinMax?: ScaleExtentFixMinMax; +}; + +/** + * NOTE: See the summary of the process of extent determination in the comment of `scaleMapper.setExtent`. + * + * Calculate a "nice" extent and "nice" ticks configs based on the current scale extent and ec options. + * scale extent will be modified, and config may be set to the scale. + */ +export function scaleCalcNice( + axisLike: { + scale: Scale, + model: AxisBaseModel, + }, +): void { + const scale = axisLike.scale; + const model = axisLike.model as AxisBaseModel; + + const axis = model.axis; + const ecModel = model.ecModel; + if (__DEV__) { + assert(axis && ecModel); + } + + scaleCalcNice2(scale, model, axis, ecModel, null); +} + +export function scaleCalcNice2( + scale: Scale, + model: AxisBaseModel, + // Some call from external source, such as echarts-gl, may have no `axis` and `ecModel`, + // but has `externalDataExtent`. + axis: Axis | NullUndefined, + ecModel: GlobalModel | NullUndefined, + externalDataExtent: number[] | NullUndefined +): void { + + const rawExtentResult = adoptScaleRawExtentInfoAndPrepare(scale, model, ecModel, axis, externalDataExtent); + + // If some one specified the min, max. And the default calculated interval + // is not good enough. He can specify the interval. It is often appeared + // in angle axis with angle 0 - 360. Interval calculated in interval scale is hard + // to be 60. + // In `xxxAxis.type: 'log'`, ec option `xxxAxis.interval` requires a logarithm-applied + // value rather than a value in the raw scale. + const interval = model.get('interval'); + if (interval != null && (scale as IntervalScale).setConfig) { + (scale as IntervalScale).setConfig({interval}); + } + else { + const isIntervalOrTime = isIntervalScale(scale) || isTimeScale(scale); + scaleCalcNiceDirectly(scale, { + splitNumber: model.get('splitNumber'), + fixMinMax: rawExtentResult.fixMM, + minInterval: isIntervalOrTime ? model.get('minInterval') : null, + maxInterval: isIntervalOrTime ? model.get('maxInterval') : null + }); + } + + if (axis && ecModel) { + adoptScaleExtentKindMapping(scale, rawExtentResult); + } + + if (__DEV__) { + scale.freeze(); + } +} + +export function scaleCalcNiceDirectly( + scale: Scale, + opt: ScaleCalcNiceMethodOpt +): void { + scaleCalcNiceMethods[scale.type](scale, opt); +} + +const scaleCalcNiceMethods: Record = { + interval: calcNiceForIntervalOrLogScale, + log: calcNiceForIntervalOrLogScale, + time: calcNiceForTimeScale, + ordinal: noop, +}; + +// ------ END: scaleCalcNice Entry ------ diff --git a/src/coord/axisStatistics.ts b/src/coord/axisStatistics.ts new file mode 100644 index 0000000000..32f01c01c4 --- /dev/null +++ b/src/coord/axisStatistics.ts @@ -0,0 +1,594 @@ +/* +* 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 { assert, createHashMap, HashMap, retrieve2 } from 'zrender/src/core/util'; +import type GlobalModel from '../model/Global'; +import type SeriesModel from '../model/Series'; +import { + initExtentForUnion, makeCallOnlyOnce, makeInner, +} from '../util/model'; +import { ComponentSubType, DimensionIndex, NullUndefined } from '../util/types'; +import type Axis from './Axis'; +import { asc, isNullableNumberFinite } from '../util/number'; +import { parseSanitizationFilter, passesSanitizationFilter } from '../data/helper/dataValueHelper'; +import type DataStore from '../data/DataStore'; +import type { AxisBaseModel } from './AxisBaseModel'; +import { tryEnsureTypedArray, Float64ArrayCtor } from '../util/vendor'; +import { EChartsExtensionInstallRegisters } from '../extension'; +import type ComponentModel from '../model/Component'; +import { + getCachePerECFullUpdate, getCachePerECPrepare, GlobalModelCachePerECFullUpdate, + GlobalModelCachePerECPrepare +} from '../util/cycleCache'; +import { CoordinateSystem } from './CoordinateSystem'; + + +const callOnlyOnce = makeCallOnlyOnce(); +// Ensure that it never appears in internal generated uid and pre-defined coordSysType. +export const AXIS_STAT_KEY_DELIMITER = '|&'; + +const ecModelCacheFullUpdateInner = makeInner<{ + + // It stores all pairs, aggregated by axis, based on which axis scale extent is calculated. + // NOTICE: series that has been filtered out are included. + // It is unrelated to `AxisStatKeyedClient`. + axSer: HashMap; + + // AxisStatKey based statistics records. + // Only `AxisStatKeyedClient` concerned pairs are collected, based on which + // statistics are calculated. + keyed: AxisStatKeyed; + // `keys` is only used to quick travel. + keys: AxisStatKeys; + + // Only used in dev mode for duplication checking. + axSerPairCheck?: HashMap<1, string>; + +}, GlobalModelCachePerECFullUpdate>(); + +type AxisStatKeys = HashMap; +type AxisStatKeyed = HashMap; +type AxisStatPerKey = HashMap; +type AxisStatPerKeyPerAxis = { + axis: Axis; + // This is series use this axis as base axis and need to be laid out. + // The order is determined by the client and must be respected. + // Never be null/undefined. + // series filtered out is included. + sers: SeriesModel[]; + // For query. The array index is series index. + serByIdx: SeriesModel[]; + // Minimal positive gap of values (in the linear space) of all relevant series (e.g. per `BaseBarSeriesSubType`) + // on this axis. + // Be `null`/`undefined` if this metric is not calculated. + // Be `NaN` if no meaningful gap can be calculated, typically when only one item an no item. + liPosMinGap?: number | NullUndefined; + + // metrics corresponds to this record. + metrics?: AxisStatMetrics; +}; + +const ecModelCachePrepareInner = makeInner<{ + keyed: AxisStatECPrepareCacheKeyed | NullUndefined; +}, GlobalModelCachePerECPrepare>(); + +type AxisStatECPrepareCacheKeyed = HashMap; +type AxisStatECPrepareCachePerKey = HashMap; +type AxisStatECPrepareCachePerKeyPerAxis = + Pick & { + // Used for cache validity. + serUids?: HashMap<1, ComponentModel['uid']> + }; + +export type AxisStatKeyedClient = { + + // A key for retrieving result. + key: AxisStatKey; + + // Only the specific `seriesType` is covered. + seriesType: ComponentSubType; + // `true` by default - the pair is collected only if series's base axis is that axis. + baseAxis?: boolean | NullUndefined; + // `NullUndefined` by default - all coordinate systems are covered. + coordSysType?: CoordinateSystem['type'] | NullUndefined; + + // `NullUndefined` return indicates this axis should be omitted. + getMetrics: (axis: Axis) => AxisStatMetrics | NullUndefined; +}; + +/** + * Within each individual axis, different groups of relevant series and statistics are + * designated by `AxisStatKey`. In most case `seriesType` is used as `AxisStatKey`. + */ +export type AxisStatKey = string & {_: 'AxisStatKey'}; // Nominal to avoid misusing. +type ClientQueryKey = string & {_: 'ClientQueryKey'}; // Nominal to avoid misusing; internal usage. + +export type AxisStatMetrics = { + + // NOTICE: + // May be time-consuming in large data due to some metrics requiring travel and sort of + // series data, especially when axis break is used, so it is performed only if required, + // and stop calculation if the iteration is over `MIN_GAP_FOR_BAND_CALCULATION_LIMIT`. + liPosMinGap?: boolean +}; + +export type AxisStatisticsResult = Pick< + AxisStatPerKeyPerAxis, + 'liPosMinGap' +>; + +type AxisStatEachSeriesCb = (seriesModel: SeriesModel, travelIdx: number) => void; + +let validateInputAxis: ((axis: Axis) => void) | NullUndefined; +if (__DEV__) { + validateInputAxis = function (axis) { + assert(axis && axis.model && axis.model.uid && axis.model.ecModel); + }; +} + +function getAxisStatPerKeyPerAxis( + axis: Axis, + axisStatKey: AxisStatKey +): AxisStatPerKeyPerAxis | NullUndefined { + const axisModel = axis.model; + const keyed = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(axisModel.ecModel)).keyed; + const perKey = keyed && keyed.get(axisStatKey); + return perKey && perKey.get(axisModel.uid); +} + +export function getAxisStat( + axis: Axis, + axisStatKey: AxisStatKey + // Return: Never return null/undefined. +): AxisStatisticsResult { + if (__DEV__) { + assert(axisStatKey != null); + validateInputAxis(axis); + } + return wrapStatResult(getAxisStatPerKeyPerAxis(axis, axisStatKey)); +} + +export function getAxisStatBySeries( + axis: Axis, + seriesList: (SeriesModel | NullUndefined)[] + // Return: Never be null/undefined; never contain null/undefined. +): AxisStatisticsResult[] { + if (__DEV__) { + validateInputAxis(axis); + } + const result: AxisStatisticsResult[] = []; + eachKeyEachAxis(axis.model.ecModel, function (perKeyPerAxis) { + for (let idx = 0; idx < seriesList.length; idx++) { + if (seriesList[idx] && perKeyPerAxis.serByIdx[seriesList[idx].seriesIndex]) { + result.push(wrapStatResult(perKeyPerAxis)); + } + } + }); + return result; +} + +function eachKeyEachAxis( + ecModel: GlobalModel, + cb: ( + perKeyPerAxis: AxisStatPerKeyPerAxis, + axisStatKey: AxisStatKey, + axisModelUid: AxisBaseModel['uid'] + ) => void +): void { + const keyed = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).keyed; + keyed && keyed.each(function (perKey, axisStatKey) { + perKey.each(function (perKeyPerAxis, axisModelUid) { + cb(perKeyPerAxis, axisStatKey, axisModelUid); + }); + }); +} + +function wrapStatResult(record: AxisStatPerKeyPerAxis | NullUndefined): AxisStatisticsResult { + return { + liPosMinGap: record ? record.liPosMinGap : undefined, + }; +} + +/** + * NOTE: + * - series declaration order is respected. + * - series filtered out are excluded. + */ +export function eachSeriesOnAxis( + axis: Axis, + cb: AxisStatEachSeriesCb +): void { + if (__DEV__) { + validateInputAxis(axis); + } + const ecModel = axis.model.ecModel; + const seriesOnAxisMap = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).axSer; + seriesOnAxisMap && eachSeriesDeal(ecModel, seriesOnAxisMap.get(axis.model.uid), cb); +} + +export function eachSeriesOnAxisOnKey( + axis: Axis, + axisStatKey: AxisStatKey, + cb: (series: SeriesModel, idx: number) => void +): void { + if (__DEV__) { + assert(axisStatKey != null); + validateInputAxis(axis); + } + const perKeyPerAxis = getAxisStatPerKeyPerAxis(axis, axisStatKey); + perKeyPerAxis && eachSeriesDeal(axis.model.ecModel, perKeyPerAxis.sers, cb); +} + +function eachSeriesDeal( + ecModel: GlobalModel, + seriesList: SeriesModel[] | NullUndefined, + cb: AxisStatEachSeriesCb +): void { + if (!seriesList) { + return; + } + for (let i = 0; i < seriesList.length; i++) { + const seriesModel = seriesList[i]; + // Legend-filtered series need to be ignored since series are registered before `legendFilter`. + if (!ecModel.isSeriesFiltered(seriesModel)) { + cb(seriesModel, i); + } + } +} + +/** + * NOTE: + * - series filtered out are excluded. + */ +export function countSeriesOnAxisOnKey( + axis: Axis, + axisStatKey: AxisStatKey, +): number { + if (__DEV__) { + assert(axisStatKey != null); + validateInputAxis(axis); + } + const perKeyPerAxis = getAxisStatPerKeyPerAxis(axis, axisStatKey); + if (!perKeyPerAxis || !perKeyPerAxis.sers.length) { + return 0; + } + let count = 0; + eachSeriesDeal(axis.model.ecModel, perKeyPerAxis.sers, function () { + count++; + }); + return count; +} + +export function eachAxisOnKey( + ecModel: GlobalModel, + axisStatKey: AxisStatKey, + cb: (axis: Axis) => void +): void { + if (__DEV__) { + assert(axisStatKey != null); + } + const keyed = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)).keyed; + const perKey = keyed && keyed.get(axisStatKey); + perKey && perKey.each(function (perKeyPerAxis) { + cb(perKeyPerAxis.axis); + }); +} + +/** + * NOTICE: Available after `CoordinateSystem['create']` (not included). + */ +export function eachKeyOnAxis( + axis: Axis, + cb: (axisStatKey: AxisStatKey) => void +): void { + if (__DEV__) { + validateInputAxis(axis); + } + const keys = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(axis.model.ecModel)).keys; + keys && keys.each(function (axisStatKeyList) { + for (let i = 0; i < axisStatKeyList.length; i++) { + cb(axisStatKeyList[i]); + } + }); +} + +/** + * NOTICE: this processor may be omitted - it is registered only if required. + */ +function performAxisStatisticsOnOverallReset(ecModel: GlobalModel): void { + const ecPrepareCache = ecModelCachePrepareInner(getCachePerECPrepare(ecModel)); + const ecPrepareCacheKeyed = ecPrepareCache.keyed || (ecPrepareCache.keyed = createHashMap()); + + eachKeyEachAxis(ecModel, function (perKeyPerAxis, axisStatKey, axisModelUid) { + const ecPrepareCachePerKey = ecPrepareCacheKeyed.get(axisStatKey) + || ecPrepareCacheKeyed.set(axisStatKey, createHashMap()); + const ecPreparePerKeyPerAxis = ecPrepareCachePerKey.get(axisModelUid) + || ecPrepareCachePerKey.set(axisModelUid, {}); + + performStatisticsForRecord(ecModel, perKeyPerAxis, ecPreparePerKeyPerAxis); + }); +} + +function performStatisticsForRecord( + ecModel: GlobalModel, + perKeyPerAxis: AxisStatPerKeyPerAxis, + ecPreparePerKeyPerAxis: AxisStatECPrepareCachePerKeyPerAxis +): void { + if (!perKeyPerAxis.metrics.liPosMinGap) { + return; + } + + const newSerUids: AxisStatECPrepareCachePerKeyPerAxis['serUids'] = createHashMap(); + const ecPrepareSerUids = ecPreparePerKeyPerAxis.serUids; + const ecPrepareLiPosMinGap = ecPreparePerKeyPerAxis.liPosMinGap; + let ecPrepareCacheMiss: boolean; + + const axis = perKeyPerAxis.axis; + const scale = axis.scale; + const linearValueExtent = initExtentForUnion(); + const needTransform = scale.needTransform(); + const filter = scale.getFilter ? scale.getFilter() : null; + const filterParsed = parseSanitizationFilter(filter); + + // const timeRetrieve: number[] = []; // _EC_PERF_ + // const timeSort: number[] = []; // _EC_PERF_ + // const timeAll: number[] = []; // _EC_PERF_ + // timeAll[0] = Date.now(); // _EC_PERF_ + + function eachSeries( + cb: (dimStoreIdx: DimensionIndex, seriesModel: SeriesModel, rawDataStore: DataStore) => void + ) { + eachSeriesDeal(ecModel, perKeyPerAxis.sers, function (seriesModel) { + const rawData = seriesModel.getRawData(); + // NOTE: Currently there is no series that a "base axis" can map to multiple dimensions. + const dimStoreIdx = rawData.getDimensionIndex(rawData.mapDimension(axis.dim)); + if (dimStoreIdx >= 0) { + cb(dimStoreIdx, seriesModel, rawData.getStore()); + } + }); + } + + let bufferCapacity = 0; + eachSeries(function (dimStoreIdx, seriesModel, rawDataStore) { + newSerUids.set(seriesModel.uid, 1); + if (!ecPrepareSerUids || !ecPrepareSerUids.hasKey(seriesModel.uid)) { + ecPrepareCacheMiss = true; + } + bufferCapacity += rawDataStore.count(); + }); + + if (!ecPrepareSerUids || ecPrepareSerUids.keys().length !== newSerUids.keys().length) { + ecPrepareCacheMiss = true; + } + if (!ecPrepareCacheMiss && ecPrepareLiPosMinGap != null) { + // Consider the fact in practice: + // - Series data can only be changed in EC_PREPARE_UPDATE. + // - The relationship between series and axes can only be changed in EC_PREPARE_UPDATE and + // SERIES_FILTER. + // (See EC_CYCLE for more info) + // Therefore, some statistics results can be cached in `GlobalModelCachePerECPrepare` to avoid + // repeated time-consuming calculation for large data (e.g., over 1e5 data items). + perKeyPerAxis.liPosMinGap = ecPrepareLiPosMinGap; + return; + } + + tryEnsureTypedArray(tmpValueBuffer, bufferCapacity); + + // timeRetrieve[0] = Date.now(); // _EC_PERF_ + let writeIdx = 0; + eachSeries(function (dimStoreIdx, seriesModel, store) { + // NOTE: It appears to be optimized by traveling only in a specific window (e.g., the current window) + // instead of the entire data, but that would likely generate inconsistent result and bring + // jitter when dataZoom roaming. + for (let i = 0, cnt = store.count(); i < cnt; ++i) { + // Manually inline some code for performance, since no other optimization + // (such as, progressive) can be applied here. + let val = store.get(dimStoreIdx, i) as number; + // NOTE: in most cases, filter does not exist. + if (isFinite(val) + && (!filter || passesSanitizationFilter(filterParsed, val)) + ) { + if (needTransform) { + // PENDING: time-consuming if axis break is applied. + val = scale.transformIn(val, null); + } + tmpValueBuffer.arr[writeIdx++] = val; + val < linearValueExtent[0] && (linearValueExtent[0] = val); + val > linearValueExtent[1] && (linearValueExtent[1] = val); + } + } + }); + // Indicatively, retrieving values above costs 40ms for 1e6 values in a certain platform. + // timeRetrieve[1] = Date.now(); // _EC_PERF_ + + const tmpValueBufferView = tmpValueBuffer.typed + ? (tmpValueBuffer.arr as Float64Array).subarray(0, writeIdx) + : ((tmpValueBuffer.arr as number[]).length = writeIdx, tmpValueBuffer.arr); + + // timeSort[0] = Date.now(); // _EC_PERF_ + // Sort axis values into ascending order to calculate gaps. + if (tmpValueBuffer.typed) { + // Indicatively, 5ms for 1e6 values in a certain platform. + tmpValueBufferView.sort(); + } + else { + asc(tmpValueBufferView as number[]); + } + // timeAll[1] = timeSort[1] = Date.now(); // _EC_PERF_ + + // console.log('axisStatistics_minGap_retrieve', timeRetrieve[1] - timeRetrieve[0]); // _EC_PERF_ + // console.log('axisStatistics_minGap_sort', timeSort[1] - timeSort[0]); // _EC_PERF_ + // console.log('axisStatistics_minGap_all', timeAll[1] - timeAll[0]); // _EC_PERF_ + + let min = Infinity; + for (let j = 1; j < writeIdx; ++j) { + const delta = tmpValueBufferView[j] - tmpValueBufferView[j - 1]; + if (// - Different series normally have the same values, which should be ignored. + // - A single series with multiple same values is often not meaningful to + // create `bandWidth`, so it is also ignored. + delta > 0 + && delta < min + ) { + min = delta; + } + } + + ecPreparePerKeyPerAxis.liPosMinGap = perKeyPerAxis.liPosMinGap = isNullableNumberFinite(min) + ? min + : NaN; // No valid data item or single valid data item. + ecPreparePerKeyPerAxis.serUids = newSerUids; +} + +// For performance optimization. +const tmpValueBuffer = tryEnsureTypedArray( + {ctor: Float64ArrayCtor}, + 50 // arbitrary. May be expanded if needed. +); + +/** + * NOTICE: + * - It must be called in `CoordinateSystem['create']`, before series filtering. + * - It must be called in `seriesIndex` ascending order (series declaration order). + * i.e., iterated by `ecModel.eachSeries`. + * - Every pair can only call this method once. + * + * @see scaleRawExtentInfoCreate in `scaleRawExtentInfo.ts` + */ +export function associateSeriesWithAxis( + axis: Axis | NullUndefined, + seriesModel: SeriesModel, + coordSysType: CoordinateSystem['type'] +): void { + if (!axis) { + return; + } + + const ecModel = seriesModel.ecModel; + const ecFullUpdateCache = ecModelCacheFullUpdateInner(getCachePerECFullUpdate(ecModel)); + const axisModelUid = axis.model.uid; + + if (__DEV__) { + validateInputAxis(axis); + // - An axis can be associated with multiple `axisStatKey`s. For example, if `axisStatKey`s are + // "candlestick" and "bar", they can be associated with the same "xAxis". + // - Within an individual axis, it is a typically incorrect usage if a pair is + // associated with multiple `perKeyPerAxis`, which may cause repeated calculation and + // performance degradation, had hard to be found without the checking below. For example, If + // `axisStatKey` are "grid-bar" (see `barGrid.ts`) and "polar-bar" (see `barPolar.ts`), and + // a pair is wrongly associated with both "polar-bar" and "grid-bar", the + // relevant statistics will be computed twice. + const axSerPairCheck = ecFullUpdateCache.axSerPairCheck + || (ecFullUpdateCache.axSerPairCheck = createHashMap()); + const pairKey = `${axisModelUid}${AXIS_STAT_KEY_DELIMITER}${seriesModel.uid}`; + assert(!axSerPairCheck.get(pairKey)); + axSerPairCheck.set(pairKey, 1); + } + + const seriesOnAxisMap = ecFullUpdateCache.axSer || (ecFullUpdateCache.axSer = createHashMap()); + const seriesListPerAxis = seriesOnAxisMap.get(axisModelUid) || (seriesOnAxisMap.set(axisModelUid, [])); + if (__DEV__) { + const lastSeries = seriesListPerAxis[seriesListPerAxis.length - 1]; + if (lastSeries) { + // Series order should respect to the input order, since it matters in some cases + // (e.g., see `barGrid.ts` and `barPolar.ts` - ec option declaration order matters). + assert(lastSeries.seriesIndex < seriesModel.seriesIndex); + } + } + seriesListPerAxis.push(seriesModel); + + const seriesType = seriesModel.subType; + const isBaseAxis = seriesModel.getBaseAxis() === axis; + + const client = clientsByQueryKey.get(makeClientQueryKey(seriesType, isBaseAxis, coordSysType)) + || clientsByQueryKey.get(makeClientQueryKey(seriesType, isBaseAxis, null)); + if (!client) { + return; + } + + const keyed: AxisStatKeyed = ecFullUpdateCache.keyed || (ecFullUpdateCache.keyed = createHashMap()); + const keys: AxisStatKeys = ecFullUpdateCache.keys || (ecFullUpdateCache.keys = createHashMap()); + + const axisStatKey = client.key; + const perKey = keyed.get(axisStatKey) || keyed.set(axisStatKey, createHashMap()); + let perKeyPerAxis = perKey.get(axisModelUid); + if (!perKeyPerAxis) { + perKeyPerAxis = perKey.set(axisModelUid, {axis, sers: [], serByIdx: []}); + // They should only be executed for each pair once: + perKeyPerAxis.metrics = client.getMetrics(axis); + (keys.get(axisModelUid) || keys.set(axisModelUid, [])) + .push(axisStatKey); + } + + // series order should respect to the input order. + perKeyPerAxis.sers.push(seriesModel); + perKeyPerAxis.serByIdx[seriesModel.seriesIndex] = seriesModel; +} + +/** + * NOTE: Currently, the scenario is simple enough to look up clients by hash map. + * Otherwise, a caller-provided `filter` may be an alternative if more complex requirements arise. + */ +function makeClientQueryKey( + seriesType: ComponentSubType, + isBaseAxis: boolean | NullUndefined, + coordSysType: CoordinateSystem['type'] | NullUndefined +): ClientQueryKey { + return ( + seriesType + + AXIS_STAT_KEY_DELIMITER + retrieve2(isBaseAxis, true) + + AXIS_STAT_KEY_DELIMITER + (coordSysType || '') + ) as ClientQueryKey; +} + +/** + * NOTICE: Can only be called in "install" stage. + * + * See `axisSnippets.ts` for some commonly used clients. + */ +export function requireAxisStatistics( + registers: EChartsExtensionInstallRegisters, + client: AxisStatKeyedClient +): void { + const queryKey = makeClientQueryKey(client.seriesType, client.baseAxis, client.coordSysType); + + if (__DEV__) { + assert(client.seriesType + && client.key + && !clientsCheckStatKey.get(client.key) + && !clientsByQueryKey.get(queryKey) + ); // More checking is performed in `axSerPairCheck`. + clientsCheckStatKey.set(client.key, 1); + } + + clientsByQueryKey.set(queryKey, client); + + callOnlyOnce(registers, function () { + registers.registerProcessor(registers.PRIORITY.PROCESSOR.AXIS_STATISTICS, { + // Theoretically, `appendData` requires to re-calculate them. + dirtyOnOverallProgress: true, + overallReset: performAxisStatisticsOnOverallReset + }); + }); +} + +let clientsCheckStatKey: HashMap<1, AxisStatKey>; +if (__DEV__) { + clientsCheckStatKey = createHashMap(); +} +const clientsByQueryKey: HashMap = createHashMap(); diff --git a/src/coord/axisTickLabelBuilder.ts b/src/coord/axisTickLabelBuilder.ts index ab344dec68..5fdd209415 100644 --- a/src/coord/axisTickLabelBuilder.ts +++ b/src/coord/axisTickLabelBuilder.ts @@ -19,28 +19,26 @@ import * as zrUtil from 'zrender/src/core/util'; import * as textContain from 'zrender/src/contain/text'; -import {makeInner} from '../util/model'; +import {makeInner, removeDuplicates, removeDuplicatesGetKeyFromItemItself} from '../util/model'; import { makeLabelFormatter, getOptionCategoryInterval, - shouldShowAllLabels } from './axisHelper'; import Axis from './Axis'; import Model from '../model/Model'; -import { AxisBaseOption, CategoryAxisBaseOption } from './axisCommonTypes'; +import { AxisBaseOption, AxisTickLabelCustomValuesOption, CategoryAxisBaseOption } from './axisCommonTypes'; import OrdinalScale from '../scale/Ordinal'; import { AxisBaseModel } from './AxisBaseModel'; import type Axis2D from './cartesian/Axis2D'; -import { NullUndefined, ScaleTick, VisualAxisBreak } from '../util/types'; -import { ScaleGetTicksOpt } from '../scale/Scale'; +import { NullUndefined, ScaleTick } from '../util/types'; +import Scale, { ScaleGetTicksOpt } from '../scale/Scale'; +import { asc } from '../util/number'; -type AxisLabelInfoDetermined = { +export type AxisLabelInfoDetermined = { formattedLabel: string, rawLabel: string, - tickValue: number, - time: ScaleTick['time'] | NullUndefined, - break: VisualAxisBreak | NullUndefined, + tick: ScaleTick, // Never be null/undefined. }; type AxisCache = { @@ -107,37 +105,19 @@ export function createAxisLabelsComputingContext(kind: AxisTickLabelComputingKin }; } - -function tickValuesToNumbers(axis: Axis, values: (number | string | Date)[]) { - const nums = zrUtil.map(values, val => axis.scale.parse(val)); - if (axis.type === 'time' && nums.length > 0) { - // Time axis needs duplicate first/last tick (see TimeScale.getTicks()) - // The first and last tick/label don't get drawn - nums.sort(); - nums.unshift(nums[0]); - nums.push(nums[nums.length - 1]); - } - return nums; -} - export function createAxisLabels(axis: Axis, ctx: AxisLabelsComputingContext): { labels: AxisLabelInfoDetermined[] } { const custom = axis.getLabelModel().get('customValues'); if (custom) { - const labelFormatter = makeLabelFormatter(axis); - const extent = axis.scale.getExtent(); - const tickNumbers = tickValuesToNumbers(axis, custom); - const ticks = zrUtil.filter(tickNumbers, val => val >= extent[0] && val <= extent[1]); + const scale = axis.scale; return { - labels: zrUtil.map(ticks, (numval, index) => { + labels: zrUtil.map(parseTickLabelCustomValues(custom, scale), (numval, index) => { const tick = {value: numval}; return { - formattedLabel: labelFormatter(tick, index), - rawLabel: axis.scale.getLabel(tick), - tickValue: numval, - time: undefined as ScaleTick['time'] | NullUndefined, - break: undefined as VisualAxisBreak | NullUndefined, + formattedLabel: makeLabelFormatter(axis)(tick, index), + rawLabel: scale.getLabel(tick), + tick: tick, }; }), }; @@ -159,18 +139,34 @@ export function createAxisTicks( ticks: number[], tickCategoryInterval?: number } { + const scale = axis.scale; const custom = axis.getTickModel().get('customValues'); if (custom) { - const extent = axis.scale.getExtent(); - const tickNumbers = tickValuesToNumbers(axis, custom); return { - ticks: zrUtil.filter(tickNumbers, val => val >= extent[0] && val <= extent[1]) + ticks: parseTickLabelCustomValues(custom, scale) }; } // Only ordinal scale support tick interval return axis.type === 'category' ? makeCategoryTicks(axis, tickModel) - : {ticks: zrUtil.map(axis.scale.getTicks(opt), tick => tick.value)}; + : {ticks: zrUtil.map(scale.getTicks(opt), tick => tick.value)}; +} + +function parseTickLabelCustomValues( + customValues: AxisTickLabelCustomValuesOption, + scale: Scale, +): number[] { + const extent = scale.getExtent(); + const tickNumbers: number[] = []; + zrUtil.each(customValues, function (val) { + val = scale.parse(val); + if (val >= extent[0] && val <= extent[1]) { + tickNumbers.push(val); + } + }); + removeDuplicates(tickNumbers, removeDuplicatesGetKeyFromItemItself, null); + asc(tickNumbers); + return tickNumbers; } function makeCategoryLabels(axis: Axis, ctx: AxisLabelsComputingContext): ReturnType { @@ -260,7 +256,7 @@ function makeCategoryTicks(axis: Axis, tickModel: AxisBaseModel) { ); tickCategoryInterval = labelsResult.labelCategoryInterval; ticks = zrUtil.map(labelsResult.labels, function (labelItem) { - return labelItem.tickValue; + return labelItem.tick.value; }); } else { @@ -282,9 +278,7 @@ function makeRealNumberLabels(axis: Axis): ReturnType { return { formattedLabel: labelFormatter(tick, idx), rawLabel: axis.scale.getLabel(tick), - tickValue: tick.value, - time: tick.time, - break: tick.break, + tick: tick, }; }) }; @@ -340,7 +334,7 @@ function makeAutoCategoryInterval(axis: Axis, ctx: AxisLabelsComputingContext): /** * Calculate interval for category axis ticks and labels. - * Use a stretegy to try to avoid overlapping. + * Use a strategy to try to avoid overlapping. * To get precise result, at least one of `getRotate` and `isHorizontal` * should be implemented in axis. */ @@ -487,7 +481,6 @@ function makeLabelsByNumericCategoryInterval( const labelFormatter = makeLabelFormatter(axis); const ordinalScale = axis.scale as OrdinalScale; const ordinalExtent = ordinalScale.getExtent(); - const labelModel = axis.getLabelModel(); const result: (AxisLabelInfoDetermined | number)[] = []; // TODO: axisType: ordinalTime, pick the tick from each month/day/year/... @@ -499,21 +492,14 @@ function makeLabelsByNumericCategoryInterval( // Calculate start tick based on zero if possible to keep label consistent // while zooming and moving while interval > 0. Otherwise the selection // of displayable ticks and symbols probably keep changing. - // 3 is empirical value. if (startTick !== 0 && step > 1 && tickCount / step > 2) { startTick = Math.round(Math.ceil(startTick / step) * step); } - // (1) Only add min max label here but leave overlap checking - // to render stage, which also ensure the returned list - // suitable for splitLine and splitArea rendering. - // (2) Scales except category always contain min max label so - // do not need to perform this process. - const showAllLabel = shouldShowAllLabels(axis); - const includeMinLabel = labelModel.get('showMinLabel') || showAllLabel; - const includeMaxLabel = labelModel.get('showMaxLabel') || showAllLabel; - - if (includeMinLabel && startTick !== ordinalExtent[0]) { + // min max labels may be excluded due to the previous modification of `startTick`. + // But they should be always included and the display strategy is adopted uniformly + // later in `AxisBuilder`. + if (startTick !== ordinalExtent[0]) { addItem(ordinalExtent[0]); } @@ -523,20 +509,18 @@ function makeLabelsByNumericCategoryInterval( addItem(tickValue); } - if (includeMaxLabel && tickValue - step !== ordinalExtent[1]) { + if (tickValue - step !== ordinalExtent[1]) { addItem(ordinalExtent[1]); } function addItem(tickValue: number) { - const tickObj = { value: tickValue }; + const tickObj = {value: tickValue}; result.push(onlyTick ? tickValue : { formattedLabel: labelFormatter(tickObj), rawLabel: ordinalScale.getLabel(tickObj), - tickValue: tickValue, - time: undefined, - break: undefined, + tick: tickObj, } ); } @@ -567,16 +551,14 @@ function makeLabelsByCustomizedCategoryInterval( zrUtil.each(ordinalScale.getTicks(), function (tick) { const rawLabel = ordinalScale.getLabel(tick); const tickValue = tick.value; - if (categoryInterval(tick.value, rawLabel)) { + if (categoryInterval(tickValue, rawLabel)) { result.push( onlyTick ? tickValue : { formattedLabel: labelFormatter(tick), rawLabel: rawLabel, - tickValue: tickValue, - time: undefined, - break: undefined, + tick: tick, } ); } diff --git a/src/coord/cartesian/Cartesian2D.ts b/src/coord/cartesian/Cartesian2D.ts index bf833ece6a..2636e40e23 100644 --- a/src/coord/cartesian/Cartesian2D.ts +++ b/src/coord/cartesian/Cartesian2D.ts @@ -23,21 +23,24 @@ import Cartesian from './Cartesian'; import { ScaleDataValue } from '../../util/types'; import Axis2D from './Axis2D'; import { CoordinateSystem } from '../CoordinateSystem'; -import GridModel from './GridModel'; +import GridModel, { COORD_SYS_TYPE_CARTESIAN_2D } from './GridModel'; import Grid from './Grid'; import Scale from '../../scale/Scale'; import { invert } from 'zrender/src/core/matrix'; import { applyTransform } from 'zrender/src/core/vector'; +import { getScaleExtentForMappingUnsafe } from '../../scale/scaleMapper'; +import { hasBreaks } from '../../scale/break'; export const cartesian2DDimensions = ['x', 'y']; function canCalculateAffineTransform(scale: Scale) { - return (scale.type === 'interval' || scale.type === 'time') && !scale.hasBreaks(); + // Only supported on linear space. + return (scale.type === 'interval' || scale.type === 'time') && !hasBreaks(scale); } class Cartesian2D extends Cartesian implements CoordinateSystem { - readonly type = 'cartesian2d'; + readonly type = COORD_SYS_TYPE_CARTESIAN_2D; readonly dimensions = cartesian2DDimensions; @@ -62,8 +65,8 @@ class Cartesian2D extends Cartesian implements CoordinateSystem { return; } - const xScaleExtent = xAxisScale.getExtent(); - const yScaleExtent = yAxisScale.getExtent(); + const xScaleExtent = getScaleExtentForMappingUnsafe(xAxisScale, null); + const yScaleExtent = getScaleExtentForMappingUnsafe(yAxisScale, null); const start = this.dataToPoint([xScaleExtent[0], yScaleExtent[0]]); const end = this.dataToPoint([xScaleExtent[1], yScaleExtent[1]]); @@ -85,9 +88,17 @@ class Cartesian2D extends Cartesian implements CoordinateSystem { } /** - * Base axis will be used on stacking. + * Base axis will be used on stacking and series such as 'bar', 'pictorialBar', etc. */ getBaseAxis(): Axis2D { + // FIXME: + // (1) We should allow series (e.g., bar) to specify a base axis when + // both axes are type "value", rather than force to xAxis or angleAxis. + // NOTE: At present BoxplotSeries has its own overide `getBaseAxis`. + // `CoordinateSystem['getBaseAxis']` probably should not exist, since it + // may introduce inconsistency with `Series['getBaseAxis']`. + // (2) "base axis" info is required in "createSeriesData" stage for "stack", + // (see `dataStackHelper.ts` for details). Currently it is hard coded there. return this.getAxesByScale('ordinal')[0] || this.getAxesByScale('time')[0] || this.getAxis('x'); diff --git a/src/coord/cartesian/Grid.ts b/src/coord/cartesian/Grid.ts index 97ac081bf0..915e9f0413 100644 --- a/src/coord/cartesian/Grid.ts +++ b/src/coord/cartesian/Grid.ts @@ -27,36 +27,38 @@ import {isObject, each, indexOf, retrieve3, keys, assert, eqNaN, find, retrieve2 import {BoxLayoutReferenceResult, createBoxLayoutReference, getLayoutRect, LayoutRect} from '../../util/layout'; import { createScaleByModel, - ifAxisCrossZero, - niceScaleExtent, - getDataDimensionsOnAxis, + getScaleValuePositionKind, isNameLocationCenter, shouldAxisShow, + retrieveAxisBreaksOption, + determineAxisType, + discourageOnAxisZero, + isOnAxisZeroDiscouraged, + SCALE_VALUE_POSITION_KIND_OUTSIDE, + SCALE_VALUE_POSITION_KIND_EDGE, + SCALE_VALUE_POSITION_KIND_INSIDE, } from '../../coord/axisHelper'; import Cartesian2D, {cartesian2DDimensions} from './Cartesian2D'; import Axis2D from './Axis2D'; import {ParsedModelFinder, ParsedModelFinderKnown, SINGLE_REFERRING} from '../../util/model'; // Depends on GridModel, AxisModel, which performs preprocess. -import GridModel, { GridOption, OUTER_BOUNDS_CLAMP_DEFAULT, OUTER_BOUNDS_DEFAULT } from './GridModel'; +import GridModel, { COORD_SYS_TYPE_CARTESIAN_2D, GridOption, OUTER_BOUNDS_CLAMP_DEFAULT, OUTER_BOUNDS_DEFAULT } from './GridModel'; import CartesianAxisModel from './AxisModel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import { Dictionary } from 'zrender/src/core/types'; import {CoordinateSystemMaster} from '../CoordinateSystem'; import { NullUndefined, ScaleDataValue } from '../../util/types'; -import SeriesData from '../../data/SeriesData'; -import OrdinalScale from '../../scale/Ordinal'; import { findAxisModels, createCartesianAxisViewCommonPartBuilder, updateCartesianAxisViewCommonPartBuilder, - isCartesian2DInjectedAsDataCoordSys } from './cartesianAxisHelper'; -import { CategoryAxisBaseOption, NumericAxisBaseOptionCommon } from '../axisCommonTypes'; +import { AxisBaseOptionCommon, CategoryAxisBaseOption, NumericAxisBaseOptionCommon } from '../axisCommonTypes'; import { AxisBaseModel } from '../AxisBaseModel'; -import { isIntervalOrLogScale } from '../../scale/helper'; -import { alignScaleTicks } from '../axisAlignTicks'; +import { isIntervalOrLogScale, isOrdinalScale } from '../../scale/helper'; +import { scaleCalcAlign } from '../axisAlignTicks'; import IntervalScale from '../../scale/Interval'; import LogScale from '../../scale/Log'; import { BoundingRect, expandOrShrinkRect, WH, XY } from '../../util/graphic'; @@ -70,6 +72,16 @@ import { error, log } from '../../util/log'; import { AxisTickLabelComputingKind } from '../axisTickLabelBuilder'; import { injectCoordSysByOption } from '../../core/CoordinateSystem'; import { mathMax, parsePositionSizeOption } from '../../util/number'; +import { scaleCalcNice } from '../axisNiceTicks'; +import { createDimNameMap } from '../../data/helper/SeriesDataSchema'; +import type Axis from '../Axis'; +import { + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, + scaleRawExtentInfoEnableBoxCoordSysUsage, scaleRawExtentInfoCreate +} from '../scaleRawExtentInfo'; +import { hasBreaks } from '../../scale/break'; +import { associateSeriesWithAxis } from '../axisStatistics'; + type Cartesian2DDimensionName = 'x' | 'y'; @@ -107,6 +119,7 @@ class Grid implements CoordinateSystemMaster { // For deciding which dimensions to use when creating list data static dimensions = cartesian2DDimensions; readonly dimensions = cartesian2DDimensions; + static dimIdxMap = createDimNameMap(cartesian2DDimensions); constructor(gridModel: GridModel, ecModel: GlobalModel, api: ExtensionAPI) { this._initCartesian(gridModel, ecModel, api); @@ -121,53 +134,39 @@ class Grid implements CoordinateSystemMaster { const axesMap = this._axesMap; - this._updateScale(ecModel, this.model); + each(this._axesList, function (axis) { + scaleRawExtentInfoCreate(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + const scale = axis.scale; + if (isOrdinalScale(scale)) { + scale.setSortInfo(axis.model.get('categorySortInfo')); + } + }); function updateAxisTicks(axes: Record) { - let alignTo: Axis2D; // Axis is added in order of axisIndex. const axesIndices = keys(axes); - const len = axesIndices.length; - if (!len) { - return; - } const axisNeedsAlign: Axis2D[] = []; - // Process once and calculate the ticks for those don't use alignTicks. - for (let i = len - 1; i >= 0; i--) { - const idx = +axesIndices[i]; // Convert to number. - const axis = axes[idx]; - const model = axis.model as AxisBaseModel; - const scale = axis.scale; - if (// Only value and log axis without interval support alignTicks. - isIntervalOrLogScale(scale) - && model.get('alignTicks') - && model.get('interval') == null - ) { + + for (let i = axesIndices.length - 1; i >= 0; i--) { // Reverse order + const axis = axes[+axesIndices[i]]; + if (axis.__alignTo) { axisNeedsAlign.push(axis); } else { - niceScaleExtent(scale, model); - if (isIntervalOrLogScale(scale)) { // Can only align to interval or log axis. - alignTo = axis; - } + scaleCalcNice(axis); } }; - // All axes has set alignTicks. Pick the first one. - // PENDING. Should we find the axis that both set interval, min, max and align to this one? - if (axisNeedsAlign.length) { - if (!alignTo) { - alignTo = axisNeedsAlign.pop(); - niceScaleExtent(alignTo.scale, alignTo.model); + each(axisNeedsAlign, axis => { + if (incapableOfAlignNeedFallback(axis, axis.__alignTo as Axis2D)) { + scaleCalcNice(axis); } - - each(axisNeedsAlign, axis => { - alignScaleTicks( - axis.scale as IntervalScale | LogScale, - axis.model, - alignTo.scale as IntervalScale | LogScale + else { + scaleCalcAlign( + axis, + axis.__alignTo.scale as IntervalScale | LogScale ); - }); - } + } + }); } updateAxisTicks(axesMap.x); @@ -216,6 +215,10 @@ class Grid implements CoordinateSystemMaster { const optionContainLabel = gridModel.get('containLabel'); // No `.get(, true)` for backward compat. + // NOTE: The axis pixel extent is also required by some estimation, e.g., in coord sys update stage, + // bars on 'time'/'value' axis need it to calculate the supplementary scale extent to avoid edge bars + // overflowing the axis (see `barGrid.ts`). Therefore, axis pixel extent need to be set early, even + // may not be accurate. updateAllAxisExtentTransByGridRect(axesMap, gridRect); if (!beforeDataProcessing) { @@ -265,13 +268,13 @@ class Grid implements CoordinateSystemMaster { layoutRef ); // console.timeEnd('buildAxesView_determine'); - } // End of beforeDataProcessing - each(this._coordsList, function (coord) { - // Calculate affine matrix to accelerate the data to point transform. - // If all the axes scales are time or value. - coord.calcAffineTransform(); - }); + each(this._coordsList, function (coord) { + // Calculate affine matrix to accelerate the data to point transform. + // If all the axes scales are time or value. + coord.calcAffineTransform(); + }); + } // End of beforeDataProcessing } getAxis(dim: Cartesian2DDimensionName, axisIndex?: number): Axis2D { @@ -447,9 +450,14 @@ class Grid implements CoordinateSystemMaster { cartesian.addAxis(xAxis); cartesian.addAxis(yAxis); + + discourageOnAxisZero(cartesian.getBaseAxis(), {base: true}); }); }); + prepareAlignToInCoordSysCreate(axesMap.x); + prepareAlignToInCoordSysCreate(axesMap.y); + function createAxisCreator(dimName: Cartesian2DDimensionName) { return function (axisModel: CartesianAxisModel, idx: number): void { if (!isAxisUsedInTheGrid(axisModel, gridModel)) { @@ -473,11 +481,12 @@ class Grid implements CoordinateSystemMaster { } axisPositionUsed[axisPosition] = true; + const axisType = determineAxisType(axisModel); const axis = new Axis2D( dimName, - createScaleByModel(axisModel), + createScaleByModel(axisModel, axisType, true), [0, 0], - axisModel.get('type'), + axisType, axisPosition ); @@ -505,53 +514,6 @@ class Grid implements CoordinateSystemMaster { } } - /** - * Update cartesian properties from series. - */ - private _updateScale(ecModel: GlobalModel, gridModel: GridModel): void { - // Reset scale - each(this._axesList, function (axis) { - axis.scale.setExtent(Infinity, -Infinity); - if (axis.type === 'category') { - const categorySortInfo = axis.model.get('categorySortInfo'); - (axis.scale as OrdinalScale).setSortInfo(categorySortInfo); - } - }); - - ecModel.eachSeries(function (seriesModel) { - // If pie (or other similar series) use cartesian2d, the unionExtent logic below is - // wrong, therefore skip it temporarily. See also in `defaultAxisExtentFromData.ts`. - // TODO: support union extent in this case. - if (isCartesian2DInjectedAsDataCoordSys(seriesModel)) { - const axesModelMap = findAxisModels(seriesModel); - const xAxisModel = axesModelMap.xAxisModel; - const yAxisModel = axesModelMap.yAxisModel; - - if (!isAxisUsedInTheGrid(xAxisModel, gridModel) - || !isAxisUsedInTheGrid(yAxisModel, gridModel) - ) { - return; - } - - const cartesian = this.getCartesian( - xAxisModel.componentIndex, yAxisModel.componentIndex - ); - const data = seriesModel.getData(); - const xAxis = cartesian.getAxis('x'); - const yAxis = cartesian.getAxis('y'); - - unionExtent(data, xAxis); - unionExtent(data, yAxis); - } - }, this); - - function unionExtent(data: SeriesData, axis: Axis2D): void { - each(getDataDimensionsOnAxis(data, axis.dim), function (dim) { - axis.scale.unionExtentFromData(data, dim); - }); - } - } - /** * @param dim 'x' or 'y' or 'auto' or null/undefined */ @@ -585,13 +547,20 @@ class Grid implements CoordinateSystemMaster { gridModel.coordinateSystem = grid; grids.push(grid); + + each(grid._axesList, function (axis) { + scaleRawExtentInfoEnableBoxCoordSysUsage(axis, Grid.dimIdxMap); + }); }); // Inject the coordinateSystems into seriesModel ecModel.eachSeries(function (seriesModel) { + let xAxis: Axis; + let yAxis: Axis; + injectCoordSysByOption({ targetModel: seriesModel, - coordSysType: 'cartesian2d', + coordSysType: COORD_SYS_TYPE_CARTESIAN_2D, coordSysProvider: coordSysProvider }); @@ -599,6 +568,8 @@ class Grid implements CoordinateSystemMaster { const axesModelMap = findAxisModels(seriesModel); const xAxisModel = axesModelMap.xAxisModel; const yAxisModel = axesModelMap.yAxisModel; + xAxis = xAxisModel.axis; + yAxis = yAxisModel.axis; const gridModel = xAxisModel.getCoordSysModel(); @@ -623,7 +594,12 @@ class Grid implements CoordinateSystemMaster { xAxisModel.componentIndex, yAxisModel.componentIndex ); } - }); + if (xAxis && yAxis) { + associateSeriesWithAxis(xAxis, seriesModel, COORD_SYS_TYPE_CARTESIAN_2D); + associateSeriesWithAxis(yAxis, seriesModel, COORD_SYS_TYPE_CARTESIAN_2D); + } + + }, this); return grids; } @@ -660,13 +636,16 @@ function fixAxisOnZero( const onZero = axisModel.get(['axisLine', 'onZero']); const onZeroAxisIndex = axisModel.get(['axisLine', 'onZeroAxisIndex']); + // For historical reason, ec option `axisLine.onZero: undefined` leads to "not on zero" + // while leaving `axisLine.onZero` unspecified causes "on zero". This inconsistency goes + // against common sense, but is preserved for backward compatibility. if (!onZero) { return; } // If target axis is specified. if (onZeroAxisIndex != null) { - if (canOnZeroToAxis(otherAxes[onZeroAxisIndex])) { + if (canOnZeroToAxis(onZero, otherAxes[onZeroAxisIndex])) { otherAxisOnZeroOf = otherAxes[onZeroAxisIndex]; } } @@ -674,7 +653,7 @@ function fixAxisOnZero( // Find the first available other axis. for (const idx in otherAxes) { if (otherAxes.hasOwnProperty(idx) - && canOnZeroToAxis(otherAxes[idx]) + && canOnZeroToAxis(onZero, otherAxes[idx]) // Consider that two Y axes on one value axis, // if both onZero, the two Y axes overlap. && !onZeroRecords[getOnZeroRecordKey(otherAxes[idx])] @@ -694,10 +673,101 @@ function fixAxisOnZero( } } -function canOnZeroToAxis(axis: Axis2D): boolean { - return axis && axis.type !== 'category' && axis.type !== 'time' && ifAxisCrossZero(axis); +/** + * CAVEAT: Must not be called before `CoordinateSystem#update` due to `__dontOnMyZero`. + */ +function canOnZeroToAxis( + onZeroOption: AxisBaseOptionCommon['axisLine']['onZero'], + axis: Axis2D +): boolean { + const scale = axis.scale; + const kindEffective = getScaleValuePositionKind(scale, 0, false); + + let can = axis + // PENDING: Historical behavior: `onZero` on 'category' and 'time' axis are always disabled + // even if ec option gives `onZero: true`. + && axis.type !== 'category' && axis.type !== 'time' + // NOTE: Although the portion out of "effective" portion may also cross zero + // (see `SCALE_EXTENT_KIND_MAPPING`), that is commonly meaningless, so we use + // `SCALE_EXTENT_KIND_EFFECTIVE` + && kindEffective !== SCALE_VALUE_POSITION_KIND_OUTSIDE; + + if (can && onZeroOption === 'auto' + && ( + isOnAxisZeroDiscouraged(axis) + || ( + // Avoid axis line cross series shape (typically, bar series on "value"/"time" axis) unexpectedly. + kindEffective === SCALE_VALUE_POSITION_KIND_EDGE + && getScaleValuePositionKind(scale, 0, true) === SCALE_VALUE_POSITION_KIND_INSIDE + ) + ) + ) { + can = false; + } + // falsy value of `onZeroOption` has been handled in the previous logic. + return can; } +/** + * [CAVEAT] This method is called before data processing stage. + * Do not rely on any info that is determined afterward. + */ +function prepareAlignToInCoordSysCreate(axes: Record): void { + // Axis is added in order of axisIndex. + const axesIndices = keys(axes); + + let alignTo: Axis2D; + const axisNeedsAlign: Axis2D[] = []; + + for (let i = axesIndices.length - 1; i >= 0; i--) { // Reverse order + const axis = axes[+axesIndices[i]]; + if ( + isIntervalOrLogScale(axis.scale) + // NOTE: `scale.hasBreaks()` is not available at this moment. Check it later. + && retrieveAxisBreaksOption(axis.model, axis.type, true) == null + // NOTE: `scale.getTicks()` is not available at this moment. Check it later. + ) { + // Request `alignTicks`. + if ((axis.model as AxisBaseModel).get('alignTicks') + && (axis.model as AxisBaseModel).get('interval') == null + ) { + axisNeedsAlign.push(axis); + } + else { + // `alignTo` the last one that does not request `alignTicks` + // (This rule is retained for backward compat). + alignTo = axis; + } + } + }; + // If all axes has set alignTicks, pick the first one as alignTo. + // PENDING. Should we find the axis that both set interval, min, max and align to this one? + // PENDING. Should we allow specifying alignTo via ec option? + if (!alignTo) { + alignTo = axisNeedsAlign.pop(); + } + if (alignTo) { + each(axisNeedsAlign, function (axis) { + axis.__alignTo = alignTo; + }); + } +} + +/** + * This is just a defence code. They are unlikely to be actually `true`, + * since these cases have been addressed in `prepareAlignToInCoordSysCreate`. + * + * Can not be called BEFORE "nice" performed. + */ +function incapableOfAlignNeedFallback(targetAxis: Axis2D, alignTo: Axis2D): boolean { + return hasBreaks(targetAxis.scale) + || hasBreaks(alignTo.scale) + // Normally ticks length are more than 2 even when axis is blank. + // But still guard for corner cases and possible changes. + || alignTo.scale.getTicks().length < 2; +} + + function updateAxisTransform(axis: Axis2D, coordBase: number) { const axisExtent = axis.getExtent(); const axisExtentSum = axisExtent[0] + axisExtent[1]; @@ -803,7 +873,7 @@ function layOutGridByOuterBounds( if (labelInfoList) { for (let idx = 0; idx < labelInfoList.length; idx++) { const labelInfo = labelInfoList[idx]; - let proportion = axis.scale.normalize(getLabelInner(labelInfo.label).tickValue); + let proportion = axis.scale.normalize(getLabelInner(labelInfo.label).labelInfo.tick.value); proportion = xyIdx === 1 ? 1 - proportion : proportion; // xAxis use proportion on x, yAxis use proprotion on y, otherwise not. fillMarginOnOneDimension(labelInfo.rect, xyIdx, proportion); @@ -917,7 +987,7 @@ function createOrUpdateAxesView( function prepareOuterBounds( gridModel: GridModel, - rawRridRect: BoundingRect, + rawGridRect: BoundingRect, layoutRef: BoxLayoutReferenceResult, ): { outerBoundsRect: BoundingRect | NullUndefined @@ -927,7 +997,7 @@ function prepareOuterBounds( let outerBoundsRect: BoundingRect | NullUndefined; const optionOuterBoundsMode = gridModel.get('outerBoundsMode', true); if (optionOuterBoundsMode === 'same') { - outerBoundsRect = rawRridRect.clone(); + outerBoundsRect = rawGridRect.clone(); } else if (optionOuterBoundsMode == null || optionOuterBoundsMode === 'auto') { outerBoundsRect = getLayoutRect( @@ -957,10 +1027,10 @@ function prepareOuterBounds( const outerBoundsClamp = [ parsePositionSizeOption( - retrieve2(gridModel.get('outerBoundsClampWidth', true), OUTER_BOUNDS_CLAMP_DEFAULT[0]), rawRridRect.width + retrieve2(gridModel.get('outerBoundsClampWidth', true), OUTER_BOUNDS_CLAMP_DEFAULT[0]), rawGridRect.width ), parsePositionSizeOption( - retrieve2(gridModel.get('outerBoundsClampHeight', true), OUTER_BOUNDS_CLAMP_DEFAULT[1]), rawRridRect.height + retrieve2(gridModel.get('outerBoundsClampHeight', true), OUTER_BOUNDS_CLAMP_DEFAULT[1]), rawGridRect.height ) ]; diff --git a/src/coord/cartesian/GridModel.ts b/src/coord/cartesian/GridModel.ts index ba8341841b..654919df55 100644 --- a/src/coord/cartesian/GridModel.ts +++ b/src/coord/cartesian/GridModel.ts @@ -23,7 +23,7 @@ import { ComponentOption, BoxLayoutOptionMixin, ZRColor, ShadowOptionMixin, NullUndefined, ComponentOnCalendarOptionMixin, ComponentOnMatrixOptionMixin } from '../../util/types'; -import Grid from './Grid'; +import type Grid from './Grid'; import { CoordinateSystemHostModel } from '../CoordinateSystem'; import type GlobalModel from '../../model/Global'; import { getLayoutParams, mergeLayoutParam } from '../../util/layout'; @@ -34,6 +34,8 @@ import tokens from '../../visual/tokens'; export const OUTER_BOUNDS_DEFAULT = {left: 0, right: 0, top: 0, bottom: 0}; export const OUTER_BOUNDS_CLAMP_DEFAULT = ['25%', '25%']; +export const COORD_SYS_TYPE_CARTESIAN_2D = 'cartesian2d'; + export interface GridOption extends ComponentOption, ComponentOnCalendarOptionMixin, ComponentOnMatrixOptionMixin, BoxLayoutOptionMixin, ShadowOptionMixin { diff --git a/src/coord/cartesian/cartesianAxisHelper.ts b/src/coord/cartesian/cartesianAxisHelper.ts index 17f5cdfc41..a03cc2a198 100644 --- a/src/coord/cartesian/cartesianAxisHelper.ts +++ b/src/coord/cartesian/cartesianAxisHelper.ts @@ -31,6 +31,7 @@ import { isIntervalOrLogScale } from '../../scale/helper'; import type Cartesian2D from './Cartesian2D'; import ExtensionAPI from '../../core/ExtensionAPI'; import { NullUndefined } from 'zrender/src/core/types'; +import type Axis2D from './Axis2D'; interface CartesianAxisLayout { position: AxisBuilderCfg['position']; @@ -202,3 +203,9 @@ export function updateCartesianAxisViewCommonPartBuilder( axisBuilder.updateCfg(newRaw); } + +export type CartesianAxisHashKey = string; + +export function getCartesianAxisHashKey(axis: Axis2D): CartesianAxisHashKey { + return axis.dim + '_' + axis.index; +} diff --git a/src/coord/cartesian/defaultAxisExtentFromData.ts b/src/coord/cartesian/defaultAxisExtentFromData.ts index ca4bdcbacf..cccd0a45c1 100644 --- a/src/coord/cartesian/defaultAxisExtentFromData.ts +++ b/src/coord/cartesian/defaultAxisExtentFromData.ts @@ -17,254 +17,273 @@ * under the License. */ -import * as echarts from '../../core/echarts'; -import { createHashMap, each, HashMap, hasOwn, keys, map } from 'zrender/src/core/util'; -import SeriesModel from '../../model/Series'; -import { - isCartesian2DDeclaredSeries, findAxisModels, isCartesian2DInjectedAsDataCoordSys -} from './cartesianAxisHelper'; -import { getDataDimensionsOnAxis, unionAxisExtentFromData } from '../axisHelper'; -import { AxisBaseModel } from '../AxisBaseModel'; -import Axis from '../Axis'; -import GlobalModel from '../../model/Global'; -import { Dictionary } from '../../util/types'; -import { ScaleRawExtentInfo, ScaleRawExtentResult, ensureScaleRawExtentInfo } from '../scaleRawExtentInfo'; +// import * as echarts from '../../core/echarts'; +// import { createHashMap, each, HashMap, hasOwn, keys, map } from 'zrender/src/core/util'; +// import SeriesModel from '../../model/Series'; +// import { +// isCartesian2DDeclaredSeries, findAxisModels, isCartesian2DInjectedAsDataCoordSys +// } from './cartesianAxisHelper'; +// import { getDataDimensionsOnAxis } from '../axisHelper'; +// import { AxisBaseModel } from '../AxisBaseModel'; +// import type Axis from '../Axis'; +// import GlobalModel from '../../model/Global'; +// import { Dictionary } from '../../util/types'; +// import { +// AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM, ensureScaleRawExtentInfo, ScaleRawExtentInfo, ScaleRawExtentResult +// } from '../scaleRawExtentInfo'; +// import { initExtentForUnion, unionExtentFromNumber } from '../../util/model'; +/** + * @obsolete + * PENDING: + * - This file is not used anywhere currently. + * - This is a similar behavior to `dataZoom`, but historically supported separately. + * Can it be merged into `dataZoom`? + * - The impl need to be fixed, @see #15050 , and, + * - Remove side-effect. + * - Need to fix the case: + * series_a => + * x_m (category): dataExtent: [3,8] + * y_i: + * series_b => + * x_m (category): dataExtent: [4,6] + * y_j: + * series_c => + * x_m (category): dataExtent: [5,7] + * y_j: + * dataZoom control y_i, so series_a is excluded. + * So x_m.condExtent = [4,6] U [5,7] = [4,7] , and use it to call ensureScaleRawExtentInfo. + * (incorrect?, supposed to be [3,8]?) + * + * See test case `test/axis-filter-extent.html`. + * + * The responsibility of this processor: + * Enable category axis to use the specified `min`/`max` to shrink the extent of the orthogonal axis in + * Cartesian2D. That is, if some data item on a category axis is out of the range of `min`/`max`, the + * extent of the orthogonal axis will exclude the data items. + * A typical case is bar-racing, where bars are sorted dynamically and may only need to + * displayed part of the whole bars. + * + * IMPL_MEMO: + * - For each triple xAxis-yAxis-series, if either xAxis or yAxis is controlled by a dataZoom, + * the triple should be ignored in this processor. + * - Input: + * - Cartesian series data ("series approximate extent" has been prepared). + * - Axis original `ScaleRawExtentInfo` + * (the content comes from ec option and "series approximate extent"). + * - Modify(result): + * - `ScaleRawExtentInfo#min/max` of the determined "target axis". + * - "series approximate extent". + */ +// The priority is just after dataZoom processor. +// echarts.registerProcessor(echarts.PRIORITY.PROCESSOR.FILTER + 10, { -type AxisRecord = { - condExtent: number[]; - rawExtentInfo?: ScaleRawExtentInfo; - rawExtentResult?: ScaleRawExtentResult - tarExtent?: number[]; -}; +// getTargetSeries: function (ecModel) { +// const seriesModelMap = createHashMap(); +// ecModel.eachSeries(function (seriesModel: SeriesModel) { +// isCartesian2DDeclaredSeries(seriesModel) && seriesModelMap.set(seriesModel.uid, seriesModel); +// }); +// return seriesModelMap; +// }, -type SeriesRecord = { - seriesModel: SeriesModel; - xAxisModel: AxisBaseModel; - yAxisModel: AxisBaseModel; -}; +// overallReset: function (ecModel, api) { +// const seriesRecords = [] as SeriesRecord[]; +// const axisRecordMap = createHashMap(); -// A tricky: the priority is just after dataZoom processor. -// If dataZoom has fixed the min/max, this processor do not need to work. -// TODO: SELF REGISTERED. -echarts.registerProcessor(echarts.PRIORITY.PROCESSOR.FILTER + 10, { +// prepareDataExtentOnAxis(ecModel, axisRecordMap, seriesRecords); +// calculateFilteredExtent(axisRecordMap, seriesRecords); +// shrinkAxisExtent(axisRecordMap); +// } +// }); - getTargetSeries: function (ecModel) { - const seriesModelMap = createHashMap(); - ecModel.eachSeries(function (seriesModel: SeriesModel) { - isCartesian2DDeclaredSeries(seriesModel) && seriesModelMap.set(seriesModel.uid, seriesModel); - }); - return seriesModelMap; - }, +// type AxisRecord = { +// rawExtentInfo?: ScaleRawExtentInfo; +// rawExtentResult?: ScaleRawExtentResult; +// tarExtent?: number[]; +// }; - overallReset: function (ecModel, api) { - const seriesRecords = [] as SeriesRecord[]; - const axisRecordMap = createHashMap(); +// type SeriesRecord = { +// seriesModel: SeriesModel; +// xAxisModel: AxisBaseModel; +// yAxisModel: AxisBaseModel; +// }; - prepareDataExtentOnAxis(ecModel, axisRecordMap, seriesRecords); - calculateFilteredExtent(axisRecordMap, seriesRecords); - shrinkAxisExtent(axisRecordMap); - } -}); +// function prepareDataExtentOnAxis( +// ecModel: GlobalModel, +// axisRecordMap: HashMap, +// seriesRecords: SeriesRecord[] +// ): void { +// ecModel.eachSeries(function (seriesModel: SeriesModel) { +// // If pie (or other similar series) use cartesian2d, the logic below is +// // probably wrong, therefore skip it temporarily. +// // TODO: support union extent in this case. +// // e.g. make a fake seriesData by series.coord/series.center, and it can be +// // performed by data processing (such as, filter), and applied here. +// if (!isCartesian2DInjectedAsDataCoordSys(seriesModel)) { +// return; +// } -function prepareDataExtentOnAxis( - ecModel: GlobalModel, - axisRecordMap: HashMap, - seriesRecords: SeriesRecord[] -): void { - ecModel.eachSeries(function (seriesModel: SeriesModel) { - // If pie (or other similar series) use cartesian2d, the logic below is - // probably wrong, therefore skip it temporarily. - // TODO: support union extent in this case. - // e.g. make a fake seriesData by series.coord/series.center, and it can be - // performed by data processing (such as, filter), and applied here. - if (!isCartesian2DInjectedAsDataCoordSys(seriesModel)) { - return; - } +// const axesModelMap = findAxisModels(seriesModel); +// const xAxisModel = axesModelMap.xAxisModel; +// const yAxisModel = axesModelMap.yAxisModel; +// const xAxis = xAxisModel.axis; +// const yAxis = yAxisModel.axis; +// const xRawExtentInfo = ensureScaleRawExtentInfo(xAxis); +// const yRawExtentInfo = ensureScaleRawExtentInfo(yAxis); - const axesModelMap = findAxisModels(seriesModel); - const xAxisModel = axesModelMap.xAxisModel; - const yAxisModel = axesModelMap.yAxisModel; - const xAxis = xAxisModel.axis; - const yAxis = yAxisModel.axis; - const xRawExtentInfo = xAxis.scale.rawExtentInfo; - const yRawExtentInfo = yAxis.scale.rawExtentInfo; - const data = seriesModel.getData(); +// // If either axis controlled by other filter like "dataZoom", +// // use the rule of dataZoom rather than adopting the rules here. +// if ( +// (xRawExtentInfo && xRawExtentInfo.from === AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM) +// || (yRawExtentInfo && yRawExtentInfo.from === AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM) +// ) { +// return; +// } - // If either axis controlled by other filter like "dataZoom", - // use the rule of dataZoom rather than adopting the rules here. - if ( - (xRawExtentInfo && xRawExtentInfo.frozen) - || (yRawExtentInfo && yRawExtentInfo.frozen) - ) { - return; - } +// seriesRecords.push({ +// seriesModel: seriesModel, +// xAxisModel: xAxisModel, +// yAxisModel: yAxisModel +// }); +// }); +// } - seriesRecords.push({ - seriesModel: seriesModel, - xAxisModel: xAxisModel, - yAxisModel: yAxisModel - }); +// function calculateFilteredExtent( +// axisRecordMap: HashMap, +// seriesRecords: SeriesRecord[] +// ) { +// each(seriesRecords, function (seriesRecord) { +// const xAxisModel = seriesRecord.xAxisModel; +// const yAxisModel = seriesRecord.yAxisModel; +// const xAxis = xAxisModel.axis; +// const yAxis = yAxisModel.axis; +// const xAxisRecord = prepareAxisRecord(axisRecordMap, xAxisModel); +// const yAxisRecord = prepareAxisRecord(axisRecordMap, yAxisModel); +// xAxisRecord.rawExtentInfo = ensureScaleRawExtentInfo(xAxis); +// yAxisRecord.rawExtentInfo = ensureScaleRawExtentInfo(yAxis); +// xAxisRecord.rawExtentResult = xAxisRecord.rawExtentInfo.calculate(); +// yAxisRecord.rawExtentResult = yAxisRecord.rawExtentInfo.calculate(); - // FIXME: this logic needs to be consistent with - // `coord/cartesian/Grid.ts#_updateScale`. - // It's not good to implement one logic in multiple places. - unionAxisExtentFromData(prepareAxisRecord(axisRecordMap, xAxisModel).condExtent, data, xAxis.dim); - unionAxisExtentFromData(prepareAxisRecord(axisRecordMap, yAxisModel).condExtent, data, yAxis.dim); - }); -} +// const data = seriesRecord.seriesModel.getData(); +// // For duplication removal. +// // key: series data dimension corresponding to the condition axis. +// const condDimMap: Dictionary = {}; +// // key: series data dimension corresponding to the target axis. +// const tarDimMap: Dictionary = {}; +// let condAxis: Axis; +// let tarAxisRecord: AxisRecord; -function calculateFilteredExtent( - axisRecordMap: HashMap, - seriesRecords: SeriesRecord[] -) { - each(seriesRecords, function (seriesRecord) { - const xAxisModel = seriesRecord.xAxisModel; - const yAxisModel = seriesRecord.yAxisModel; - const xAxis = xAxisModel.axis; - const yAxis = yAxisModel.axis; - const xAxisRecord = prepareAxisRecord(axisRecordMap, xAxisModel); - const yAxisRecord = prepareAxisRecord(axisRecordMap, yAxisModel); - xAxisRecord.rawExtentInfo = ensureScaleRawExtentInfo( - xAxis.scale, xAxisModel, xAxisRecord.condExtent - ); - yAxisRecord.rawExtentInfo = ensureScaleRawExtentInfo( - yAxis.scale, yAxisModel, yAxisRecord.condExtent - ); - xAxisRecord.rawExtentResult = xAxisRecord.rawExtentInfo.calculate(); - yAxisRecord.rawExtentResult = yAxisRecord.rawExtentInfo.calculate(); +// function addCondition(axis: Axis, axisRecord: AxisRecord) { +// // But for simplicity and safety and performance, we only adopt this +// // feature on category axis at present. +// const rawExtentResult = axisRecord.rawExtentResult; +// if (axis.type === 'category' +// && (rawExtentResult.dataMinMax[0] < rawExtentResult.resultMinMax[0] +// || rawExtentResult.resultMinMax[1] < rawExtentResult.dataMinMax[1] +// ) +// ) { +// each(getDataDimensionsOnAxis(data, axis.dim), function (dataDim) { +// if (!hasOwn(condDimMap, dataDim)) { +// condDimMap[dataDim] = true; +// condAxis = axis; +// } +// }); +// } +// } +// function addTarget(axis: Axis, axisRecord: AxisRecord) { +// const rawExtentResult = axisRecord.rawExtentResult; +// const fixMinMax = rawExtentResult.fixMinMax; +// if (axis.type !== 'category' +// && (!fixMinMax[0] || !fixMinMax[1]) +// ) { +// each(getDataDimensionsOnAxis(data, axis.dim), function (dataDim) { +// if (!hasOwn(condDimMap, dataDim) && !hasOwn(tarDimMap, dataDim)) { +// tarDimMap[dataDim] = true; +// tarAxisRecord = axisRecord; +// } +// }); +// } +// } - // If the "xAxis" is set `min`/`max`, some data items might be out of the cartesian. - // then the "yAxis" may needs to calculate extent only based on the data items inside - // the cartesian (similar to what "dataZoom" did). - // A typical case is bar-racing, where bars ara sort dynamically and may only need to - // displayed part of the whole bars. +// addCondition(xAxis, xAxisRecord); +// addCondition(yAxis, yAxisRecord); +// addTarget(xAxis, xAxisRecord); +// addTarget(yAxis, yAxisRecord); - const data = seriesRecord.seriesModel.getData(); - // For duplication removal. - const condDimMap: Dictionary = {}; - const tarDimMap: Dictionary = {}; - let condAxis: Axis; - let tarAxisRecord: AxisRecord; +// const condDims = keys(condDimMap); +// const tarDims = keys(tarDimMap); +// const tarDimExtents = map(tarDims, function () { +// return initExtentForUnion(); +// }); - function addCondition(axis: Axis, axisRecord: AxisRecord) { - // But for simplicity and safety and performance, we only adopt this - // feature on category axis at present. - const condExtent = axisRecord.condExtent; - const rawExtentResult = axisRecord.rawExtentResult; - if (axis.type === 'category' - && (condExtent[0] < rawExtentResult.min || rawExtentResult.max < condExtent[1]) - ) { - each(getDataDimensionsOnAxis(data, axis.dim), function (dataDim) { - if (!hasOwn(condDimMap, dataDim)) { - condDimMap[dataDim] = true; - condAxis = axis; - } - }); - } - } - function addTarget(axis: Axis, axisRecord: AxisRecord) { - const rawExtentResult = axisRecord.rawExtentResult; - if (axis.type !== 'category' - && (!rawExtentResult.minFixed || !rawExtentResult.maxFixed) - ) { - each(getDataDimensionsOnAxis(data, axis.dim), function (dataDim) { - if (!hasOwn(condDimMap, dataDim) && !hasOwn(tarDimMap, dataDim)) { - tarDimMap[dataDim] = true; - tarAxisRecord = axisRecord; - } - }); - } - } +// const condDimsLen = condDims.length; +// const tarDimsLen = tarDims.length; - addCondition(xAxis, xAxisRecord); - addCondition(yAxis, yAxisRecord); - addTarget(xAxis, xAxisRecord); - addTarget(yAxis, yAxisRecord); +// if (!condDimsLen || !tarDimsLen) { +// return; +// } - const condDims = keys(condDimMap); - const tarDims = keys(tarDimMap); - const tarDimExtents = map(tarDims, function () { - return initExtent(); - }); +// const singleCondDim = condDimsLen === 1 ? condDims[0] : null; +// const singleTarDim = tarDimsLen === 1 ? tarDims[0] : null; +// const dataLen = data.count(); - const condDimsLen = condDims.length; - const tarDimsLen = tarDims.length; +// // Time consuming, because this is a "block task". +// // Simple optimization for the vast majority of cases. +// if (singleCondDim && singleTarDim) { +// for (let dataIdx = 0; dataIdx < dataLen; dataIdx++) { +// const condVal = data.get(singleCondDim, dataIdx) as number; +// if (condAxis.scale.contain(condVal)) { +// unionExtentFromNumber(tarDimExtents[0], data.get(singleTarDim, dataIdx) as number); +// } +// } +// } +// else { +// for (let dataIdx = 0; dataIdx < dataLen; dataIdx++) { +// for (let j = 0; j < condDimsLen; j++) { +// const condVal = data.get(condDims[j], dataIdx) as number; +// if (condAxis.scale.contain(condVal)) { +// for (let k = 0; k < tarDimsLen; k++) { +// unionExtentFromNumber(tarDimExtents[k], data.get(tarDims[k], dataIdx) as number); +// } +// // Any one dim is in range means satisfied. +// break; +// } +// } +// } +// } - if (!condDimsLen || !tarDimsLen) { - return; - } +// each(tarDimExtents, function (tarDimExtent, i) { +// // FIXME: if there has been approximateExtent set? +// data.setApproximateExtent(tarDimExtent as [number, number], tarDims[i]); +// const tarAxisExtent = tarAxisRecord.tarExtent = tarAxisRecord.tarExtent || initExtentForUnion(); +// unionExtentFromNumber(tarAxisExtent, tarDimExtent[0]); +// unionExtentFromNumber(tarAxisExtent, tarDimExtent[1]); +// }); +// }); +// } - const singleCondDim = condDimsLen === 1 ? condDims[0] : null; - const singleTarDim = tarDimsLen === 1 ? tarDims[0] : null; - const dataLen = data.count(); +// function shrinkAxisExtent(axisRecordMap: HashMap) { +// axisRecordMap.each(function (axisRecord) { +// const tarAxisExtent = axisRecord.tarExtent; +// if (tarAxisExtent) { +// const rawExtentResult = axisRecord.rawExtentResult; +// const fixMinMax = rawExtentResult.fixMinMax; +// // const rawExtentInfo = axisRecord.rawExtentInfo; +// // Shrink the original extent. +// if (!fixMinMax[0] && tarAxisExtent[0] > rawExtentResult.resultMinMax[0]) { +// // rawExtentInfo.modifyDataMinMax('min', tarAxisExtent[0]); +// } +// if (!fixMinMax[1] && tarAxisExtent[1] < rawExtentResult.resultMinMax[1]) { +// // rawExtentInfo.modifyDataMinMax('max', tarAxisExtent[1]); +// } +// } +// }); +// } - // Time consuming, because this is a "block task". - // Simple optimization for the vast majority of cases. - if (singleCondDim && singleTarDim) { - for (let dataIdx = 0; dataIdx < dataLen; dataIdx++) { - const condVal = data.get(singleCondDim, dataIdx) as number; - if (condAxis.scale.isInExtentRange(condVal)) { - unionExtent(tarDimExtents[0], data.get(singleTarDim, dataIdx) as number); - } - } - } - else { - for (let dataIdx = 0; dataIdx < dataLen; dataIdx++) { - for (let j = 0; j < condDimsLen; j++) { - const condVal = data.get(condDims[j], dataIdx) as number; - if (condAxis.scale.isInExtentRange(condVal)) { - for (let k = 0; k < tarDimsLen; k++) { - unionExtent(tarDimExtents[k], data.get(tarDims[k], dataIdx) as number); - } - // Any one dim is in range means satisfied. - break; - } - } - } - } - - each(tarDimExtents, function (tarDimExtent, i) { - const dim = tarDims[i]; - // FIXME: if there has been approximateExtent set? - data.setApproximateExtent(tarDimExtent as [number, number], dim); - const tarAxisExtent = tarAxisRecord.tarExtent = tarAxisRecord.tarExtent || initExtent(); - unionExtent(tarAxisExtent, tarDimExtent[0]); - unionExtent(tarAxisExtent, tarDimExtent[1]); - }); - }); -} - -function shrinkAxisExtent(axisRecordMap: HashMap) { - axisRecordMap.each(function (axisRecord) { - const tarAxisExtent = axisRecord.tarExtent; - if (tarAxisExtent) { - const rawExtentResult = axisRecord.rawExtentResult; - const rawExtentInfo = axisRecord.rawExtentInfo; - // Shink the original extent. - if (!rawExtentResult.minFixed && tarAxisExtent[0] > rawExtentResult.min) { - rawExtentInfo.modifyDataMinMax('min', tarAxisExtent[0]); - } - if (!rawExtentResult.maxFixed && tarAxisExtent[1] < rawExtentResult.max) { - rawExtentInfo.modifyDataMinMax('max', tarAxisExtent[1]); - } - } - }); -} - -function prepareAxisRecord( - axisRecordMap: HashMap, - axisModel: AxisBaseModel -): AxisRecord { - return axisRecordMap.get(axisModel.uid) - || axisRecordMap.set(axisModel.uid, { condExtent: initExtent() }); -} - -function initExtent() { - return [Infinity, -Infinity]; -} - -function unionExtent(extent: number[], val: number) { - val < extent[0] && (extent[0] = val); - val > extent[1] && (extent[1] = val); -} +// function prepareAxisRecord( +// axisRecordMap: HashMap, +// axisModel: AxisBaseModel +// ): AxisRecord { +// return axisRecordMap.get(axisModel.uid) +// || axisRecordMap.set(axisModel.uid, {}); +// } diff --git a/src/coord/cartesian/prepareCustom.ts b/src/coord/cartesian/prepareCustom.ts index 1244be90e4..5ce1ae5594 100644 --- a/src/coord/cartesian/prepareCustom.ts +++ b/src/coord/cartesian/prepareCustom.ts @@ -19,6 +19,7 @@ import * as zrUtil from 'zrender/src/core/util'; import Cartesian2D from './Cartesian2D'; +import { calcBandWidth } from '../axisBand'; function dataToCoordSize(this: Cartesian2D, dataSize: number[], dataItem: number[]): number[] { // dataItem is necessary in log axis. @@ -28,7 +29,7 @@ function dataToCoordSize(this: Cartesian2D, dataSize: number[], dataItem: number const val = dataItem[dimIdx]; const halfSize = dataSize[dimIdx] / 2; return axis.type === 'category' - ? axis.getBandWidth() + ? calcBandWidth(axis).w : Math.abs(axis.dataToCoord(val - halfSize) - axis.dataToCoord(val + halfSize)); }, this); } diff --git a/src/coord/geo/geoCreator.ts b/src/coord/geo/geoCreator.ts index d41f72b545..fdf77947c6 100644 --- a/src/coord/geo/geoCreator.ts +++ b/src/coord/geo/geoCreator.ts @@ -96,8 +96,8 @@ function resizeGeo(this: Geo, geoModel: ComponentModel */ -import * as zrUtil from 'zrender/src/core/util'; +import {each, createHashMap, clone} from 'zrender/src/core/util'; import * as matrix from 'zrender/src/core/matrix'; import * as layoutUtil from '../../util/layout'; import * as axisHelper from '../../coord/axisHelper'; import ParallelAxis from './ParallelAxis'; import * as graphic from '../../util/graphic'; -import * as numberUtil from '../../util/number'; +import {mathCeil, mathFloor, mathMax, mathMin, mathPI, round} from '../../util/number'; import sliderMove from '../../component/helper/sliderMove'; -import ParallelModel, { ParallelLayoutDirection } from './ParallelModel'; +import ParallelModel, { COORD_SYS_TYPE_PARALLEL, ParallelLayoutDirection } from './ParallelModel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import { Dictionary, DimensionName, ScaleDataValue } from '../../util/types'; @@ -40,14 +40,11 @@ import ParallelAxisModel, { ParallelActiveState } from './AxisModel'; import SeriesData from '../../data/SeriesData'; import { AxisBaseModel } from '../AxisBaseModel'; import { CategoryAxisBaseOption } from '../axisCommonTypes'; +import { scaleCalcNice } from '../axisNiceTicks'; +import { + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoCreate +} from '../scaleRawExtentInfo'; -const each = zrUtil.each; -const mathMin = Math.min; -const mathMax = Math.max; -const mathFloor = Math.floor; -const mathCeil = Math.ceil; -const round = numberUtil.round; -const PI = Math.PI; interface ParallelCoordinateSystemLayoutInfo { layout: ParallelLayoutDirection; @@ -80,12 +77,12 @@ type SlidedAxisExpandBehavior = 'none' | 'slide' | 'jump'; class Parallel implements CoordinateSystemMaster, CoordinateSystem { - readonly type = 'parallel'; + readonly type = COORD_SYS_TYPE_PARALLEL; /** * key: dimension */ - private _axesMap = zrUtil.createHashMap(); + private _axesMap = createHashMap(); /** * key: dimension @@ -124,11 +121,12 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { const axisIndex = parallelAxisIndex[idx]; const axisModel = ecModel.getComponent('parallelAxis', axisIndex) as ParallelAxisModel; + const axisType = axisHelper.determineAxisType(axisModel); const axis = this._axesMap.set(dim, new ParallelAxis( dim, - axisHelper.createScaleByModel(axisModel), + axisHelper.createScaleByModel(axisModel, axisType, false), [0, 0], - axisModel.get('type'), + axisType, axisIndex )); @@ -149,7 +147,11 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { * Update axis scale after data processed */ update(ecModel: GlobalModel, api: ExtensionAPI): void { - this._updateAxesFromSeries(this._model, ecModel); + each(this.dimensions, function (dim) { + const axis = this._axesMap.get(dim); + scaleRawExtentInfoCreate(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleCalcNice(axis); + }, this); } containPoint(point: number[]): boolean { @@ -170,31 +172,6 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { return this._model; } - /** - * Update properties from series - */ - private _updateAxesFromSeries(parallelModel: ParallelModel, ecModel: GlobalModel): void { - ecModel.eachSeries(function (seriesModel) { - - if (!parallelModel.contains(seriesModel, ecModel)) { - return; - } - - const data = seriesModel.getData(); - - each(this.dimensions, function (dim) { - const axis = this._axesMap.get(dim); - axis.scale.unionExtentFromData(data, data.mapDimension(dim)); - }, this); - }, this); - - // do after all series processed - each(this.dimensions, function (dim) { - const axis = this._axesMap.get(dim); - axisHelper.niceScaleExtent(axis.scale, axis.model); - }, this); - } - /** * Resize the parallel coordinate system. */ @@ -304,7 +281,7 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { } }; const rotationTable = { - horizontal: PI / 2, + horizontal: mathPI / 2, vertical: 0 }; @@ -373,7 +350,7 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { const dataDimensions = [] as DimensionName[]; const axisModels = [] as ParallelAxisModel[]; - zrUtil.each(dimensions, function (axisDim) { + each(dimensions, function (axisDim) { dataDimensions.push(data.mapDimension(axisDim)); axisModels.push(axesMap.get(axisDim).model); }); @@ -433,7 +410,7 @@ class Parallel implements CoordinateSystemMaster, CoordinateSystem { * Get axis layout. */ getAxisLayout(dim: DimensionName): ParallelAxisLayoutInfo { - return zrUtil.clone(this._axesLayout[dim]); + return clone(this._axesLayout[dim]); } /** diff --git a/src/coord/parallel/ParallelModel.ts b/src/coord/parallel/ParallelModel.ts index 4ca648bb39..080e9d9fb7 100644 --- a/src/coord/parallel/ParallelModel.ts +++ b/src/coord/parallel/ParallelModel.ts @@ -20,7 +20,7 @@ import * as zrUtil from 'zrender/src/core/util'; import ComponentModel from '../../model/Component'; -import Parallel from './Parallel'; +import type Parallel from './Parallel'; import { DimensionName, ComponentOption, BoxLayoutOptionMixin, ComponentOnCalendarOptionMixin, ComponentOnMatrixOptionMixin @@ -31,6 +31,9 @@ import ParallelSeriesModel from '../../chart/parallel/ParallelSeries'; import SeriesModel from '../../model/Series'; +export const COORD_SYS_TYPE_PARALLEL = 'parallel'; +export const COMPONENT_TYPE_PARALLEL = COORD_SYS_TYPE_PARALLEL; + export type ParallelLayoutDirection = 'horizontal' | 'vertical'; export interface ParallelCoordinateSystemOption extends @@ -65,7 +68,7 @@ export interface ParallelCoordinateSystemOption extends class ParallelModel extends ComponentModel { - static type = 'parallel'; + static type = COMPONENT_TYPE_PARALLEL; readonly type = ParallelModel.type; static dependencies = ['parallelAxis']; diff --git a/src/coord/parallel/parallelCreator.ts b/src/coord/parallel/parallelCreator.ts index 35b3ae11d2..a9eaac5e8d 100644 --- a/src/coord/parallel/parallelCreator.ts +++ b/src/coord/parallel/parallelCreator.ts @@ -19,21 +19,24 @@ /** - * Parallel coordinate system creater. + * Parallel coordinate system creator. */ import Parallel from './Parallel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; -import ParallelModel from './ParallelModel'; +import ParallelModel, { COMPONENT_TYPE_PARALLEL, COORD_SYS_TYPE_PARALLEL } from './ParallelModel'; import { CoordinateSystemMaster } from '../CoordinateSystem'; import ParallelSeriesModel from '../../chart/parallel/ParallelSeries'; import { SINGLE_REFERRING } from '../../util/model'; +import { each } from 'zrender/src/core/util'; +import { associateSeriesWithAxis } from '../axisStatistics'; + function createParallelCoordSys(ecModel: GlobalModel, api: ExtensionAPI): CoordinateSystemMaster[] { const coordSysList: CoordinateSystemMaster[] = []; - ecModel.eachComponent('parallel', function (parallelModel: ParallelModel, idx: number) { + ecModel.eachComponent(COMPONENT_TYPE_PARALLEL, function (parallelModel: ParallelModel, idx: number) { const coordSys = new Parallel(parallelModel, ecModel, api); coordSys.name = 'parallel_' + idx; @@ -47,16 +50,22 @@ function createParallelCoordSys(ecModel: GlobalModel, api: ExtensionAPI): Coordi // Inject the coordinateSystems into seriesModel ecModel.eachSeries(function (seriesModel) { - if ((seriesModel as ParallelSeriesModel).get('coordinateSystem') === 'parallel') { + if ((seriesModel as ParallelSeriesModel).get('coordinateSystem') === COORD_SYS_TYPE_PARALLEL) { const parallelModel = seriesModel.getReferringComponents( - 'parallel', SINGLE_REFERRING + COMPONENT_TYPE_PARALLEL, SINGLE_REFERRING ).models[0] as ParallelModel; - seriesModel.coordinateSystem = parallelModel.coordinateSystem; + const parallel = seriesModel.coordinateSystem = parallelModel.coordinateSystem; + if (parallel) { + each(parallel.dimensions, function (dim) { + associateSeriesWithAxis(parallel.getAxis(dim), seriesModel, COORD_SYS_TYPE_PARALLEL); + }); + } } }); return coordSysList; } + const parallelCoordSysCreator = { create: createParallelCoordSys }; diff --git a/src/coord/polar/Polar.ts b/src/coord/polar/Polar.ts index 9667008298..76a49bfc4f 100644 --- a/src/coord/polar/Polar.ts +++ b/src/coord/polar/Polar.ts @@ -19,7 +19,7 @@ import RadiusAxis from './RadiusAxis'; import AngleAxis from './AngleAxis'; -import PolarModel from './PolarModel'; +import PolarModel, { COORD_SYS_TYPE_POLAR } from './PolarModel'; import { CoordinateSystem, CoordinateSystemMaster, CoordinateSystemClipArea } from '../CoordinateSystem'; import GlobalModel from '../../model/Global'; import { ParsedModelFinder, ParsedModelFinderKnown } from '../../util/model'; @@ -37,7 +37,7 @@ class Polar implements CoordinateSystem, CoordinateSystemMaster { readonly dimensions = polarDimensions; - readonly type = 'polar'; + readonly type = COORD_SYS_TYPE_POLAR; /** * x of polar center @@ -117,7 +117,6 @@ class Polar implements CoordinateSystem, CoordinateSystemMaster { /** * Base axis will be used on stacking. - * */ getBaseAxis() { return this.getAxesByScale('ordinal')[0] @@ -140,6 +139,7 @@ class Polar implements CoordinateSystem, CoordinateSystemMaster { */ dataToPoint(data: ScaleDataValue[], clamp?: boolean, out?: number[]) { return this.coordToPoint([ + // Must be the same order as polarDimensions this._radiusAxis.dataToRadius(data[0], clamp), this._angleAxis.dataToAngle(data[1], clamp) ], out); @@ -151,6 +151,7 @@ class Polar implements CoordinateSystem, CoordinateSystemMaster { pointToData(point: number[], clamp?: boolean, out?: number[]) { out = out || []; const coord = this.pointToCoord(point); + // Must be the same order as polarDimensions out[0] = this._radiusAxis.radiusToData(coord[0], clamp); out[1] = this._angleAxis.angleToData(coord[1], clamp); return out; diff --git a/src/coord/polar/PolarModel.ts b/src/coord/polar/PolarModel.ts index 60133aaa7c..96a6fb174c 100644 --- a/src/coord/polar/PolarModel.ts +++ b/src/coord/polar/PolarModel.ts @@ -22,7 +22,7 @@ import { ComponentOnMatrixOptionMixin } from '../../util/types'; import ComponentModel from '../../model/Component'; -import Polar from './Polar'; +import type Polar from './Polar'; import { AngleAxisModel, RadiusAxisModel } from './AxisModel'; export interface PolarOption extends @@ -32,8 +32,11 @@ export interface PolarOption extends mainType?: 'polar'; } +export const COORD_SYS_TYPE_POLAR = 'polar'; +export const COMPONENT_TYPE_POLAR = COORD_SYS_TYPE_POLAR; + class PolarModel extends ComponentModel { - static type = 'polar' as const; + static type = COORD_SYS_TYPE_POLAR; type = PolarModel.type; static dependencies = ['radiusAxis', 'angleAxis']; diff --git a/src/coord/polar/polarCreator.ts b/src/coord/polar/polarCreator.ts index 224de9c3b0..f1f3a45e2d 100644 --- a/src/coord/polar/polarCreator.ts +++ b/src/coord/polar/polarCreator.ts @@ -24,11 +24,10 @@ import Polar, { polarDimensions } from './Polar'; import {parsePercent} from '../../util/number'; import { createScaleByModel, - niceScaleExtent, - getDataDimensionsOnAxis + determineAxisType, } from '../../coord/axisHelper'; -import PolarModel from './PolarModel'; +import PolarModel, { COMPONENT_TYPE_POLAR, COORD_SYS_TYPE_POLAR } from './PolarModel'; import ExtensionAPI from '../../core/ExtensionAPI'; import GlobalModel from '../../model/Global'; import OrdinalScale from '../../scale/Ordinal'; @@ -41,6 +40,11 @@ import { SINGLE_REFERRING } from '../../util/model'; import { AxisBaseModel } from '../AxisBaseModel'; import { CategoryAxisBaseOption } from '../axisCommonTypes'; import { createBoxLayoutReference } from '../../util/layout'; +import { scaleCalcNice } from '../axisNiceTicks'; +import { + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoCreate +} from '../scaleRawExtentInfo'; +import { associateSeriesWithAxis } from '../axisStatistics'; /** * Resize method bound to the polar @@ -81,24 +85,12 @@ function updatePolarScale(this: Polar, ecModel: GlobalModel, api: ExtensionAPI) const polar = this; const angleAxis = polar.getAngleAxis(); const radiusAxis = polar.getRadiusAxis(); - // Reset scale - angleAxis.scale.setExtent(Infinity, -Infinity); - radiusAxis.scale.setExtent(Infinity, -Infinity); - - ecModel.eachSeries(function (seriesModel) { - if (seriesModel.coordinateSystem === polar) { - const data = seriesModel.getData(); - zrUtil.each(getDataDimensionsOnAxis(data, 'radius'), function (dim) { - radiusAxis.scale.unionExtentFromData(data, dim); - }); - zrUtil.each(getDataDimensionsOnAxis(data, 'angle'), function (dim) { - angleAxis.scale.unionExtentFromData(data, dim); - }); - } - }); - - niceScaleExtent(angleAxis.scale, angleAxis.model); - niceScaleExtent(radiusAxis.scale, radiusAxis.model); + + scaleRawExtentInfoCreate(ecModel, angleAxis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleRawExtentInfoCreate(ecModel, radiusAxis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + + scaleCalcNice(angleAxis); + scaleCalcNice(radiusAxis); // Fix extent of category angle axis if (angleAxis.type === 'category' && !angleAxis.onBand) { @@ -116,8 +108,8 @@ function isAngleAxisModel(axisModel: AngleAxisModel | PolarAxisModel): axisModel * Set common axis properties */ function setAxis(axis: RadiusAxis | AngleAxis, axisModel: PolarAxisModel) { - axis.type = axisModel.get('type'); - axis.scale = createScaleByModel(axisModel); + axis.type = determineAxisType(axisModel); + axis.scale = createScaleByModel(axisModel, axis.type, false); axis.onBand = (axisModel as AxisBaseModel).get('boundaryGap') && axis.type === 'category'; axis.inverse = axisModel.get('inverse'); @@ -134,14 +126,13 @@ function setAxis(axis: RadiusAxis | AngleAxis, axisModel: PolarAxisModel) { axis.model = axisModel as AngleAxisModel | RadiusAxisModel; } - const polarCreator = { dimensions: polarDimensions, create: function (ecModel: GlobalModel, api: ExtensionAPI) { const polarList: Polar[] = []; - ecModel.eachComponent('polar', function (polarModel: PolarModel, idx: number) { + ecModel.eachComponent(COMPONENT_TYPE_POLAR, function (polarModel: PolarModel, idx: number) { const polar = new Polar(idx + ''); // Inject resize and update method polar.update = updatePolarScale; @@ -167,9 +158,9 @@ const polarCreator = { polarIndex?: number polarId?: string }>) { - if (seriesModel.get('coordinateSystem') === 'polar') { + if (seriesModel.get('coordinateSystem') === COORD_SYS_TYPE_POLAR) { const polarModel = seriesModel.getReferringComponents( - 'polar', SINGLE_REFERRING + COMPONENT_TYPE_POLAR, SINGLE_REFERRING ).models[0] as PolarModel; if (__DEV__) { @@ -183,7 +174,11 @@ const polarCreator = { ); } } - seriesModel.coordinateSystem = polarModel.coordinateSystem; + const polar = seriesModel.coordinateSystem = polarModel.coordinateSystem; + if (polar) { + associateSeriesWithAxis(polar.getRadiusAxis(), seriesModel, COORD_SYS_TYPE_POLAR); + associateSeriesWithAxis(polar.getAngleAxis(), seriesModel, COORD_SYS_TYPE_POLAR); + } } }); diff --git a/src/coord/polar/prepareCustom.ts b/src/coord/polar/prepareCustom.ts index ebbd99783b..7349ef6532 100644 --- a/src/coord/polar/prepareCustom.ts +++ b/src/coord/polar/prepareCustom.ts @@ -20,6 +20,7 @@ import * as zrUtil from 'zrender/src/core/util'; import Polar from './Polar'; import RadiusAxis from './RadiusAxis'; +import { calcBandWidth } from '../axisBand'; // import AngleAxis from './AngleAxis'; function dataToCoordSize(this: Polar, dataSize: number[], dataItem: number[]) { @@ -33,7 +34,7 @@ function dataToCoordSize(this: Polar, dataSize: number[], dataItem: number[]) { const halfSize = dataSize[dimIdx] / 2; let result = axis.type === 'category' - ? axis.getBandWidth() + ? calcBandWidth(axis).w : Math.abs(axis.dataToCoord(val - halfSize) - axis.dataToCoord(val + halfSize)); if (dim === 'Angle') { diff --git a/src/coord/radar/Radar.ts b/src/coord/radar/Radar.ts index c0fb4df295..0710772b34 100644 --- a/src/coord/radar/Radar.ts +++ b/src/coord/radar/Radar.ts @@ -23,19 +23,26 @@ import IndicatorAxis from './IndicatorAxis'; import IntervalScale from '../../scale/Interval'; import * as numberUtil from '../../util/number'; import { CoordinateSystemMaster, CoordinateSystem } from '../CoordinateSystem'; -import RadarModel from './RadarModel'; +import RadarModel, { + COMPONENT_TYPE_RADAR, COORD_SYS_TYPE_RADAR, RADAR_DEFAULT_SPLIT_NUMBER, SERIES_TYPE_RADAR +} from './RadarModel'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import { ScaleDataValue } from '../../util/types'; import { ParsedModelFinder } from '../../util/model'; import { map, each, isString, isNumber } from 'zrender/src/core/util'; -import { alignScaleTicks } from '../axisAlignTicks'; +import { scaleCalcAlign } from '../axisAlignTicks'; import { createBoxLayoutReference } from '../../util/layout'; +import { + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoCreate +} from '../scaleRawExtentInfo'; +import { ensureValidSplitNumber } from '../../scale/helper'; +import { associateSeriesWithAxis } from '../axisStatistics'; class Radar implements CoordinateSystem, CoordinateSystemMaster { - readonly type: 'radar'; + readonly type = COORD_SYS_TYPE_RADAR; /** * * Radar dimensions @@ -155,33 +162,15 @@ class Radar implements CoordinateSystem, CoordinateSystemMaster { update(ecModel: GlobalModel, api: ExtensionAPI) { const indicatorAxes = this._indicatorAxes; const radarModel = this._model; - each(indicatorAxes, function (indicatorAxis) { - indicatorAxis.scale.setExtent(Infinity, -Infinity); - }); - ecModel.eachSeriesByType('radar', function (radarSeries, idx) { - if (radarSeries.get('coordinateSystem') !== 'radar' - // @ts-ignore - || ecModel.getComponent('radar', radarSeries.get('radarIndex')) !== radarModel - ) { - return; - } - const data = radarSeries.getData(); - each(indicatorAxes, function (indicatorAxis) { - indicatorAxis.scale.unionExtentFromData(data, data.mapDimension(indicatorAxis.dim)); - }); - }, this); - const splitNumber = radarModel.get('splitNumber'); + const splitNumber = ensureValidSplitNumber(radarModel.get('splitNumber'), RADAR_DEFAULT_SPLIT_NUMBER); const dummyScale = new IntervalScale(); dummyScale.setExtent(0, splitNumber); - dummyScale.setInterval(1); + dummyScale.setConfig({interval: 1}); // Force all the axis fixing the maxSplitNumber. - each(indicatorAxes, function (indicatorAxis, idx) { - alignScaleTicks( - indicatorAxis.scale as IntervalScale, - indicatorAxis.model, - dummyScale - ); + each(indicatorAxes, function (indicatorAxis) { + scaleRawExtentInfoCreate(ecModel, indicatorAxis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleCalcAlign(indicatorAxis, dummyScale); }); } @@ -204,16 +193,21 @@ class Radar implements CoordinateSystem, CoordinateSystemMaster { static create(ecModel: GlobalModel, api: ExtensionAPI) { const radarList: Radar[] = []; - ecModel.eachComponent('radar', function (radarModel: RadarModel) { + ecModel.eachComponent(COMPONENT_TYPE_RADAR, function (radarModel: RadarModel) { const radar = new Radar(radarModel, ecModel, api); radarList.push(radar); radarModel.coordinateSystem = radar; }); - ecModel.eachSeriesByType('radar', function (radarSeries) { - if (radarSeries.get('coordinateSystem') === 'radar') { + ecModel.eachSeriesByType(SERIES_TYPE_RADAR, function (radarSeries) { + if (radarSeries.get('coordinateSystem') === COORD_SYS_TYPE_RADAR) { // Inject coordinate system // @ts-ignore - radarSeries.coordinateSystem = radarList[radarSeries.get('radarIndex') || 0]; + const radar = radarSeries.coordinateSystem = radarList[radarSeries.get('radarIndex') || 0]; + if (radar) { + each(radar.getIndicatorAxes(), function (indicatorAxis) { + associateSeriesWithAxis(indicatorAxis, radarSeries, COORD_SYS_TYPE_RADAR); + }); + } } }); return radarList; diff --git a/src/coord/radar/RadarModel.ts b/src/coord/radar/RadarModel.ts index 60fe2060aa..de7254f4e8 100644 --- a/src/coord/radar/RadarModel.ts +++ b/src/coord/radar/RadarModel.ts @@ -32,12 +32,19 @@ import { } from '../../util/types'; import { AxisBaseOption, CategoryAxisBaseOption, ValueAxisBaseOption } from '../axisCommonTypes'; import { AxisBaseModel } from '../AxisBaseModel'; -import Radar from './Radar'; +import type Radar from './Radar'; import {CoordinateSystemHostModel} from '../../coord/CoordinateSystem'; import tokens from '../../visual/tokens'; +import { getUID } from '../../util/component'; const valueAxisDefault = axisDefault.value; +export const COORD_SYS_TYPE_RADAR = 'radar'; +export const COMPONENT_TYPE_RADAR = COORD_SYS_TYPE_RADAR; +export const SERIES_TYPE_RADAR = COORD_SYS_TYPE_RADAR; + +export const RADAR_DEFAULT_SPLIT_NUMBER = 5; + function defaultsShow(opt: object, show: boolean) { return zrUtil.defaults({ show: show @@ -103,7 +110,7 @@ export type InnerIndicatorAxisOption = AxisBaseOption & { }; class RadarModel extends ComponentModel implements CoordinateSystemHostModel { - static readonly type = 'radar'; + static readonly type = COMPONENT_TYPE_RADAR; readonly type = RadarModel.type; coordinateSystem: Radar; @@ -173,6 +180,9 @@ class RadarModel extends ComponentModel implements CoordinateSystem // For triggerEvent. model.mainType = 'radar'; model.componentIndex = this.componentIndex; + // FIXME: construct an AxisBaseModel directly, rather than mixin. + // @ts-ignore + model.uid = getUID('ec_radar'); return model; }, this); @@ -207,7 +217,7 @@ class RadarModel extends ComponentModel implements CoordinateSystem boundaryGap: [0, 0], - splitNumber: 5, + splitNumber: RADAR_DEFAULT_SPLIT_NUMBER, axisNameGap: 15, diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index 389b98c3b1..607e3639e7 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -17,167 +17,215 @@ * under the License. */ -import { assert, isArray, eqNaN, isFunction } from 'zrender/src/core/util'; +import { + assert, isArray, eqNaN, isFunction, each, HashMap, createHashMap +} from 'zrender/src/core/util'; import Scale from '../scale/Scale'; import { AxisBaseModel } from './AxisBaseModel'; import { parsePercent } from 'zrender/src/contain/text'; -import { AxisBaseOption, CategoryAxisBaseOption, NumericAxisBaseOptionCommon } from './axisCommonTypes'; -import { ScaleDataValue } from '../util/types'; - - -export interface ScaleRawExtentResult { - // `min`/`max` defines data available range, determined by - // `dataMin`/`dataMax` and explicit specified min max related option. - // The final extent will be based on the `min`/`max` and may be enlarge - // a little (say, "nice strategy", e.g., niceScale, boundaryGap). - // Ensure `min`/`max` be finite number or NaN here. - // (not to be null/undefined) `NaN` means min/max axis is blank. - readonly min: number; - readonly max: number; - // `minFixed`/`maxFixed` marks that `min`/`max` should be used - // in the final extent without other "nice strategy". - readonly minFixed: boolean; - readonly maxFixed: boolean; - // Mark that the axis should be blank. - readonly isBlank: boolean; -} +import { + NumericAxisBaseOptionCommon, + NumericAxisBoundaryGapOptionItemValue, +} from './axisCommonTypes'; +import { DimensionIndex, DimensionName, NullUndefined, ScaleDataValue } from '../util/types'; +import { isIntervalScale, isLogScale, isOrdinalScale, isTimeScale } from '../scale/helper'; +import { + makeInner, initExtentForUnion, unionExtentFromNumber, isValidNumberForExtent, + extentHasValue, + unionExtentFromExtent, + unionExtentStartFromNumber, + unionExtentEndFromNumber, +} from '../util/model'; +import { getDataDimensionsOnAxis } from './axisHelper'; +import { + getCoordForCoordSysUsageKindBox +} from '../core/CoordinateSystem'; +import type GlobalModel from '../model/Global'; +import { error } from '../util/log'; +import type Axis from './Axis'; +import { mathMax, mathMin } from '../util/number'; +import { SCALE_EXTENT_KIND_MAPPING } from '../scale/scaleMapper'; +import { AxisStatKey, eachKeyOnAxis, eachSeriesOnAxis } from './axisStatistics'; -export class ScaleRawExtentInfo { - private _needCrossZero: boolean; - private _isOrdinal: boolean; - private _axisDataLen: number; - private _boundaryGapInner: number[]; +/** + * NOTICE: Can be only used in `ensureScaleStore(axisLike)`. + * + * In most cases the instances of `Axis` and `Scale` are one-to-one mapping and share the same lifecycle. + * But in some external usage (such as echarts-gl), axis instance does not necessarily exist, and only + * scale instance and axisModel are used. Therefore we store the internal info on scale instance directly. + */ +const scaleInner = makeInner<{ + extent: number[]; + dimIdxInCoord: number; +}, Scale>(); + +export const AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE = 1; +export const AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM = 2; +const AXIS_EXTENT_INFO_BUILD_FROM_EMPTY = 3; +export type AxisExtentInfoBuildFrom = + typeof AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE + | typeof AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM + | typeof AXIS_EXTENT_INFO_BUILD_FROM_EMPTY; - // Accurate raw value get from model. - private _modelMinRaw: AxisBaseOption['min']; - private _modelMaxRaw: AxisBaseOption['max']; +/** + * It is originally created as `ScaleRawExtentResultFinal['fixMinMax']` and may be + * modified in the subsequent process. + * It suggests axes to use `scaleRawExtentResult.min/max` directly as their bounds, + * instead of expanding the extent by some "nice strategy". But axes may refuse to + * comply with it in some special cases, for example, when their scale extents are + * invalid, or when they need to be expanded to visually contain series bars. + * + * In `ScaleRawExtentResultFinal['fixMinMax']`, it is `true` iff: + * - ec option `xxxAxis.min/max` are specified, or + * - `scaleRawExtentResult.zoomFixMinMax[]` are `true`. + * - `min` `max` are expanded by `AxisContainShapeHandler`. + * + * In the subsequent process, it may be modified to `true` for customization. + */ +export type ScaleExtentFixMinMax = boolean[]; - // Can be `finite number`/`null`/`undefined`/`NaN` - private _modelMinNum: number; - private _modelMaxNum: number; +type ScaleRawExtentResultForContainShape = Pick< + ScaleRawExtentInternal, + 'noZoomEffMM' | 'containShape' +>; + +/** + * Return the min max before `dataZoom` applied. + */ +export type ScaleRawExtentResultForZoom = { + // "effective" means `SCALE_EXTENT_KIND_EFFECTIVE`. + noZoomEffMM: number[]; + // "mapping" means `SCALE_EXTENT_KIND_MAPPING`. + noZoomMapMM: number[]; +}; + +type ScaleRawExtentResultFinal = Pick< + ScaleRawExtentInternal, + 'fixMM' | 'zoomFixMM' | 'isBlank' | 'needCrossZero' | 'needToggleAxisInverse' +> & { + // This is the effective min max. "effective" indicates `SCALE_EXTENT_KIND_EFFECTIVE`. + // It is determined by series data extent and ec options such as `xxxAxis.min/max`, + // `xxxAxis.boundaryGap`, etc. And this is the input of "nice" strategy. + // Ensure `effMM` has only finite numbers or `NaN`, but never has `null`/`undefined`. + // `NaN` means min/max axis is blank. + effMM: number[]; + + // Expected min max for mapping. "mapping" indicates `SCALE_EXTENT_KIND_MAPPING`. + // It has to be applied after "nice" strategies (see `scaleCalcNice`) being applied, + // since "nice" strategies may expand the scale extent originated from `effMM`. + mapMM: number[]; +}; + +type ScaleRawExtentResultOthers = Pick< + ScaleRawExtentInternal, + 'startValue' | 'dataMM' +>; - // Range union by series data on this axis. - // May be modified if data is filtered. - private _dataMin: number; - private _dataMax: number; +/** + * CAVEAT: MUST NOT be modified outside! + */ +interface ScaleRawExtentInternal { + + scale: Scale; + + // The effective min max before `dataZoom` applied. + // "effective" means `SCALE_EXTENT_KIND_EFFECTIVE`. + noZoomEffMM: number[]; + + // Expanded from `noZoomEffMM`. `NullUndefined` means not used. + // This is relevant to `SCALE_EXTENT_KIND_MAPPING`. + noZoomEffMMExp?: number[] | NullUndefined; + + // data min max, an union from series data on this axis and `model.dataMin/dataMax`. + // May be at the initial state `[Infinity, -Infinity]`. + dataMM: number[]; + + // Specified by `dataZoom` when its start/end is not 0%/100%. + // `NullUndefined` means not specified. // Highest priority if specified. - private _determinedMin: number; - private _determinedMax: number; + zoomMM: (number | NullUndefined)[]; - // Make that the `rawExtentInfo` can not be modified any more. - readonly frozen: boolean; + // See comments of `ScaleExtentFixMinMax` + fixMM: ScaleExtentFixMinMax; - // custom dataMin/dataMax - private _dataMinNum: number; - private _dataMaxNum: number; + // It indicates that the min max have been fixed by `dataZoom` when its start/end is not 0%/100%. + zoomFixMM: ScaleExtentFixMinMax; - constructor( - scale: Scale, - model: AxisBaseModel, - // Usually: data extent from all series on this axis. - originalExtent: number[] - ) { - this._prepareParams(scale, model, originalExtent); - } + startValue: number; - /** - * Parameters depending on outside (like model, user callback) - * are prepared and fixed here. - */ - private _prepareParams( - scale: Scale, - model: AxisBaseModel, - // Usually: data extent from all series on this axis. - dataExtent: number[] - ) { - if (dataExtent[1] < dataExtent[0]) { - dataExtent = [NaN, NaN]; - } - this._dataMin = dataExtent[0]; - this._dataMax = dataExtent[1]; + // Mark that the axis should be blank. + isBlank: boolean; - const isOrdinal = this._isOrdinal = scale.type === 'ordinal'; - this._needCrossZero = scale.type === 'interval' && model.getNeedCrossZero && model.getNeedCrossZero(); + needCrossZero: boolean; - if (scale.type === 'interval' || scale.type === 'log' || scale.type === 'time') { - // Process custom dataMin/dataMax - const dataMinRaw = (model as AxisBaseModel).get('dataMin', true); - if (dataMinRaw != null) { - this._dataMinNum = parseAxisModelMinMax(scale, dataMinRaw); - } + needToggleAxisInverse: boolean; - const dataMaxRaw = (model as AxisBaseModel).get('dataMax', true); - if (dataMaxRaw != null) { - this._dataMaxNum = parseAxisModelMinMax(scale, dataMaxRaw); - } - } + containShape: boolean; +} - let axisMinValue = model.get('min', true); - if (axisMinValue == null) { - axisMinValue = model.get('startValue', true); - } - const modelMinRaw = this._modelMinRaw = axisMinValue; - if (isFunction(modelMinRaw)) { - // This callback always provides users the full data extent (before data is filtered). - this._modelMinNum = parseAxisModelMinMax(scale, modelMinRaw({ - min: dataExtent[0], - max: dataExtent[1] - })); - } - else if (modelMinRaw !== 'dataMin') { - this._modelMinNum = parseAxisModelMinMax(scale, modelMinRaw); - } +export type AxisContainShapeHandler = ( + axis: Axis, + scale: Scale, + ecModel: GlobalModel + // @return: supplement in linear space. + // Must ensure: `supplement[0] <= 0 && supplement[1] >= 0` + // - e.g., [-50, 70] indicates the final extent should calculated by adding this supplement: + // [ + // scale.transformOut(scale.transformIn(extent[0]) - 50), + // scale.transformOut(scale.transformIn(extent[1]) + 70) + // ] + // - `null/undefined` means no supplement. +) => number[] | NullUndefined; - const modelMaxRaw = this._modelMaxRaw = model.get('max', true); - if (isFunction(modelMaxRaw)) { - // This callback always provides users the full data extent (before data is filtered). - this._modelMaxNum = parseAxisModelMinMax(scale, modelMaxRaw({ - min: dataExtent[0], - max: dataExtent[1] - })); - } - else if (modelMaxRaw !== 'dataMax') { - this._modelMaxNum = parseAxisModelMinMax(scale, modelMaxRaw); - } - if (isOrdinal) { +export class ScaleRawExtentInfo { + + private _i: ScaleRawExtentInternal; + + // Injected outside + readonly from: AxisExtentInfoBuildFrom; + + + constructor( + scale: Scale, + model: AxisBaseModel, + // Typically: data extent from all series on this axis. + dataExtent: number[] + ) { + const isOrdinal = isOrdinalScale(scale); + + const axisDataLen = isOrdinal // FIXME: there is a flaw here: if there is no "block" data processor like `dataZoom`, // and progressive rendering is using, here the category result might just only contain // the processed chunk rather than the entire result. - this._axisDataLen = model.getCategories().length; - } - else { - const boundaryGap = (model as AxisBaseModel).get('boundaryGap'); - const boundaryGapArr = isArray(boundaryGap) - ? boundaryGap : [boundaryGap || 0, boundaryGap || 0]; + ? model.getCategories().length + : null; - if (typeof boundaryGapArr[0] === 'boolean' || typeof boundaryGapArr[1] === 'boolean') { - if (__DEV__) { - console.warn('Boolean type for boundaryGap is only ' - + 'allowed for ordinal axis. Please use string in ' - + 'percentage instead, e.g., "20%". Currently, ' - + 'boundaryGap is set to be 0.'); - } - this._boundaryGapInner = [0, 0]; - } - else { - this._boundaryGapInner = [ - parsePercent(boundaryGapArr[0], 1), - parsePercent(boundaryGapArr[1], 1) - ]; - } + // NOTE: also considered the input dataExtent may be still in the initialized state `[Infinity, -Infinity]`. + const dataMM = dataExtent.slice(); + // custom dataMin/dataMax. + // Also considered `modelDataMinMax[0] > modelDataMinMax[1]` may occur. + if (isIntervalScale(scale) || isLogScale(scale) || isTimeScale(scale)) { + unionExtentStartFromNumber( + dataMM, + parseAxisModelMinMax(scale, (model as AxisBaseModel).get('dataMin', true)) + ); + unionExtentEndFromNumber( + dataMM, + parseAxisModelMinMax(scale, (model as AxisBaseModel).get('dataMax', true)) + ); + } + if (!extentHasValue(dataMM)) { + // dataMM may be still `[Infinity, -Infinity]`, we use `NaN` on the subsequent calculations + // to force the `noZoomEffMM` to be `[NaN, NaN]` if needed. + dataMM[0] = dataMM[1] = NaN; } - } - /** - * Calculate extent by prepared parameters. - * This method has no external dependency and can be called duplicatedly, - * getting the same result. - * If parameters changed, should call this method to recalcuate. - */ - calculate(): ScaleRawExtentResult { + const noZoomEffMM: number[] = []; + const fixMM: boolean[] = [false, false]; + // Notice: When min/max is not set (that is, when there are null/undefined, // which is the most common case), these cases should be ensured: // (1) For 'ordinal', show all axis.data. @@ -188,160 +236,590 @@ export class ScaleRawExtentInfo { // be the result that originalExtent enlarged by boundaryGap. // (3) If no data, it should be ensured that `scale.setBlank` is set. - const isOrdinal = this._isOrdinal; - let dataMin = this._dataMin; - let dataMax = this._dataMax; - - // Include custom dataMin/dataMax in calculation - // If dataMin is set and less than current data minimum, update the minimum value - if (this._dataMinNum != null && isFinite(dataMin) && this._dataMinNum < dataMin) { - dataMin = this._dataMinNum; + let startValue = parseAxisModelMinMax(scale, model.get('startValue', true)); + let modelMinRaw = model.get('min', true); + if (modelMinRaw == null) { + modelMinRaw = startValue; + } + if (modelMinRaw === 'dataMin') { + noZoomEffMM[0] = dataMM[0]; + fixMM[0] = true; + } + else { + noZoomEffMM[0] = parseAxisModelMinMax( + scale, + isFunction(modelMinRaw) + // This callback always provides users the full data extent (before data is filtered). + ? modelMinRaw({min: dataMM[0], max: dataMM[1]}) + : modelMinRaw + ); + // If `xxxAxis.min: null/undefined`, min should not be fixed. + fixMM[0] = noZoomEffMM[0] != null; } - // If dataMax is set and greater than current data maximum, update the maximum value - if (this._dataMaxNum != null && isFinite(dataMax) && this._dataMaxNum > dataMax) { - dataMax = this._dataMaxNum; + const modelMaxRaw = model.get('max', true); + if (modelMaxRaw === 'dataMax') { + noZoomEffMM[1] = dataMM[1]; + fixMM[1] = true; + } + else { + noZoomEffMM[1] = parseAxisModelMinMax( + scale, + isFunction(modelMaxRaw) + // This callback always provides users the full data extent (before data is filtered). + ? modelMaxRaw({min: dataMM[0], max: dataMM[1]}) + : modelMaxRaw + ); + // If `xxxAxis.max: null/undefined`, max should not be fixed. + fixMM[1] = noZoomEffMM[1] != null; } - const axisDataLen = this._axisDataLen; - const boundaryGapInner = this._boundaryGapInner; + const boundaryGap = parseBoundaryGapOption(scale, model); const span = !isOrdinal - ? ((dataMax - dataMin) || Math.abs(dataMin)) + ? ((dataMM[1] - dataMM[0]) || Math.abs(dataMM[0])) : null; - - // Currently if a `'value'` axis model min is specified as 'dataMin'/'dataMax', - // `boundaryGap` will not be used. It's the different from specifying as `null`/`undefined`. - let min = this._modelMinRaw === 'dataMin' ? dataMin : this._modelMinNum; - let max = this._modelMaxRaw === 'dataMax' ? dataMax : this._modelMaxNum; - - // If `_modelMinNum`/`_modelMaxNum` is `null`/`undefined`, should not be fixed. - let minFixed = min != null; - let maxFixed = max != null; - - if (min == null) { - min = isOrdinal + // NOTE: If a numeric axis min/max is specified as 'dataMin'/'dataMax', + // `boundaryGap` will not be used. + if (noZoomEffMM[0] == null) { + noZoomEffMM[0] = isOrdinal ? (axisDataLen ? 0 : NaN) - : dataMin - boundaryGapInner[0] * span; + : dataMM[0] - boundaryGap[0] * span; } - if (max == null) { - max = isOrdinal + if (noZoomEffMM[1] == null) { + noZoomEffMM[1] = isOrdinal ? (axisDataLen ? axisDataLen - 1 : NaN) - : dataMax + boundaryGapInner[1] * span; + : dataMM[1] + boundaryGap[1] * span; } - (min == null || !isFinite(min)) && (min = NaN); - (max == null || !isFinite(max)) && (max = NaN); + // Normalize to `NaN` if invalid. + !isValidNumberForExtent(noZoomEffMM[0]) && (noZoomEffMM[0] = NaN); + !isValidNumberForExtent(noZoomEffMM[1]) && (noZoomEffMM[1] = NaN); - const isBlank = eqNaN(min) - || eqNaN(max) + const isBlank = eqNaN(noZoomEffMM[0]) || eqNaN(noZoomEffMM[1]) || (isOrdinal && !axisDataLen); - // If data extent modified, need to recalculated to ensure cross zero. - if (this._needCrossZero) { - // Axis is over zero and min is not set - if (min > 0 && max > 0 && !minFixed) { - min = 0; - // minFixed = true; + let needToggleAxisInverse: boolean = false; + if (noZoomEffMM[0] > noZoomEffMM[1]) { + // Historically, if users set `xxxAxis.min > xxxAxis.max`, the behavior is + // sometimes like `xxxAxis.inverse = true`, sometimes abnormal. We remain + // backward compatible with the former one. + noZoomEffMM.reverse(); + needToggleAxisInverse = true; + } + + const needCrossZero = isIntervalScale(scale) && model.getNeedCrossZero && model.getNeedCrossZero(); + if (needCrossZero) { + if (noZoomEffMM[0] > 0 && noZoomEffMM[1] > 0 && !fixMM[0]) { + noZoomEffMM[0] = 0; + // fixMM[0] = true; } - // Axis is under zero and max is not set - if (min < 0 && max < 0 && !maxFixed) { - max = 0; - // maxFixed = true; + if (noZoomEffMM[0] < 0 && noZoomEffMM[1] < 0 && !fixMM[1]) { + noZoomEffMM[1] = 0; + // fixMM[1] = true; } - // PENDING: - // When `needCrossZero` and all data is positive/negative, should it be ensured - // that the results processed by boundaryGap are positive/negative? - // If so, here `minFixed`/`maxFixed` need to be set. } - const determinedMin = this._determinedMin; - const determinedMax = this._determinedMax; - if (determinedMin != null) { - min = determinedMin; - minFixed = true; + if (scale.sanitize) { + startValue = scale.sanitize(startValue, dataMM); } - if (determinedMax != null) { - max = determinedMax; - maxFixed = true; + + let containShape = (model as AxisBaseModel).get('containShape', true); + if (containShape == null) { + containShape = true; } - // Ensure min/max be finite number or NaN here. (not to be null/undefined) - // `NaN` means min/max axis is blank. + const internal: ScaleRawExtentInternal = this._i = { + scale, + dataMM, + noZoomEffMM, + zoomMM: [], + fixMM, + zoomFixMM: [false, false], + startValue, + isBlank, + needCrossZero, + needToggleAxisInverse, + containShape, + }; + + sanitizeExtent(internal, noZoomEffMM); + } + + makeForContainShape(): ScaleRawExtentResultForContainShape { + const internal = this._i; return { - min: min, - max: max, - minFixed: minFixed, - maxFixed: maxFixed, - isBlank: isBlank + noZoomEffMM: internal.noZoomEffMM.slice(), + containShape: internal.containShape }; } - modifyDataMinMax(minMaxName: 'min' | 'max', val: number): void { - if (__DEV__) { - assert(!this.frozen); + makeNoZoom(): ScaleRawExtentResultForZoom { + const internal = this._i; + return { + noZoomEffMM: internal.noZoomEffMM.slice(), + noZoomMapMM: makeNoZoomMappingMM(internal), + }; + } + + makeFinal(): ScaleRawExtentResultFinal { + const internal = this._i; + const zoomMM = internal.zoomMM; + const noZoomEffMM = internal.noZoomEffMM; + const zoomFixMM = internal.zoomFixMM; + const fixMM = internal.fixMM; + const result = { + fixMM, + zoomFixMM, + isBlank: internal.isBlank, + needCrossZero: internal.needCrossZero, + needToggleAxisInverse: internal.needToggleAxisInverse, + effMM: noZoomEffMM.slice(), + mapMM: makeNoZoomMappingMM(internal), + }; + const effMM = result.effMM; + const mapMM = result.mapMM; + + // NOTE: Switching `fixMM` probably leads to abrupt extent changes when draging a `dataZoom` + // handle, since `fixMM` impact the "nice extent" and "nice ticks" calculation. + // Consider a case: + // dataZoom `start` is 2% but its `end` is 100%, (or vice versa), we currently only set `fixMM[0]` + // as `true` but remain `fixMM[1]` as `false` for this case to avoid unnecessary abrupt change. + // Incidentally, the effect is not unacceptable if we set both `fixMM[0]/[1]` as `true`. + if (zoomMM[0] != null) { + // `zoomMM` may overflow `noZoomEffMM` due to `noZoomEffMMExp`. + effMM[0] = mathMax(noZoomEffMM[0], zoomMM[0]); + mapMM[0] = zoomMM[0]; + fixMM[0] = zoomFixMM[0] = true; } - this[DATA_MIN_MAX_ATTR[minMaxName]] = val; + if (zoomMM[1] != null) { + // `zoomMM` may overflow `noZoomEffMM` due to `noZoomEffectiveMinMaxExpanded`. + effMM[1] = mathMin(noZoomEffMM[1], zoomMM[1]); + mapMM[1] = zoomMM[1]; + fixMM[1] = zoomFixMM[1] = true; + } + + sanitizeExtent(internal, effMM); + sanitizeExtent(internal, mapMM); + + return result; } - setDeterminedMinMax(minMaxName: 'min' | 'max', val: number): void { - const attr = DETERMINED_MIN_MAX_ATTR[minMaxName]; + makeOthers(): ScaleRawExtentResultOthers { + const internal = this._i; + return { + dataMM: internal.dataMM.slice(), + startValue: internal.startValue, + }; + } + + /** + * NOTICE: + * The caller must ensure `start <= end` and the range is equal or less then `noZoomMappingMinMax`. + * The outcome `_zoomMM` may have both `NullUndefined` and a finite value, like `[undefined, 123]`. + */ + setZoomMinMax(idxMinMax: 0 | 1, val: number | NullUndefined): void { + this._i.zoomMM[idxMinMax] = val; + } + + /** + * NOTICE: The caller MUST ensure `start <= end` and the range is equal or larger than `noZoomEffMM`. + */ + setNoZoomExpanded(start: number, end: number) { + const internal = this._i; if (__DEV__) { - assert( - !this.frozen - // Earse them usually means logic flaw. - && (this[attr] == null) - ); + assert(internal.noZoomEffMMExp == null); } - this[attr] = val; + sanitizeExtent(internal, internal.noZoomEffMMExp = [start, end]); } - freeze() { - // @ts-ignore - this.frozen = true; +} + +function makeNoZoomMappingMM(internal: ScaleRawExtentInternal): number[] { + return (internal.noZoomEffMMExp || internal.noZoomEffMM).slice(); +} + +/** + * Should be called when a new extent is created or modified. + */ +function sanitizeExtent( + internal: ScaleRawExtentInternal, + mm: (number | NullUndefined)[] +): void { + const scale = internal.scale; + if (scale.sanitize) { + const dataMM = internal.dataMM; + mm[0] = scale.sanitize(mm[0], dataMM); + mm[1] = scale.sanitize(mm[1], dataMM); + if (mm[1] < mm[0]) { + mm[1] = mm[0]; + } } } -const DETERMINED_MIN_MAX_ATTR = { min: '_determinedMin', max: '_determinedMax' } as const; -const DATA_MIN_MAX_ATTR = { min: '_dataMin', max: '_dataMax' } as const; +function parseAxisModelMinMax(scale: Scale, minMax: ScaleDataValue): number { + return minMax == null ? null // null/undefined means not specified and other default values can be applied. + : eqNaN(minMax) ? NaN // NaN means a deliberate invalid number. + : scale.parse(minMax); +} + +function parseBoundaryGapOption( + scale: Scale, + model: AxisBaseModel +): number[] { + let boundaryGapOptionArr; + if (isOrdinalScale(scale)) { + boundaryGapOptionArr = [0, 0]; + } + else { + let boundaryGap = (model as AxisBaseModel).get('boundaryGap'); + if (typeof boundaryGap === 'boolean') { + if (__DEV__) { + console.warn('Boolean type for boundaryGap is only ' + + 'allowed for ordinal axis. Please use string in ' + + 'percentage instead, e.g., "20%". Currently, ' + + 'boundaryGap is set to be 0.'); + } + boundaryGap = null; + } + boundaryGapOptionArr = isArray(boundaryGap) ? boundaryGap : [boundaryGap, boundaryGap]; + } + return [ + parseBoundaryGapOptionItem(boundaryGapOptionArr[0]), + parseBoundaryGapOptionItem(boundaryGapOptionArr[1]), + ]; +} + +function parseBoundaryGapOptionItem( + opt: NumericAxisBoundaryGapOptionItemValue | boolean +): number { + return parsePercent( + typeof opt === 'boolean' ? 0 : opt, + 1 + ) || 0; +} + +/** + * NOTE: `associateSeriesWithAxis` is not necessarily called, e.g., when + * an axis is not used by any series. + */ +function ensureScaleStore(axisLike: {scale: Scale}) { + const store = scaleInner(axisLike.scale); + if (!store.extent) { + store.extent = initExtentForUnion(); + } + return store; +} /** - * Get scale min max and related info only depends on model settings. - * This method can be called after coordinate system created. - * For example, in data processing stage. + * This supports union extent on case like: pie (or other similar series) + * lays out on cartesian2d. + * @see scaleRawExtentInfoCreate + */ +export function scaleRawExtentInfoEnableBoxCoordSysUsage( + axisLike: { + scale: Scale; + dim: DimensionName; + }, + coordSysDimIdxMap: HashMap | NullUndefined +): void { + ensureScaleStore(axisLike).dimIdxInCoord = coordSysDimIdxMap.get(axisLike.dim); +} + +/** + * @usage + * class SomeCoordSys { + * static create() { + * ecModel.eachSeries(function (seriesModel) { + * associateSeriesWithAxis(axis1, seriesModel, ...); + * associateSeriesWithAxis(axis2, seriesModel, ...); + * // ... + * }); + * } + * update() { + * scaleRawExtentInfoCreate(axis1); + * scaleRawExtentInfoCreate(axis2); + * } + * } + * class AxisProxy { + * reset() { + * scaleRawExtentInfoCreate(axis1); + * } + * } * - * Scale extent info probably be required multiple times during a workflow. - * For example: - * (1) `dataZoom` depends it to get the axis extent in "100%" state. - * (2) `processor/extentCalculator` depends it to make sure whether axis extent is specified. - * (3) `coordSys.update` use it to finally decide the scale extent. - * But the callback of `min`/`max` should not be called multiple times. - * The code below should not be implemented repeatedly either. - * So we cache the result in the scale instance, which will be recreated at the beginning - * of the workflow (because `scale` instance will be recreated each round of the workflow). + * NOTICE: + * - `associateSeriesWithAxis`(in `axisStatistics.ts`) should be called in: + * - Coord sys create method. + * - `scaleRawExtentInfoCreate` should be typically called in: + * - `dataZoom` processor. It require processing like: + * 1. Filter series data by dataZoom1; + * 2. Union the filtered data and init the extent of the orthogonal axes, which is the 100% of dataZoom2; + * 3. Filter series data by dataZoom2; + * 4. ... + * - Coord sys update method, for other axes that not covered by `dataZoom`. + * NOTE: If `dataZoom` exists can cover this series, this data and its extent + * has been dataZoom-filtered. Therefore this handling should not before dataZoom. + * - The callback of `min`/`max` in ec option should NOT be called multiple times, + * therefore, we initialize `ScaleRawExtentInfo` uniformly in `scaleRawExtentInfoCreate`. */ -export function ensureScaleRawExtentInfo( +export function scaleRawExtentInfoCreate( + ecModel: GlobalModel, + axis: Axis, + from: AxisExtentInfoBuildFrom +): void { + const scale = axis.scale; + const model = axis.model; + const axisDim = axis.dim; + if (__DEV__) { + assert(scale && model && axisDim); + } + + if (scale.rawExtentInfo) { + if (__DEV__) { + // Check for incorrect impl - the duplicated calling of this method is only allowed in + // these cases: + // - First in `AxisProxy['reset']` (for dataZoom) + // - Then in `CoordinateSystem['update']`. + // - Then after `chart.appendData()` due to `dirtyOnOverallProgress: true` + assert(scale.rawExtentInfo.from !== from || from === AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM); + } + return; + } + + scaleRawExtentInfoCreateDeal(scale, axis, axisDim, model, ecModel, from); + + calcContainShape(scale, axis, ecModel, scale.rawExtentInfo); +} + +function scaleRawExtentInfoCreateDeal( scale: Scale, + axis: Axis, + axisDim: DimensionName, model: AxisBaseModel, - // Usually: data extent from all series on this axis. - originalExtent: number[] -): ScaleRawExtentInfo { - - // Do not permit to recreate. - let rawExtentInfo = scale.rawExtentInfo; - if (rawExtentInfo) { - return rawExtentInfo; + ecModel: GlobalModel, + from: AxisExtentInfoBuildFrom +): void { + const scaleStore = ensureScaleStore(axis); + const extent = scaleStore.extent; + + eachSeriesOnAxis(axis, function (seriesModel) { + if (seriesModel.boxCoordinateSystem) { + // This supports union extent on case like: pie (or other similar series) + // lays out on cartesian2d. + const {coord} = getCoordForCoordSysUsageKindBox(seriesModel); + const dimIdx = scaleStore.dimIdxInCoord; + if (!(dimIdx >= 0)) { + if (__DEV__) { + // Require `scaleRawExtentInfoEnableBoxCoordSysUsage` have been called to support it. + // But if users set it, give a error log but no exceptions. + error(`Property "series.coord" is not supported on axis ${seriesModel.boxCoordinateSystem.type}.`); + } + } + // Only `[val1, val2]` case needs to be supported currently. + else if (isArray(coord)) { + const coordItem = coord[dimIdx]; + if (coordItem != null && !isArray(coordItem)) { + unionExtentFromNumber(extent, scale.parse(coordItem)); + } + } + } + else if (seriesModel.coordinateSystem) { + // NOTE: This data may have been filtered by dataZoom on orthogonal axes. + const data = seriesModel.getData(); + if (data) { + const filter = scale.getFilter ? scale.getFilter() : null; + each(getDataDimensionsOnAxis(data, axisDim), function (dim) { + unionExtentFromExtent(extent, data.getApproximateExtent(dim, filter)); + }); + } + } + }); + + const rawExtentInfo = new ScaleRawExtentInfo(scale, model, extent); + injectScaleRawExtentInfo(scale, rawExtentInfo, from); + + scaleStore.extent = null; // Clean up +} + +/** + * `rawExtentInfo` may not be created in some cases, such as no series declared or extra useless + * axes declared in ec option. In this case we still create a default one for that empty axis. + */ +function scaleRawExtentInfoBuildDefault( + axisLike: { + scale: Scale; + model: AxisBaseModel; + }, + dataExtent: number[] +): void { + const scale = axisLike.scale; + if (__DEV__) { + assert(!scale.rawExtentInfo); } + injectScaleRawExtentInfo( + scale, + new ScaleRawExtentInfo(scale, axisLike.model, dataExtent), + AXIS_EXTENT_INFO_BUILD_FROM_EMPTY + ); +} - rawExtentInfo = new ScaleRawExtentInfo(scale, model, originalExtent); +function injectScaleRawExtentInfo( + scale: Scale, + scaleRawExtentInfo: ScaleRawExtentInfo, + from: AxisExtentInfoBuildFrom +): void { + // @ts-ignore + scale.rawExtentInfo = scaleRawExtentInfo; // @ts-ignore - scale.rawExtentInfo = rawExtentInfo; + scaleRawExtentInfo.from = from; +} - return rawExtentInfo; +/** + * See `axisSnippets.ts` for some commonly used handlers. + */ +export function registerAxisContainShapeHandler( + // `axisStatKey` is used to quickly omit irrelevant handlers, + // since handlers need to be iterated per axis. + axisStatKey: AxisStatKey, + handler: AxisContainShapeHandler, +) { + if (__DEV__) { + assert(!axisContainShapeHandlerMap.get(axisStatKey)); + } + axisContainShapeHandlerMap.set(axisStatKey, handler); } -export function parseAxisModelMinMax(scale: Scale, minMax: ScaleDataValue): number { - return minMax == null ? null - : eqNaN(minMax) ? NaN - : scale.parse(minMax); +const axisContainShapeHandlerMap: HashMap = createHashMap(); + + +/** + * Prepare axis scale extent before "nice". + * Item of returned array can only be number (including Infinity and NaN). + */ +export function adoptScaleRawExtentInfoAndPrepare( + scale: Scale, + model: AxisBaseModel, + ecModel: GlobalModel | NullUndefined, + axis: Axis | NullUndefined, + externalDataExtent: number[] | NullUndefined +): ScaleRawExtentResultFinal { + + if (__DEV__) { + assert(!externalDataExtent || !scale.rawExtentInfo); + } + + if (!scale.rawExtentInfo) { + scaleRawExtentInfoBuildDefault( + {scale, model}, + externalDataExtent || initExtentForUnion() + ); + } + const rawExtentResult = scale.rawExtentInfo.makeFinal(); + + // NOTE: The scale extent is at least required in: + // - `AxisContainShapeHandler`, such as `makeColumnLayout` in `barGrid.ts`. And this should be the raw + // extent instead of the "nice" extent for better preciseness. + // - Nice extent calculation and axis align calculation, where the transformed intermediate extent may + // be required. + const effectiveMinMax = rawExtentResult.effMM; + scale.setExtent(effectiveMinMax[0], effectiveMinMax[1]); + + scale.setBlank(rawExtentResult.isBlank); + + if (axis + && rawExtentResult.needToggleAxisInverse + && ecModel && !ecModel.get('legacyMinMaxDontInverseAxis') + ) { + axis.inverse = !axis.inverse; + } + + return rawExtentResult; +} + +/** + * These handlers implement ec option `someAxis.boundaryGap[i].containShape`. That is, expand scale + * extent slightly to ensure shapes of specific series are fully contained in the axis extent without + * overflow. See `barGridAxisContainShapeHandler` in `barGrid.ts` as a typical example. + * + * NOTICE: + * - Time-consuming. So avoid calling this frequently. + * - `axis.getExtent()` (pixel extent) is required. + * - This feature can be implemented by either expanding axis extent (pixel extent) or scale extent + * (data extent). The choice depends on whether series shape sizes are defined in pixels or data space. + * For example, scatter series glyph sizes is mainly defined in pixel, while bar series `bandWidth` is + * mainly determined by given percents of data scale. Since currently scatter does not require this + * feature, we implement it only on the data scale. + * - scale extent has been set in `adoptScaleRawExtentAndPrepare` as an input, though it may be modified later. + * - axis pixel extent has been set as an input, though it may be modified later (e.g., `outerBounds`). + * - (See the summary in the comment of `scaleMapper.setExtent`.) + */ +function calcContainShape( + scale: Scale, + axis: Axis, + ecModel: GlobalModel, + rawExtentInfo: ScaleRawExtentInfo, +): void { + // `scale.getExtent` is required by AxisContainShapeHandler. See + // `barGridCreateAxisContainShapeHandler` in `barGrid.ts` as an example. + const {noZoomEffMM, containShape} = rawExtentInfo.makeForContainShape(); + axis.scale.setExtent(noZoomEffMM[0], noZoomEffMM[1]); + + if (!containShape) { + return; + } + + // `NullUndefined` indicates that `linearSupplement` is not introduced. + let linearSupplement: number[] | NullUndefined; + + eachKeyOnAxis(axis, function (axisStatKey) { + const handler = axisContainShapeHandlerMap.get(axisStatKey); + if (handler) { + const singleLinearSupplement = handler(axis, scale, ecModel); + if (singleLinearSupplement) { + linearSupplement = linearSupplement || [0, 0]; + unionExtentStartFromNumber(linearSupplement, singleLinearSupplement[0]); + unionExtentEndFromNumber(linearSupplement, singleLinearSupplement[1]); + } + } + }); + + if (linearSupplement) { + rawExtentInfo.setNoZoomExpanded( + mathMin( + noZoomEffMM[0], + axis.scale.transformOut( + axis.scale.transformIn(noZoomEffMM[0], null) + linearSupplement[0], null + ) + ), + mathMax( + noZoomEffMM[1], + axis.scale.transformOut( + axis.scale.transformIn(noZoomEffMM[1], null) + linearSupplement[1], null + ) + ) + ); + } +} + +export function adoptScaleExtentKindMapping( + scale: Scale, + rawExtentResult: ScaleRawExtentResultFinal, +): void { + // NOTE: `SCALE_EXTENT_KIND_MAPPING` is only used on the full extent before dataZoom applied, + // which is the most intuitive. When dataZoom `start`/`end` is applied, the edge should be + // exactly with respect to that `start`/`end`, and shapes are clipped if overflowing. + // + // NOTE: since currently `SCALE_EXTENT_KIND_MAPPING` is never required to be displayed, we + // do not need to find a proper precision for that. But if it is required in the future, We + // can use `getAcceptableTickPrecision` to find a proper precision. + const scaleExtent = scale.getExtent(); + const scaleExtentExpanded = scaleExtent.slice(); + unionExtentFromExtent(scaleExtentExpanded, rawExtentResult.mapMM); + if (scaleExtentExpanded[0] < scaleExtent[0] || scaleExtentExpanded[1] > scaleExtent[1]) { + scale.setExtent2( + SCALE_EXTENT_KIND_MAPPING, + scaleExtentExpanded[0], + scaleExtentExpanded[1] + ); + } } diff --git a/src/coord/single/AxisModel.ts b/src/coord/single/AxisModel.ts index a65e149c88..581edf1bb2 100644 --- a/src/coord/single/AxisModel.ts +++ b/src/coord/single/AxisModel.ts @@ -29,6 +29,10 @@ import { import { AxisBaseModel } from '../AxisBaseModel'; import { mixin } from 'zrender/src/core/util'; + +export const COORD_SYS_TYPE_SINGLE_AXIS = 'singleAxis'; +export const COMPONENT_TYPE_SINGLE_AXIS = COORD_SYS_TYPE_SINGLE_AXIS; + export type SingleAxisPosition = 'top' | 'bottom' | 'left' | 'right'; export type SingleAxisOption = AxisBaseOption & BoxLayoutOptionMixin & { @@ -39,7 +43,7 @@ export type SingleAxisOption = AxisBaseOption & BoxLayoutOptionMixin & { class SingleAxisModel extends ComponentModel implements AxisBaseModel { - static type = 'singleAxis'; + static type = COMPONENT_TYPE_SINGLE_AXIS; type = SingleAxisModel.type; static readonly layoutMode = 'box'; diff --git a/src/coord/single/Single.ts b/src/coord/single/Single.ts index 80e2ac04c2..7c2e804e48 100644 --- a/src/coord/single/Single.ts +++ b/src/coord/single/Single.ts @@ -24,16 +24,19 @@ import SingleAxis from './SingleAxis'; import * as axisHelper from '../axisHelper'; import {createBoxLayoutReference, getLayoutRect} from '../../util/layout'; -import {each} from 'zrender/src/core/util'; import { CoordinateSystem, CoordinateSystemMaster } from '../CoordinateSystem'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import BoundingRect from 'zrender/src/core/BoundingRect'; -import SingleAxisModel from './AxisModel'; +import SingleAxisModel, { COORD_SYS_TYPE_SINGLE_AXIS } from './AxisModel'; import { ParsedModelFinder, ParsedModelFinderKnown } from '../../util/model'; import { ScaleDataValue } from '../../util/types'; import { AxisBaseModel } from '../AxisBaseModel'; import { CategoryAxisBaseOption } from '../axisCommonTypes'; +import { scaleCalcNice } from '../axisNiceTicks'; +import { + AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE, scaleRawExtentInfoCreate +} from '../scaleRawExtentInfo'; export const singleDimensions = ['single']; /** @@ -41,7 +44,7 @@ export const singleDimensions = ['single']; */ class Single implements CoordinateSystem, CoordinateSystemMaster { - readonly type = 'single'; + readonly type = COORD_SYS_TYPE_SINGLE_AXIS; readonly dimension = 'single'; /** @@ -73,11 +76,12 @@ class Single implements CoordinateSystem, CoordinateSystemMaster { const dim = this.dimension; + const axisType = axisHelper.determineAxisType(axisModel); const axis = new SingleAxis( dim, - axisHelper.createScaleByModel(axisModel), + axisHelper.createScaleByModel(axisModel, axisType, true), [0, 0], - axisModel.get('type'), + axisType, axisModel.get('position') ); @@ -96,15 +100,9 @@ class Single implements CoordinateSystem, CoordinateSystemMaster { * Update axis scale after data processed */ update(ecModel: GlobalModel, api: ExtensionAPI) { - ecModel.eachSeries(function (seriesModel) { - if (seriesModel.coordinateSystem === this) { - const data = seriesModel.getData(); - each(data.mapDimensionsAll(this.dimension), function (dim) { - this._axis.scale.unionExtentFromData(data, dim); - }, this); - axisHelper.niceScaleExtent(this._axis.scale, this._axis.model); - } - }, this); + const axis = this._axis; + scaleRawExtentInfoCreate(ecModel, axis, AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE); + scaleCalcNice(axis); } /** diff --git a/src/coord/single/prepareCustom.ts b/src/coord/single/prepareCustom.ts index 0ca8e46942..f8a4a23762 100644 --- a/src/coord/single/prepareCustom.ts +++ b/src/coord/single/prepareCustom.ts @@ -17,6 +17,7 @@ * under the License. */ +import { calcBandWidth } from '../axisBand'; import Single from './Single'; import { bind } from 'zrender/src/core/util'; @@ -26,7 +27,7 @@ function dataToCoordSize(this: Single, dataSize: number | number[], dataItem: nu const val = dataItem instanceof Array ? dataItem[0] : dataItem; const halfSize = (dataSize instanceof Array ? dataSize[0] : dataSize) / 2; return axis.type === 'category' - ? axis.getBandWidth() + ? calcBandWidth(axis).w : Math.abs(axis.dataToCoord(val - halfSize) - axis.dataToCoord(val + halfSize)); } diff --git a/src/coord/single/singleCreator.ts b/src/coord/single/singleCreator.ts index 1c2c5849a0..e1821cb205 100644 --- a/src/coord/single/singleCreator.ts +++ b/src/coord/single/singleCreator.ts @@ -24,10 +24,11 @@ import Single, { singleDimensions } from './Single'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; -import SingleAxisModel from './AxisModel'; +import SingleAxisModel, { COMPONENT_TYPE_SINGLE_AXIS, COORD_SYS_TYPE_SINGLE_AXIS } from './AxisModel'; import SeriesModel from '../../model/Series'; import { SeriesOption } from '../../util/types'; import { SINGLE_REFERRING } from '../../util/model'; +import { associateSeriesWithAxis } from '../axisStatistics'; /** * Create single coordinate system and inject it into seriesModel. @@ -35,7 +36,7 @@ import { SINGLE_REFERRING } from '../../util/model'; function create(ecModel: GlobalModel, api: ExtensionAPI) { const singles: Single[] = []; - ecModel.eachComponent('singleAxis', function (axisModel: SingleAxisModel, idx: number) { + ecModel.eachComponent(COMPONENT_TYPE_SINGLE_AXIS, function (axisModel: SingleAxisModel, idx: number) { const single = new Single(axisModel, ecModel, api); single.name = 'single_' + idx; @@ -49,11 +50,14 @@ function create(ecModel: GlobalModel, api: ExtensionAPI) { singleAxisIndex?: number singleAxisId?: string }>) { - if (seriesModel.get('coordinateSystem') === 'singleAxis') { + if (seriesModel.get('coordinateSystem') === COORD_SYS_TYPE_SINGLE_AXIS) { const singleAxisModel = seriesModel.getReferringComponents( - 'singleAxis', SINGLE_REFERRING + COMPONENT_TYPE_SINGLE_AXIS, SINGLE_REFERRING ).models[0] as SingleAxisModel; - seriesModel.coordinateSystem = singleAxisModel && singleAxisModel.coordinateSystem; + const single = seriesModel.coordinateSystem = singleAxisModel && singleAxisModel.coordinateSystem; + if (single) { + associateSeriesWithAxis(single.getAxis(), seriesModel, COORD_SYS_TYPE_SINGLE_AXIS); + } } }); diff --git a/src/core/CoordinateSystem.ts b/src/core/CoordinateSystem.ts index d3535347e3..5073a56020 100644 --- a/src/core/CoordinateSystem.ts +++ b/src/core/CoordinateSystem.ts @@ -28,13 +28,14 @@ import SeriesModel from '../model/Series'; import { error } from '../util/log'; import { CoordinateSystemDataCoord, NullUndefined } from '../util/types'; + type CoordinateSystemCreatorMap = {[type: string]: CoordinateSystemCreator}; /** * FIXME: * `nonSeriesBoxCoordSysCreators` and `_nonSeriesBoxMasterList` are hardcoded implementations. - * Regarding "coord sys layout based on another coord sys", currently we only exprimentally support one level - * dpendency, such as, "grid(cartesian)s can be laid out based on matrix/calendar coord sys." + * Regarding "coord sys layout based on another coord sys", currently we only experimentally support one level + * dependency, such as, "grid(cartesian)s can be laid out based on matrix/calendar coord sys." * But a comprehensive implementation may need to support: * - Recursive dependencies. e.g., a matrix coord sys lays out based on another matrix coord sys. * That requires in the implementation `create` and `update` of coord sys are called by a dependency graph. @@ -109,18 +110,16 @@ function canBeNonSeriesBoxCoordSys(coordSysType: string): boolean { return !!nonSeriesBoxCoordSysCreators[coordSysType]; } - -export const BoxCoordinateSystemCoordFrom = { - // By default fetch coord from `model.get('coord')`. - coord: 1, - // Some model/series, such as pie, is allowed to also get coord from `model.get('center')`, - // if cannot get from `model.get('coord')`. But historically pie use `center` option, but - // geo use `layoutCenter` option to specify layout center; they are not able to be unified. - // Therefor it is not recommended. - coord2: 2, -} as const; +// By default fetch coord from `model.get('coord')`. +export const BOX_COORD_SYS_COORD_FROM_PROP_COORD = 1 as const; +// Some model/series, such as pie, is allowed to also get coord from `model.get('center')`, +// if cannot get from `model.get('coord')`. But historically pie use `center` option, but +// geo use `layoutCenter` option to specify layout center; they are not able to be unified. +// Therefor it is not recommended. +export const BOX_COORD_SYS_COORD_FROM_PROP_COORD2 = 2 as const; export type BoxCoordinateSystemCoordFrom = - (typeof BoxCoordinateSystemCoordFrom)[keyof typeof BoxCoordinateSystemCoordFrom]; + typeof BOX_COORD_SYS_COORD_FROM_PROP_COORD + | typeof BOX_COORD_SYS_COORD_FROM_PROP_COORD2; type BoxCoordinateSystemGetCoord2 = (model: ComponentModel) => CoordinateSystemDataCoord; @@ -147,18 +146,18 @@ const coordSysUseMap = zrUtil.createHashMap< /** * @return Be an object, but never be NullUndefined. */ -export function getCoordForBoxCoordSys( +export function getCoordForCoordSysUsageKindBox( model: ComponentModel ): { coord: CoordinateSystemDataCoord | NullUndefined from: BoxCoordinateSystemCoordFrom } { let coord: CoordinateSystemDataCoord = model.getShallow('coord', true); - let from: BoxCoordinateSystemCoordFrom = BoxCoordinateSystemCoordFrom.coord; + let from: BoxCoordinateSystemCoordFrom = BOX_COORD_SYS_COORD_FROM_PROP_COORD; if (coord == null) { const store = coordSysUseMap.get(model.type); if (store && store.getCoord2) { - from = BoxCoordinateSystemCoordFrom.coord2; + from = BOX_COORD_SYS_COORD_FROM_PROP_COORD2; coord = store.getCoord2(model); } } @@ -166,22 +165,23 @@ export function getCoordForBoxCoordSys( } /** - * - "dataCoordSys": each data item is laid out based on a coord sys. - * - "boxCoordSys": the overall bounding rect or anchor point is calculated based on a coord sys. + * - `COORD_SYS_USAGE_KIND_DATA`: each data item is laid out based on a coord sys. + * - `COORD_SYS_USAGE_KIND_BOX`: the overall bounding rect or anchor point is calculated based on a coord sys. * e.g., * grid rect (cartesian rect) is calculate based on matrix/calendar coord sys; * pie center is calculated based on calendar/cartesian; * * The default value (if not declared in option `coordinateSystemUsage`): - * For series, use `dataCoordSys`, since this is the most case and backward compatible. - * For non-series components, use `boxCoordSys`, since `dataCoordSys` is not applicable. + * For series, use `COORD_SYS_USAGE_KIND_DATA`, since this is the most common case and backward compatible. + * For non-series components, use `COORD_SYS_USAGE_KIND_BOX`, since `COORD_SYS_USAGE_KIND_DATA` is not applicable. */ -export const CoordinateSystemUsageKind = { - none: 0, - dataCoordSys: 1, - boxCoordSys: 2, -} as const; -export type CoordinateSystemUsageKind = (typeof CoordinateSystemUsageKind)[keyof typeof CoordinateSystemUsageKind]; +export const COORD_SYS_USAGE_KIND_NONE = 0 as const; +export const COORD_SYS_USAGE_KIND_DATA = 1 as const; +export const COORD_SYS_USAGE_KIND_BOX = 2 as const; +export type CoordinateSystemUsageKind = + typeof COORD_SYS_USAGE_KIND_NONE + | typeof COORD_SYS_USAGE_KIND_DATA + | typeof COORD_SYS_USAGE_KIND_BOX; export function decideCoordSysUsageKind( // Component or series @@ -195,7 +195,7 @@ export function decideCoordSysUsageKind( const coordSysType = model.getShallow('coordinateSystem'); let coordSysUsageOption = model.getShallow('coordinateSystemUsage', true); const isDeclaredExplicitly = coordSysUsageOption != null; - let kind: CoordinateSystemUsageKind = CoordinateSystemUsageKind.none; + let kind: CoordinateSystemUsageKind = COORD_SYS_USAGE_KIND_NONE; if (coordSysType) { const isSeries = model.mainType === 'series'; @@ -204,18 +204,18 @@ export function decideCoordSysUsageKind( } if (coordSysUsageOption === 'data') { - kind = CoordinateSystemUsageKind.dataCoordSys; + kind = COORD_SYS_USAGE_KIND_DATA; if (!isSeries) { if (__DEV__) { if (isDeclaredExplicitly && printError) { error('coordinateSystemUsage "data" is not supported in non-series components.'); } } - kind = CoordinateSystemUsageKind.none; + kind = COORD_SYS_USAGE_KIND_NONE; } } else if (coordSysUsageOption === 'box') { - kind = CoordinateSystemUsageKind.boxCoordSys; + kind = COORD_SYS_USAGE_KIND_BOX; if (!isSeries && !canBeNonSeriesBoxCoordSys(coordSysType)) { if (__DEV__) { if (isDeclaredExplicitly && printError) { @@ -224,7 +224,7 @@ export function decideCoordSysUsageKind( ); } } - kind = CoordinateSystemUsageKind.none; + kind = COORD_SYS_USAGE_KIND_NONE; } } } @@ -234,40 +234,50 @@ export function decideCoordSysUsageKind( /** * These cases are considered: - * (A) Most series can use only "dataCoordSys", but "boxCoordSys" is not applicable: + * (A) Most series can use only "COORD_SYS_USAGE_KIND_DATA", but "COORD_SYS_USAGE_KIND_BOX" is not applicable: * - e.g., series.heatmap, series.line, series.bar, series.scatter, ... - * (B) Some series and most components can use only "boxCoordSys", but "dataCoordSys" is not applicable: + * (B) Some series and most components can use only "COORD_SYS_USAGE_KIND_BOX", but "COORD_SYS_USAGE_KIND_DATA" + * is not applicable: * - e.g., series.pie, series.funnel, ... * - e.g., grid, polar, geo, title, ... - * (C) Several series can use both "boxCoordSys" and "dataCoordSys", even at the same time: + * (C) Several series can use both "COORD_SYS_USAGE_KIND_BOX" and "COORD_SYS_USAGE_KIND_DATA", even at the same time: * - e.g., series.graph, series.map - * - If graph or map series use a "boxCoordSys", it creates a internal "dataCoordSys" to lay out its data. - * - Graph series can use matrix coord sys as either the "dataCoordSys" (each item layout on one cell) - * or "boxCoordSys" (the entire series are layout within one cell). + * - If graph or map series use "COORD_SYS_USAGE_KIND_BOX", it creates a internal coord sys as + * "COORD_SYS_USAGE_KIND_DATA" to lay out its data. + * - Graph series can use matrix coord sys as either the "COORD_SYS_USAGE_KIND_DATA" (each item layout + * on one cell) or "COORD_SYS_USAGE_KIND_BOX" (the entire series are layout within one cell). * - To achieve this effect, * `series.coordinateSystemUsage: 'box'` needs to be specified explicitly. * * Check these echarts option settings: * - If `series: {type: 'bar'}`: - * dataCoordSys: "cartesian2d", boxCoordSys: "none". + * COORD_SYS_USAGE_KIND_DATA: "cartesian2d", + * COORD_SYS_USAGE_KIND_BOX: "none". * (since `coordinateSystem: 'cartesian2d'` is the default option in bar.) * - If `grid: {coordinateSystem: 'matrix'}` - * dataCoordSys: "none", boxCoordSys: "matrix". + * COORD_SYS_USAGE_KIND_DATA: "none", + * COORD_SYS_USAGE_KIND_BOX: "matrix". * - If `series: {type: 'pie', coordinateSystem: 'matrix'}`: - * dataCoordSys: "none", boxCoordSys: "matrix". + * COORD_SYS_USAGE_KIND_DATA: "none", + * COORD_SYS_USAGE_KIND_BOX: "matrix". * (since `coordinateSystemUsage: 'box'` is the default option in pie.) * - If `series: {type: 'graph', coordinateSystem: 'matrix'}`: - * dataCoordSys: "matrix", boxCoordSys: "none" + * COORD_SYS_USAGE_KIND_DATA: "matrix", + * COORD_SYS_USAGE_KIND_BOX: "none" * - If `series: {type: 'graph', coordinateSystem: 'matrix', coordinateSystemUsage: 'box'}`: - * dataCoordSys: "an internal view", boxCoordSys: "the internal view is laid out on a matrix" + * COORD_SYS_USAGE_KIND_DATA: "an internal view", + * COORD_SYS_USAGE_KIND_BOX: "the internal view is laid out on a matrix" * - If `series: {type: 'map'}`: - * dataCoordSys: "a internal geo", boxCoordSys: "none" + * COORD_SYS_USAGE_KIND_DATA: "a internal geo", + * COORD_SYS_USAGE_KIND_BOX: "none" * - If `series: {type: 'map', coordinateSystem: 'geo', geoIndex: 0}`: - * dataCoordSys: "a geo", boxCoordSys: "none" + * COORD_SYS_USAGE_KIND_DATA: "a geo", + * COORD_SYS_USAGE_KIND_BOX: "none" * - If `series: {type: 'map', coordinateSystem: 'matrix'}`: * not_applicable * - If `series: {type: 'map', coordinateSystem: 'matrix', coordinateSystemUsage: 'box'}`: - * dataCoordSys: "an internal geo", boxCoordSys: "the internal geo is laid out on a matrix" + * COORD_SYS_USAGE_KIND_DATA: "an internal geo", + * COORD_SYS_USAGE_KIND_BOX: "the internal geo is laid out on a matrix" * * @usage * For case (A) & (B), @@ -276,8 +286,6 @@ export function decideCoordSysUsageKind( * call `injectCoordSysByOption({coordSysType: 'aaa', ...})` once for each series/components, * and then call `injectCoordSysByOption({coordSysType: 'bbb', ..., isDefaultDataCoordSys: true})` * once for each series/components. - * - * @return Whether injected. */ export function injectCoordSysByOption(opt: { // series or component @@ -286,7 +294,7 @@ export function injectCoordSysByOption(opt: { coordSysProvider: CoordSysInjectionProvider; isDefaultDataCoordSys?: boolean; allowNotFound?: boolean -}): boolean { +}): CoordinateSystemUsageKind { const { targetModel, coordSysType, @@ -301,16 +309,16 @@ export function injectCoordSysByOption(opt: { let {kind, coordSysType: declaredType} = decideCoordSysUsageKind(targetModel, true); if (isDefaultDataCoordSys - && kind !== CoordinateSystemUsageKind.dataCoordSys + && kind !== COORD_SYS_USAGE_KIND_DATA ) { - // If both dataCoordSys and boxCoordSys declared in one model. + // If both `COORD_SYS_USAGE_KIND_DATA` and `COORD_SYS_USAGE_KIND_BOX` declared in one model. // There is the only case in series-graph, and no other cases yet. - kind = CoordinateSystemUsageKind.dataCoordSys; + kind = COORD_SYS_USAGE_KIND_DATA; declaredType = coordSysType; } - if (kind === CoordinateSystemUsageKind.none || declaredType !== coordSysType) { - return false; + if (kind === COORD_SYS_USAGE_KIND_NONE || declaredType !== coordSysType) { + return COORD_SYS_USAGE_KIND_NONE; } const coordSys = coordSysProvider(coordSysType, targetModel); @@ -322,20 +330,20 @@ export function injectCoordSysByOption(opt: { ); } } - return false; + return COORD_SYS_USAGE_KIND_NONE; } - if (kind === CoordinateSystemUsageKind.dataCoordSys) { + if (kind === COORD_SYS_USAGE_KIND_DATA) { if (__DEV__) { zrUtil.assert(targetModel.mainType === 'series'); } (targetModel as SeriesModel).coordinateSystem = coordSys; } - else { // kind === 'boxCoordSys' + else { // kind === COORD_SYS_USAGE_KIND_BOX targetModel.boxCoordinateSystem = coordSys; } - return true; + return kind; } type CoordSysInjectionProvider = ( @@ -349,5 +357,4 @@ export const simpleCoordSysInjectionProvider: CoordSysInjectionProvider = functi return coordSysModel && coordSysModel.coordinateSystem; }; - export default CoordinateSystemManager; diff --git a/src/core/ExtensionAPI.ts b/src/core/ExtensionAPI.ts index 047bb704c9..5e615c9ea2 100644 --- a/src/core/ExtensionAPI.ts +++ b/src/core/ExtensionAPI.ts @@ -72,7 +72,15 @@ abstract class ExtensionAPI { abstract getViewOfComponentModel(componentModel: ComponentModel): ComponentView; abstract getViewOfSeriesModel(seriesModel: SeriesModel): ChartView; abstract getModel(): GlobalModel; - abstract getMainProcessVersion(): number; + abstract getECMainCycleVersion(): number; + /** + * PENDING: a temporary method - may be refactored. + * Whether a "threshold hoverLayer" is used. + * `true` means using hover layer due to over `hoverLayerThreshold`. + * Otherwise, if `false`, hover layer may be still used due to progressive (incremental), + * but this method does not need to cover this case. + */ + abstract usingTHL(): boolean; } export default ExtensionAPI; diff --git a/src/core/Scheduler.ts b/src/core/Scheduler.ts index 745631111d..f902f9f015 100644 --- a/src/core/Scheduler.ts +++ b/src/core/Scheduler.ts @@ -34,6 +34,7 @@ import { EChartsType } from './echarts'; import SeriesModel from '../model/Series'; import ChartView from '../view/Chart'; import SeriesData from '../data/SeriesData'; +import { ZRenderType } from 'zrender/src/zrender'; export type GeneralTask = Task; export type SeriesTask = Task; @@ -68,6 +69,8 @@ type TaskRecord = { overallTask?: OverallTask }; type PerformStageTaskOpt = { + // `block` means running from the beginning to the final end within + // an individual "progress". block?: boolean, setDirty?: boolean, visualType?: StageHandlerInternal['visualType'], @@ -96,7 +99,7 @@ interface OverallTaskContext extends TaskContext { } interface StubTaskContext extends TaskContext { model: SeriesModel; - overallProgress: boolean; + dirtyOnOverallProgress: boolean; }; class Scheduler { @@ -147,7 +150,7 @@ class Scheduler { // if a data processor depends on a component (e.g., dataZoomProcessor depends // on the settings of `dataZoom`), it should be re-performed if the component // is modified by `setOption`. - // (2) If a processor depends on sevral series, speicified by its `getTargetSeries`, + // (2) If a processor depends on several series, specified by its `getTargetSeries`, // it should be re-performed when the result array of `getTargetSeries` changed. // We use `dependencies` to cover these issues. // (3) How to update target series when coordinate system related components modified. @@ -231,12 +234,12 @@ class Scheduler { }; } - restorePipelines(ecModel: GlobalModel): void { + restorePipelines(zr: ZRenderType, ecModel: GlobalModel): void { const scheduler = this; const pipelineMap = scheduler._pipelineMap = createHashMap(); ecModel.eachSeries(function (seriesModel) { - const progressive = seriesModel.getProgressive(); + const progressive = zr.painter.type === 'canvas' && seriesModel.getProgressive(); const pipelineId = seriesModel.uid; pipelineMap.set(pipelineId, { @@ -488,15 +491,13 @@ class Scheduler { const seriesType = stageHandler.seriesType; const getTargetSeries = stageHandler.getTargetSeries; - let overallProgress = true; + const dirtyOnOverallProgress = stageHandler.dirtyOnOverallProgress; let shouldOverallTaskDirty = false; // FIXME:TS never used, so comment it // let modifyOutputEnd = stageHandler.modifyOutputEnd; // An overall task with seriesType detected or has `getTargetSeries`, we add - // stub in each pipelines, it will set the overall task dirty when the pipeline - // progress. Moreover, to avoid call the overall task each frame (too frequent), - // we set the pipeline block. + // stub in each pipelines to receive dirty info from upstream. let errMsg = ''; if (__DEV__) { errMsg = '"createOnAllSeries" is not supported for "overallReset", ' @@ -509,12 +510,7 @@ class Scheduler { else if (getTargetSeries) { getTargetSeries(ecModel, api).each(createStub); } - // Otherwise, (usually it is legacy case), the overall task will only be - // executed when upstream is dirty. Otherwise the progressive rendering of all - // pipelines will be disabled unexpectedly. But it still needs stubs to receive - // dirty info from upstream. else { - overallProgress = false; each(ecModel.getSeries(), createStub); } @@ -534,12 +530,12 @@ class Scheduler { ); stub.context = { model: seriesModel, - overallProgress: overallProgress + dirtyOnOverallProgress: dirtyOnOverallProgress // FIXME:TS never used, so comment it // modifyOutputEnd: modifyOutputEnd }; stub.agent = overallTask; - stub.__block = overallProgress; + stub.__block = dirtyOnOverallProgress; scheduler._pipe(seriesModel, stub); } @@ -586,7 +582,7 @@ function overallTaskReset(context: OverallTaskContext): void { } function stubReset(context: StubTaskContext): TaskProgressCallback { - return context.overallProgress && stubProgress; + return context.dirtyOnOverallProgress && stubProgress; } function stubProgress(this: StubTask): void { diff --git a/src/core/echarts.ts b/src/core/echarts.ts index 89f810088b..fc1add4b7f 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -139,6 +139,7 @@ import type geoSourceManager from '../coord/geo/geoSourceManager'; import { registerCustomSeries as registerCustom } from '../chart/custom/customSeriesRegister'; +import { resetCachePerECFullUpdate, resetCachePerECPrepare } from '../util/cycleCache'; declare let global: any; @@ -153,14 +154,19 @@ export const dependencies = { const TEST_FRAME_REMAIN_TIME = 1; const PRIORITY_PROCESSOR_SERIES_FILTER = 800; -// Some data processors depends on the stack result dimension (to calculate data extent). -// So data stack stage should be in front of data processing stage. +// In the current impl, "data stack" will modifies the original "series data extent". Some data +// processors rely on the stack result dimension to calculate extents. So data stack +// should be in front of other data processors. const PRIORITY_PROCESSOR_DATASTACK = 900; -// "Data filter" will block the stream, so it should be -// put at the beginning of data processing. +// AXIS_STATISTICS should be after SERIES_FILTER, as it may change the statistics result (like min gap). +// AXIS_STATISTICS should be before filter (dataZoom), as dataZoom require the result in "containShape" calculation. +const PRIORITY_PROCESSOR_AXIS_STATISTICS = 920; +// `PRIORITY_PROCESSOR_FILTER` is typically used by `dataZoom` (see `AxisProxy`), which relies +// on the initialized "axis extent". const PRIORITY_PROCESSOR_FILTER = 1000; const PRIORITY_PROCESSOR_DEFAULT = 2000; -const PRIORITY_PROCESSOR_STATISTIC = 5000; +const PRIORITY_PROCESSOR_STATISTICS = 5000; +// NOTICE: Data processors above block the stream (especially time-consuming processors like data filters). const PRIORITY_VISUAL_LAYOUT = 1000; const PRIORITY_VISUAL_PROGRESSIVE_LAYOUT = 1100; @@ -168,7 +174,7 @@ const PRIORITY_VISUAL_GLOBAL = 2000; const PRIORITY_VISUAL_CHART = 3000; const PRIORITY_VISUAL_COMPONENT = 4000; // Visual property in data. Greater than `PRIORITY_VISUAL_COMPONENT` to enable to -// overwrite the viusal result of component (like `visualMap`) +// overwrite the visual result of component (like `visualMap`) // using data item specific setting (like itemStyle.xxx on data item) const PRIORITY_VISUAL_CHART_DATA_CUSTOM = 4500; // Greater than `PRIORITY_VISUAL_CHART_DATA_CUSTOM` to enable to layout based on @@ -180,9 +186,11 @@ const PRIORITY_VISUAL_DECAL = 7000; export const PRIORITY = { PROCESSOR: { - FILTER: PRIORITY_PROCESSOR_FILTER, SERIES_FILTER: PRIORITY_PROCESSOR_SERIES_FILTER, - STATISTIC: PRIORITY_PROCESSOR_STATISTIC + AXIS_STATISTICS: PRIORITY_PROCESSOR_AXIS_STATISTICS, + FILTER: PRIORITY_PROCESSOR_FILTER, + STATISTIC: PRIORITY_PROCESSOR_STATISTICS, // naming - backward compatibility. + STATISTICS: PRIORITY_PROCESSOR_STATISTICS, }, VISUAL: { LAYOUT: PRIORITY_VISUAL_LAYOUT, @@ -198,17 +206,69 @@ export const PRIORITY = { } }; -// Main process have three entries: `setOption`, `dispatchAction` and `resize`, -// where they must not be invoked nestedly, except the only case: invoke -// dispatchAction with updateMethod "none" in main process. -// This flag is used to carry out this rule. -// All events will be triggered out side main process (i.e. when !this[IN_MAIN_PROCESS]). -const IN_MAIN_PROCESS_KEY = '__flagInMainProcess' as const; +/** + * [ec updating/rendering cycles (EC_CYCLE)] + * + * - EC_MAIN_CYCLE: + * - It designates a run of a series of processing/updating/rendering. + * - It is triggered by: + * - `setOption` + * - `dispatchAction` + * (It is typically internally triggered by user inputs; but can also an explicit API call.) + * - `resize` + * - The next "animation frame" if in `lazyMode: true`. + * - Nested entry is not allowed. If triggering a new run of EC_MAIN_CYCLE during a + * unfinished run, the new run will be delayed until the current run finishes + * (if triggered by `dispatchAction`), or throw error (if triggered by other API calls). + * - All user-visible ec events are triggered outside EC_MAIN_CYCLE + * (i.e. be triggered after `this[IN_EC_MAIN_CYCLE_KEY]` becoming `false`). + * - A run of EC_MAIN_CYCLE comprises: + * - EC_PREPARE_UPDATE (may be absent) + * - EC_FULL_UPDATE or EC_PARTIAL_UPDATE + * - A run of EC_FULL_UPDATE comprises: + * - CoordinateSystem['create'] + * - Data processing (may be absent) (see `registerProcessor`) + * - CoordinateSystem['update'] (may be absent) + * - Visual encoding (may be absent) (see `registerVisual`) + * - Layout (may be absent) (see `registerLayout`) + * - Rendering (`ComponentView` or `SeriesView`) + * + * - EC_PROGRESSIVE_CYCLE: + * - It also carries out a series of processing/updating/rendering, but out of EC_MAIN_CYCLE. + * - It is performed in each subsequent "animation frame" until finished. + * - It can be triggered by EC_MAIN_CYCLE or EC_APPEND_DATA_CYCLE. + * - A run of EC_PROGRESSIVE_CYCLE comprises: + * - Data processing (may be absent) (see `registerProcessor`) + * - Visual encoding (may be absent) (see `registerVisual`) + * - Layout (may be absent) (see `registerLayout`) + * - Rendering (`ComponentView` or `SeriesView`) + * - PENDING: currently all data processing tasks (via `registerProcessor`) run in "block" mode. + * (see `performDataProcessorTasks`). + * + * - Other updating/rendering cycles: + * - EC_APPEND_DATA_CYCLE (see `appendData`) is only supported for some special cases. + * - Some series have specific update/render cycles. For example, graph force layout performs + * layout and rendering in each "animation frame". + * + * - Model updating: + * - Model can only be modified at the beginning of ec cycles, including only: + * - EC_PREPARE_UPDATE (see method `prepare()`) in `setOption` call. + * - EC action handlers in `dispatchAction` call. + * - `appendData` (a special case, where only data can be modified). + * + * - The lifetime of CoordinateSystem/Axis/Scale instances: + * - They are only re-created per run of EC_FULL_UPDATE in EC_MAIN_CYCLE. + * + * - Global caches: see `cycleCache.ts` + */ + +// See comments in EC_CYCLE. +const IN_EC_MAIN_CYCLE_KEY = '__flagInMainProcess' as const; // Useful for detecting outdated rendering results in scenarios that these issues are involved: -// - Use shortcut (such as, updateTransform, or no update) to start a main process. +// - Use EC_PARTIAL_UPDATE (such as, updateTransform, or no update) to start an EC_MAIN_CYCLE. // - Asynchronously update rendered view (e.g., graph force layout). // - Multiple ChartView/ComponentView render to one group cooperatively. -const MAIN_PROCESS_VERSION_KEY = '__mainProcessVersion' as const; +const EC_MAIN_CYCLE_VERSION_KEY = '__mainProcessVersion' as const; const PENDING_UPDATE = '__pendingUpdate' as const; const STATUS_NEEDS_UPDATE_KEY = '__needsUpdateStatus' as const; const ACTION_REG = /^[a-zA-Z0-9_]+$/; @@ -250,6 +310,11 @@ interface PostIniter { (chart: EChartsType): void } +const ecInner = modelUtil.makeInner<{ + // Using hoverLayer due to over hoverLayerThreshold. + usingTHL: boolean; +}, ECharts>(); + type EventMethodName = 'on' | 'off'; function createRegisterEventWithLowercaseECharts(method: EventMethodName) { return function (this: ECharts, ...args: any): ECharts { @@ -280,7 +345,7 @@ messageCenterProto.off = createRegisterEventWithLowercaseMessageCenter('off'); // --------------------------------------- // Internal method names for class ECharts // --------------------------------------- -let prepare: (ecIns: ECharts) => void; +let prepare: (ecIns: ECharts) => void; // This is `EC_PREPARE_UPDATE`. let prepareView: (ecIns: ECharts, isComponent: boolean) => void; let updateDirectly: ( ecIns: ECharts, method: string, payload: Payload, mainType: ComponentMainType, subType?: ComponentSubType @@ -288,11 +353,11 @@ let updateDirectly: ( type UpdateMethod = (this: ECharts, payload?: Payload, renderParams?: UpdateLifecycleParams) => void; let updateMethods: { prepareAndUpdate: UpdateMethod, - update: UpdateMethod, - updateTransform: UpdateMethod, - updateView: UpdateMethod, - updateVisual: UpdateMethod, - updateLayout: UpdateMethod + update: UpdateMethod, // This is `EC_FULL_UPDATE`. + updateTransform: UpdateMethod, // This is one of `EC_PARTIAL_UPDATE`. + updateView: UpdateMethod, // This is one of `EC_PARTIAL_UPDATE`. + updateVisual: UpdateMethod, // This is one of `EC_PARTIAL_UPDATE`. + updateLayout: UpdateMethod // This is one of `EC_PARTIAL_UPDATE`. }; let doConvertPixel: { ( @@ -340,7 +405,7 @@ let enableConnect: (ecIns: ECharts) => void; let markStatusToUpdate: (ecIns: ECharts) => void; let applyChangedStates: (ecIns: ECharts) => void; -let updateMainProcessVersion: (ecIns: ECharts) => void; +let updateECMainCycleVersion: (ecIns: ECharts) => void; type RenderedEventParam = { elapsedTime: number }; type ECEventDefinition = { @@ -422,8 +487,8 @@ class ECharts extends Eventful { silent: boolean updateParams: UpdateLifecycleParams }; - private [IN_MAIN_PROCESS_KEY]: boolean; - private [MAIN_PROCESS_VERSION_KEY]: number; + private [IN_EC_MAIN_CYCLE_KEY]: boolean; + private [EC_MAIN_CYCLE_VERSION_KEY]: number; private [CONNECT_STATUS_KEY]: ConnectStatus; private [STATUS_NEEDS_UPDATE_KEY]: boolean; @@ -446,7 +511,7 @@ class ECharts extends Eventful { let defaultCoarsePointer: 'auto' | boolean = 'auto'; let defaultUseDirtyRect = false; - this[MAIN_PROCESS_VERSION_KEY] = 1; + this[EC_MAIN_CYCLE_VERSION_KEY] = 1; if (__DEV__) { const root = ( @@ -540,15 +605,15 @@ class ECharts extends Eventful { if (this[PENDING_UPDATE]) { const silent = (this[PENDING_UPDATE] as any).silent; - this[IN_MAIN_PROCESS_KEY] = true; - updateMainProcessVersion(this); + this[IN_EC_MAIN_CYCLE_KEY] = true; + updateECMainCycleVersion(this); try { prepare(this); updateMethods.update.call(this, null, this[PENDING_UPDATE].updateParams); } catch (e) { - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; this[PENDING_UPDATE] = null; throw e; } @@ -561,7 +626,7 @@ class ECharts extends Eventful { // will render the final state of the elements before the real animation started. this._zr.flush(); - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; this[PENDING_UPDATE] = null; flushPendingActions.call(this, silent); @@ -644,7 +709,7 @@ class ECharts extends Eventful { setOption(option: Opt, opts?: SetOptionOpts): void; /* eslint-disable-next-line */ setOption(option: Opt, notMerge?: boolean | SetOptionOpts, lazyUpdate?: boolean): void { - if (this[IN_MAIN_PROCESS_KEY]) { + if (this[IN_EC_MAIN_CYCLE_KEY]) { if (__DEV__) { error('`setOption` should not be called during main process.'); } @@ -667,8 +732,8 @@ class ECharts extends Eventful { notMerge = notMerge.notMerge; } - this[IN_MAIN_PROCESS_KEY] = true; - updateMainProcessVersion(this); + this[IN_EC_MAIN_CYCLE_KEY] = true; + updateECMainCycleVersion(this); if (!this._model || notMerge) { const optionManager = new OptionManager(this._api); @@ -691,7 +756,7 @@ class ECharts extends Eventful { silent: silent, updateParams: updateParams }; - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; // `setOption(option, {lazyMode: true})` may be called when zrender has been slept. // It should wake it up to make sure zrender start to render at the next frame. @@ -704,7 +769,7 @@ class ECharts extends Eventful { } catch (e) { this[PENDING_UPDATE] = null; - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; throw e; } @@ -717,7 +782,7 @@ class ECharts extends Eventful { } this[PENDING_UPDATE] = null; - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; flushPendingActions.call(this, silent); triggerUpdatedEvent.call(this, silent); @@ -730,7 +795,7 @@ class ECharts extends Eventful { * @param opts Optional settings */ setTheme(theme: string | ThemeOption, opts?: SetThemeOpts): void { - if (this[IN_MAIN_PROCESS_KEY]) { + if (this[IN_EC_MAIN_CYCLE_KEY]) { if (__DEV__) { error('`setTheme` should not be called during main process.'); } @@ -758,8 +823,8 @@ class ECharts extends Eventful { this[PENDING_UPDATE] = null; } - this[IN_MAIN_PROCESS_KEY] = true; - updateMainProcessVersion(this); + this[IN_EC_MAIN_CYCLE_KEY] = true; + updateECMainCycleVersion(this); try { this._updateTheme(theme); @@ -769,11 +834,11 @@ class ECharts extends Eventful { updateMethods.update.call(this, {type: 'setTheme'}, updateParams); } catch (e) { - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; throw e; } - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; flushPendingActions.call(this, silent); triggerUpdatedEvent.call(this, silent); @@ -1352,7 +1417,7 @@ class ECharts extends Eventful { * Resize the chart */ resize(opts?: ResizeOpts): void { - if (this[IN_MAIN_PROCESS_KEY]) { + if (this[IN_EC_MAIN_CYCLE_KEY]) { if (__DEV__) { error('`resize` should not be called during main process.'); } @@ -1390,8 +1455,8 @@ class ECharts extends Eventful { this[PENDING_UPDATE] = null; } - this[IN_MAIN_PROCESS_KEY] = true; - updateMainProcessVersion(this); + this[IN_EC_MAIN_CYCLE_KEY] = true; + updateECMainCycleVersion(this); try { needPrepare && prepare(this); @@ -1404,11 +1469,11 @@ class ECharts extends Eventful { }); } catch (e) { - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; throw e; } - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; flushPendingActions.call(this, silent); @@ -1502,7 +1567,7 @@ class ECharts extends Eventful { } // May dispatchAction in rendering procedure - if (this[IN_MAIN_PROCESS_KEY]) { + if (this[IN_EC_MAIN_CYCLE_KEY]) { this._pendingActions.push(payload); return; } @@ -1555,13 +1620,13 @@ class ECharts extends Eventful { seriesModel.appendData(params); - // Note: `appendData` does not support that update extent of coordinate - // system, util some scenario require that. In the expected usage of - // `appendData`, the initial extent of coordinate system should better - // be fixed by axis `min`/`max` setting or initial data, otherwise if - // the extent changed while `appendData`, the location of the painted - // graphic elements have to be changed, which make the usage of - // `appendData` meaningless. + // NOTICE: + // `appendData` does not support to update axis scale extent of coordinate + // systems. In the expected usage of `appendData`, the initial extent of + // coordinate system should be explicitly specified (by `xxxAxis.data` for + // 'category' axis or by `xxxAxis.min/max` for other axes). Otherwise, if + // the extent keep changing while `appendData`, the location of the painted + // graphic elements have to be changed frequently. this._scheduler.unfinished = true; @@ -1574,9 +1639,11 @@ class ECharts extends Eventful { private static internalField = (function () { prepare = function (ecIns: ECharts): void { + resetCachePerECPrepare(ecIns._model); + const scheduler = ecIns._scheduler; - scheduler.restorePipelines(ecIns._model); + scheduler.restorePipelines(ecIns._zr, ecIns._model); scheduler.prepareStageTasks(); prepareView(ecIns, true); @@ -1799,6 +1866,7 @@ class ECharts extends Eventful { return; } + resetCachePerECFullUpdate(ecModel); ecModel.setUpdatePayload(payload); scheduler.restoreData(ecModel, payload); @@ -1813,6 +1881,8 @@ class ECharts extends Eventful { // In LineView may save the old coordinate system and use it to get the original point. coordSysMgr.create(ecModel, api); + lifecycle.trigger('coordsys:aftercreate', ecModel, api); + scheduler.performDataProcessorTasks(ecModel, payload); // Current stream render is not supported in data process. So we can update @@ -2042,8 +2112,8 @@ class ECharts extends Eventful { const updateMethod = cptTypeTmp.pop(); const cptType = cptTypeTmp[0] != null && parseClassType(cptTypeTmp[0]); - this[IN_MAIN_PROCESS_KEY] = true; - updateMainProcessVersion(this); + this[IN_EC_MAIN_CYCLE_KEY] = true; + updateECMainCycleVersion(this); let payloads: Payload[] = [payload]; let batched = false; @@ -2114,7 +2184,7 @@ class ECharts extends Eventful { } } catch (e) { - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; throw e; } } @@ -2131,7 +2201,7 @@ class ECharts extends Eventful { eventObj = eventObjBatch[0] as ECActionEvent; } - this[IN_MAIN_PROCESS_KEY] = false; + this[IN_EC_MAIN_CYCLE_KEY] = false; if (!silent) { let refinedEvent: ECActionEvent; @@ -2426,8 +2496,8 @@ class ECharts extends Eventful { ecIns.getZr().wakeUp(); }; - updateMainProcessVersion = function (ecIns: ECharts): void { - ecIns[MAIN_PROCESS_VERSION_KEY] = (ecIns[MAIN_PROCESS_VERSION_KEY] + 1) % 1000; + updateECMainCycleVersion = function (ecIns: ECharts): void { + ecIns[EC_MAIN_CYCLE_VERSION_KEY] = (ecIns[EC_MAIN_CYCLE_VERSION_KEY] + 1) % 1000; }; applyChangedStates = function (ecIns: ECharts): void { @@ -2473,6 +2543,11 @@ class ECharts extends Eventful { function updateHoverLayerStatus(ecIns: ECharts, ecModel: GlobalModel): void { const zr = ecIns._zr; + + if (zr.painter.type !== 'canvas') { + return; + } + const storage = zr.storage; let elCount = 0; @@ -2482,7 +2557,10 @@ class ECharts extends Eventful { } }); - if (elCount > ecModel.get('hoverLayerThreshold') && !env.node && !env.worker) { + const inner = ecInner(ecIns); + const shouldUseHoverLayer = elCount > ecModel.get('hoverLayerThreshold') && !env.node && !env.worker; + + if (inner.usingTHL || shouldUseHoverLayer) { ecModel.eachSeries(function (seriesModel) { if (seriesModel.preventUsingHoverLayer) { return; @@ -2490,12 +2568,16 @@ class ECharts extends Eventful { const chartView = ecIns._chartsMap[seriesModel.__viewId]; if (chartView.__alive) { chartView.eachRendered((el: ECElement) => { - if (el.states.emphasis) { - el.states.emphasis.hoverLayer = true; + const emphasis = el.states.emphasis; + if (emphasis && emphasis.hoverLayer !== graphic.HOVER_LAYER_FOR_INCREMENTAL) { + emphasis.hoverLayer = shouldUseHoverLayer + ? graphic.HOVER_LAYER_FROM_THRESHOLD + : graphic.HOVER_LAYER_NO; } }); } }); + inner.usingTHL = shouldUseHoverLayer; } }; @@ -2658,8 +2740,11 @@ class ECharts extends Eventful { getViewOfSeriesModel(seriesModel: SeriesModel): ChartView { return ecIns.getViewOfSeriesModel(seriesModel); } - getMainProcessVersion(): number { - return ecIns[MAIN_PROCESS_VERSION_KEY]; + getECMainCycleVersion(): number { + return ecIns[EC_MAIN_CYCLE_VERSION_KEY]; + } + usingTHL(): boolean { + return ecInner(ecIns).usingTHL; } })(ecIns); }; @@ -2922,6 +3007,9 @@ export function registerPreprocessor(preprocessorFunc: OptionPreprocessor): void } } +/** + * NOTICE: Alway run in block way (no progessive is allowed). + */ export function registerProcessor( priority: number | StageHandler | StageHandlerOverallReset, processor?: StageHandler | StageHandlerOverallReset diff --git a/src/core/lifecycle.ts b/src/core/lifecycle.ts index 933aca567f..f42d8b38ba 100644 --- a/src/core/lifecycle.ts +++ b/src/core/lifecycle.ts @@ -54,6 +54,7 @@ export interface UpdateLifecycleParams { } interface LifecycleEvents { 'afterinit': [EChartsType], + 'coordsys:aftercreate': [GlobalModel, ExtensionAPI], 'series:beforeupdate': [GlobalModel, ExtensionAPI, UpdateLifecycleParams], 'series:layoutlabels': [GlobalModel, ExtensionAPI, UpdateLifecycleParams], 'series:transition': [GlobalModel, ExtensionAPI, UpdateLifecycleParams], diff --git a/src/core/task.ts b/src/core/task.ts index 6a25c1c127..20a59d3fb6 100644 --- a/src/core/task.ts +++ b/src/core/task.ts @@ -112,7 +112,7 @@ export class Task { // Injected in schedular __pipeline: Pipeline; __idxInPipeline: number; - __block: boolean; + __block: boolean; // FIXME: simplify it - merge with PerformStageTaskOpt['block']? // Context must be specified implicitly, to // avoid miss update context when model changed. @@ -242,6 +242,14 @@ export class Task { return this.unfinished(); } + /** + * Generally, task dirty propagates to downstream tasks. + * Task dirty leads to the `reset` call, which discards the previous result and starts over + * the processing. + * + * See `StageHandler['reset']` and `StageHandler['overallReset']` for a summary of possible + * `dirty()` calls. + */ dirty(): void { this._dirty = true; this._onDirty && this._onDirty(this.context); diff --git a/src/data/DataStore.ts b/src/data/DataStore.ts index 9bcc88e9a7..0d9e8836d3 100644 --- a/src/data/DataStore.ts +++ b/src/data/DataStore.ts @@ -21,24 +21,29 @@ import { assert, clone, createHashMap, isFunction, keys, map, reduce } from 'zre import { DimensionIndex, DimensionName, + NullUndefined, OptionDataItem, ParsedValue, - ParsedValueNumeric + ParsedValueNumeric, + UNDEFINED_STR } from '../util/types'; import { DataProvider } from './helper/dataProvider'; -import { parseDataValue } from './helper/dataValueHelper'; +import { + DataSanitizationFilter, parseDataValue, parseSanitizationFilter, passesSanitizationFilter +} from './helper/dataValueHelper'; import OrdinalMeta from './OrdinalMeta'; import { shouldRetrieveDataByName, Source } from './Source'; +import { initExtentForUnion } from '../util/model'; +import { asc } from '../util/number'; -const UNDEFINED = 'undefined'; /* global Float64Array, Int32Array, Uint32Array, Uint16Array */ // Caution: MUST not use `new CtorUint32Array(arr, 0, len)`, because the Ctor of array is // different from the Ctor of typed array. -export const CtorUint32Array = typeof Uint32Array === UNDEFINED ? Array : Uint32Array; -export const CtorUint16Array = typeof Uint16Array === UNDEFINED ? Array : Uint16Array; -export const CtorInt32Array = typeof Int32Array === UNDEFINED ? Array : Int32Array; -export const CtorFloat64Array = typeof Float64Array === UNDEFINED ? Array : Float64Array; +export const CtorUint32Array = typeof Uint32Array === UNDEFINED_STR ? Array : Uint32Array; +export const CtorUint16Array = typeof Uint16Array === UNDEFINED_STR ? Array : Uint16Array; +export const CtorInt32Array = typeof Int32Array === UNDEFINED_STR ? Array : Int32Array; +export const CtorFloat64Array = typeof Float64Array === UNDEFINED_STR ? Array : Float64Array; /** * Multi dimensional data store */ @@ -115,9 +120,7 @@ function getIndicesCtor(rawCount: number): DataArrayLikeConstructor { // The possible max value in this._indicies is always this._rawCount despite of filtering. return rawCount > 65535 ? CtorUint32Array : CtorUint16Array; }; -function getInitialExtent(): [number, number] { - return [Infinity, -Infinity]; -}; + function cloneChunk(originalChunk: DataValueChunk): DataValueChunk { const Ctor = originalChunk.constructor; // Only shallow clone is enough when Array. @@ -164,7 +167,9 @@ class DataStore { // It will not be calculated until needed. private _rawExtent: [number, number][] = []; - private _extent: [number, number][] = []; + // structure: + // `const extentOnFilterOnDimension = this._extent[dim][extentFilterKey]` + private _extent: Record[] = []; // Indices stores the indices of data subset after filtered. // This data subset will be used in chart. @@ -263,7 +268,7 @@ class DataStore { calcDimNameToIdx.set(dimName, calcDimIdx); this._chunks[calcDimIdx] = new dataCtors[type || 'float'](this._rawCount); - this._rawExtent[calcDimIdx] = getInitialExtent(); + this._rawExtent[calcDimIdx] = initExtentForUnion(); return calcDimIdx; } @@ -282,7 +287,7 @@ class DataStore { if (offset === 0) { // We need to reset the rawExtent if collect is from start. // Because this dimension may be guessed as number and calcuating a wrong extent. - rawExtents[dimIdx] = getInitialExtent(); + rawExtents[dimIdx] = initExtentForUnion(); } const dimRawExtent = rawExtents[dimIdx]; @@ -386,7 +391,7 @@ class DataStore { for (let i = 0; i < dimLen; i++) { const dim = dimensions[i]; if (!rawExtent[i]) { - rawExtent[i] = getInitialExtent(); + rawExtent[i] = initExtentForUnion(); } prepareStore(chunks, i, dim.type, end, append); } @@ -504,7 +509,7 @@ class DataStore { * Get median of data in one dimension */ getMedian(dim: DimensionIndex): number { - const dimDataArray: ParsedValue[] = []; + const dimDataArray: number[] = []; // map all data of one dimension this.each([dim], function (val) { if (!isNaN(val as number)) { @@ -514,16 +519,14 @@ class DataStore { // TODO // Use quick select? - const sortedDimDataArray = dimDataArray.sort(function (a: number, b: number) { - return a - b; - }) as number[]; + asc(dimDataArray); const len = this.count(); // calculate median return len === 0 ? 0 : len % 2 === 1 - ? sortedDimDataArray[(len - 1) / 2] - : (sortedDimDataArray[len / 2] + sortedDimDataArray[len / 2 - 1]) / 2; + ? dimDataArray[(len - 1) / 2] + : (dimDataArray[len / 2] + dimDataArray[len / 2 - 1]) / 2; } /** @@ -596,7 +599,7 @@ class DataStore { } /** - * Data filter. + * [NOTICE]: Performance-sensitive for large data. */ filter( dims: DimensionIndex[], @@ -817,7 +820,7 @@ class DataStore { const rawExtent = target._rawExtent; for (let i = 0; i < dims.length; i++) { - rawExtent[dims[i]] = getInitialExtent(); + rawExtent[dims[i]] = initExtentForUnion(); } for (let dataIndex = 0; dataIndex < dataCount; dataIndex++) { @@ -830,7 +833,7 @@ class DataStore { let retValue = cb && cb.apply(null, values); if (retValue != null) { - // a number or string (in oridinal dimension)? + // a number or string (in ordinal dimension)? if (typeof retValue !== 'object') { tmpRetValue[0] = retValue; retValue = tmpRetValue; @@ -1044,7 +1047,7 @@ class DataStore { const dimStore = targetStorage[dimension]; const len = this.count(); - const rawExtentOnDim = target._rawExtent[dimension] = getInitialExtent(); + const rawExtentOnDim = target._rawExtent[dimension] = initExtentForUnion(); const newIndices = new (getIndicesCtor(this._rawCount))(Math.ceil(len / frameSize)); @@ -1127,13 +1130,13 @@ class DataStore { } } - /** - * Get extent of data in one dimension - */ - getDataExtent(dim: DimensionIndex): [number, number] { + getDataExtent( + dim: DimensionIndex, + filter: DataSanitizationFilter | NullUndefined + ): [number, number] { // Make sure use concrete dim as cache name. const dimData = this._chunks[dim]; - const initialExtent = getInitialExtent(); + const initialExtent = initExtentForUnion(); if (!dimData) { return initialExtent; @@ -1145,33 +1148,49 @@ class DataStore { // Consider the most cases when using data zoom, `getDataExtent` // happened before filtering. We cache raw extent, which is not // necessary to be cleared and recalculated when restore data. - const useRaw = !this._indices; - let dimExtent: [number, number]; - + const useRaw = !this._indices && !filter; if (useRaw) { return this._rawExtent[dim].slice() as [number, number]; } - dimExtent = this._extent[dim]; + + // NOTE: + // - In logarithm axis, zero should be excluded, therefore the `extent[0]` should be less or equal + // than the min positive data item, which requires the special handling here. + // - "Filter non-positive values for logarithm axis" can also be implemented in a data processor + // but that requires more complicated code to not break all streams under the current architecture, + // therefore we simply implement it here. + // - Performance is sensitive for large data, therefore inline filters rather than cb is used here. + + const thisExtent = this._extent; + const dimExtentRecord = thisExtent[dim] || (thisExtent[dim] = {}); + + const filterParsed = parseSanitizationFilter(filter); + const filterKey = filterParsed.key; + + const dimExtent = dimExtentRecord[filterKey]; if (dimExtent) { return dimExtent.slice() as [number, number]; } - dimExtent = initialExtent; - let min = dimExtent[0]; - let max = dimExtent[1]; + let min = initialExtent[0]; + let max = initialExtent[1]; for (let i = 0; i < currEnd; i++) { + // NOTICE: Manually inline some code for performance of large data. const rawIdx = this.getRawIndex(i); const value = dimData[rawIdx] as ParsedValueNumeric; - value < min && (min = value); - value > max && (max = value); + // NOTE: in most cases, filter does not exist. + if (!filter || passesSanitizationFilter(filterParsed, value)) { + if (value < min) { + min = value; + } + if (value > max) { + max = value; + } + } } - dimExtent = [min, max]; - - this._extent[dim] = dimExtent; - - return dimExtent; + return (dimExtentRecord[filterKey] = [min, max]); } /** diff --git a/src/data/SeriesData.ts b/src/data/SeriesData.ts index 4e0663227b..1cfde0407f 100644 --- a/src/data/SeriesData.ts +++ b/src/data/SeriesData.ts @@ -27,7 +27,7 @@ import DataDiffer from './DataDiffer'; import {DataProvider, DefaultDataProvider} from './helper/dataProvider'; import {summarizeDimensions, DimensionSummary} from './helper/dimensionHelper'; import SeriesDimensionDefine from './SeriesDimensionDefine'; -import {ArrayLike, Dictionary, FunctionPropertyNames} from 'zrender/src/core/types'; +import {ArrayLike, Dictionary, FunctionPropertyNames, NullUndefined} from 'zrender/src/core/types'; import Element from 'zrender/src/Element'; import { DimensionIndex, DimensionName, DimensionLoose, OptionDataItem, @@ -46,6 +46,7 @@ import {isSourceInstance, Source} from './Source'; import { LineStyleProps } from '../model/mixin/lineStyle'; import DataStore, { DataStoreDimensionDefine, DimValueGetter } from './DataStore'; import { isSeriesDataSchema, SeriesDataSchema } from './helper/SeriesDataSchema'; +import { DataSanitizationFilter } from './helper/dataValueHelper'; const isObject = zrUtil.isObject; const map = zrUtil.map; @@ -672,21 +673,16 @@ class SeriesData< } /** - * PENDING: In fact currently this function is only used to short-circuit - * the calling of `scale.unionExtentFromData` when data have been filtered by modules - * like "dataZoom". `scale.unionExtentFromData` is used to calculate data extent for series on - * an axis, but if a "axis related data filter module" is used, the extent of the axis have - * been fixed and no need to calling `scale.unionExtentFromData` actually. - * But if we add "custom data filter" in future, which is not "axis related", this method may - * be still needed. - * * Optimize for the scenario that data is filtered by a given extent. * Consider that if data amount is more than hundreds of thousand, * extent calculation will cost more than 10ms and the cache will * be erased because of the filtering. */ - getApproximateExtent(dim: SeriesDimensionLoose): [number, number] { - return this._approximateExtent[dim] || this._store.getDataExtent(this._getStoreDimIndex(dim)); + getApproximateExtent( + dim: SeriesDimensionLoose, + filter: DataSanitizationFilter | NullUndefined + ): [number, number] { + return this._approximateExtent[dim] || this._store.getDataExtent(this._getStoreDimIndex(dim), filter); } /** @@ -793,7 +789,7 @@ class SeriesData< } getDataExtent(dim: DimensionLoose): [number, number] { - return this._store.getDataExtent(this._getStoreDimIndex(dim)); + return this._store.getDataExtent(this._getStoreDimIndex(dim), null); } getSum(dim: DimensionLoose): number { diff --git a/src/data/SeriesDimensionDefine.ts b/src/data/SeriesDimensionDefine.ts index 1ce09813a0..9d2376e395 100644 --- a/src/data/SeriesDimensionDefine.ts +++ b/src/data/SeriesDimensionDefine.ts @@ -51,8 +51,8 @@ class SeriesDimensionDefine { * 1. When there are too many dimensions in data store, seriesData only save the * used store dimensions. * 2. We use dimensionIndex but not name to reference store dimension - * becuause the dataset dimension definition might has no name specified by users, - * or names in sereis dimension definition might be different from dataset. + * because the dataset dimension definition might has no name specified by users, + * or names in series dimension definition might be different from dataset. */ storeDimIndex?: number; diff --git a/src/data/helper/createDimensions.ts b/src/data/helper/createDimensions.ts index b8f8e8a9ef..ba88b6aa4c 100644 --- a/src/data/helper/createDimensions.ts +++ b/src/data/helper/createDimensions.ts @@ -34,7 +34,7 @@ import { import OrdinalMeta from '../OrdinalMeta'; import { createSourceFromSeriesDataOption, isSourceInstance, Source } from '../Source'; import { CtorInt32Array } from '../DataStore'; -import { normalizeToArray } from '../../util/model'; +import { normalizeToArray, removeDuplicates } from '../../util/model'; import { BE_ORDINAL, guessOrdinal } from './sourceHelper'; import { createDimNameMap, ensureSourceDimNameMap, SeriesDataSchema, shouldOmitUnusedDimensions @@ -340,7 +340,18 @@ export default function prepareSeriesDataSchema( resultList.sort((item0, item1) => item0.storeDimIndex - item1.storeDimIndex); } - removeDuplication(resultList); + removeDuplicates( + resultList, + function (item) { + return item.name; + }, + function (item, existingCount) { + if (existingCount > 0) { + // Starts from 0. + item.name = item.name + (existingCount - 1); + } + } + ); return new SeriesDataSchema({ source, @@ -350,21 +361,6 @@ export default function prepareSeriesDataSchema( }); } -function removeDuplication(result: SeriesDimensionDefine[]) { - const duplicationMap = createHashMap(); - for (let i = 0; i < result.length; i++) { - const dim = result[i]; - const dimOriginalName = dim.name; - let count = duplicationMap.get(dimOriginalName) || 0; - if (count > 0) { - // Starts from 0. - dim.name = dimOriginalName + (count - 1); - } - count++; - duplicationMap.set(dimOriginalName, count); - } -} - // ??? TODO // Originally detect dimCount by data[0]. Should we // optimize it to only by sysDims and dimensions and encode. diff --git a/src/data/helper/dataStackHelper.ts b/src/data/helper/dataStackHelper.ts index 549820c912..206d47fc4b 100644 --- a/src/data/helper/dataStackHelper.ts +++ b/src/data/helper/dataStackHelper.ts @@ -87,12 +87,17 @@ export function enableDataStack( store = dimensionsInput.store; } - // Compatibal: when `stack` is set as '', do not stack. + // compatible: when `stack` is set as '', do not stack. const mayStack = !!(seriesModel && seriesModel.get('stack')); let stackedByDimInfo: SeriesDimensionDefine; let stackedDimInfo: SeriesDimensionDefine; let stackResultDimension: string; let stackedOverDimension: string; + let allDimTypesAreNotOrdinalAndTime = true; + + function dimTypeIsNotOrdinalAndTime(dimensionInfo: SeriesDimensionDefine): boolean { + return dimensionInfo.type !== 'ordinal' && dimensionInfo.type !== 'time'; + } each(dimensionDefineList, function (dimensionInfo, index) { if (isString(dimensionInfo)) { @@ -100,7 +105,12 @@ export function enableDataStack( name: dimensionInfo as string } as SeriesDimensionDefine; } + if (!dimTypeIsNotOrdinalAndTime(dimensionInfo)) { + allDimTypesAreNotOrdinalAndTime = false; + } + }); + each(dimensionDefineList, function (dimensionInfo: SeriesDimensionDefine, index) { if (mayStack && !dimensionInfo.isExtraCoord) { // Find the first ordinal dimension as the stackedByDimInfo. if (!byIndex && !stackedByDimInfo && dimensionInfo.ordinalMeta) { @@ -108,8 +118,17 @@ export function enableDataStack( } // Find the first stackable dimension as the stackedDimInfo. if (!stackedDimInfo - && dimensionInfo.type !== 'ordinal' - && dimensionInfo.type !== 'time' + && dimTypeIsNotOrdinalAndTime(dimensionInfo) + // FIXME: + // This rule MUST be consistent with `Cartesian2D['getBaseAxis']` and `Polar['getBaseAxis']` + // Need refactor - merge them! + // See comments in `Cartesian2D['getBaseAxis']` for details. + && (!allDimTypesAreNotOrdinalAndTime + || ( + dimensionInfo.coordDim !== 'x' + && dimensionInfo.coordDim !== 'angle' + ) + ) && (!stackedCoordDimension || stackedCoordDimension === dimensionInfo.coordDim) ) { stackedDimInfo = dimensionInfo; @@ -123,9 +142,6 @@ export function enableDataStack( byIndex = true; } - // Add stack dimension, they can be both calculated by coordinate system in `unionExtent`. - // That put stack logic in List is for using conveniently in echarts extensions, but it - // might not be a good way. if (stackedDimInfo) { // Use a weird name that not duplicated with other names. // Also need to use seriesModel.id as postfix because different diff --git a/src/data/helper/dataValueHelper.ts b/src/data/helper/dataValueHelper.ts index 18764920fa..a45b904457 100644 --- a/src/data/helper/dataValueHelper.ts +++ b/src/data/helper/dataValueHelper.ts @@ -17,12 +17,14 @@ * under the License. */ -import { ParsedValue, DimensionType } from '../../util/types'; +import { ParsedValue, DimensionType, NullUndefined } from '../../util/types'; import { parseDate, numericToNumber } from '../../util/number'; import { createHashMap, trim, hasOwn, isString, isNumber } from 'zrender/src/core/util'; import { throwError } from '../../util/log'; +// --------- START: Parsers -------- + /** * Convert raw the value in to inner value in List. * @@ -95,8 +97,12 @@ export function getRawValueParser(type: RawValueParserType): RawValueParser { return valueParserMap.get(type); } +// --------- END: Parsers --------- + +// --------- START: Data transformattion filters --------- +// (comprehensive and performance insensitive) export interface FilterComparator { evaluate(val: unknown): boolean; @@ -261,3 +267,68 @@ export function createFilterComparator( ? new FilterOrderComparator(op as OrderRelationOperator, rval) : null; } + +// --------- END: Data transformattion filters --------- + + + +// --------- START: Data store sanitization filters --------- +// (simple and performance sensitive) + +// g: greater than, ge: greater equal, l: less than, le: less equal +export type DataSanitizationFilter = {g?: number; ge?: number; l?: number; le?: number;}; +type DataSanitizationFilterParsed = {key: string; g: number; ge: number; l: number; le: number;}; + +/** + * @usage + * const filterParsed = parseSanitizationFilter(filter); + * for( ... ) { + * const val = ...; + * if (!filter || passesFilter(filterParsed, val)) { + * // normal handling + * } + * } + */ +export function parseSanitizationFilter( + filter: DataSanitizationFilter | NullUndefined +): DataSanitizationFilterParsed { + let filterKey = ''; + let filterG = -Infinity; + let filterGE = -Infinity; + let filterL = Infinity; + let filterLE = Infinity; + if (filter) { + if (filter.g != null) { + filterKey += 'G' + filter.g; + filterG = filter.g; + } + if (filter.ge != null) { + filterKey += 'GE' + filter.ge; + filterGE = filter.ge; + } + if (filter.l != null) { + filterKey += 'L' + filter.l; + filterL = filter.l; + } + if (filter.le != null) { + filterKey += 'LE' + filter.le; + filterLE = filter.le; + } + } + return { + key: filterKey, + g: filterG, + ge: filterGE, + l: filterL, + le: filterLE, + }; +} + +export function passesSanitizationFilter(filterParsed: DataSanitizationFilterParsed, value: number): boolean { + return value > filterParsed.g + || value >= filterParsed.ge + || value < filterParsed.l + || value <= filterParsed.le; +} + +// --------- END: Data store sanitization filters --------- diff --git a/src/data/helper/sourceHelper.ts b/src/data/helper/sourceHelper.ts index a4c47721a9..67668666a5 100644 --- a/src/data/helper/sourceHelper.ts +++ b/src/data/helper/sourceHelper.ts @@ -450,7 +450,8 @@ function doGuessOrdinal( const beStr = isString(val); // Consider usage convenience, '1', '2' will be treated as "number". // `Number('')` (or any whitespace) is `0`. - if (val != null && Number.isFinite(Number(val)) && val !== '') { + // `Number(val)` prevents error for BigInt. + if (val != null && isFinite(Number(val)) && val !== '') { return beStr ? BE_ORDINAL.Might : BE_ORDINAL.Not; } else if (beStr && val !== '-') { diff --git a/src/export/api/helper.ts b/src/export/api/helper.ts index 92a4ddf66a..e1b2ac06c2 100644 --- a/src/export/api/helper.ts +++ b/src/export/api/helper.ts @@ -38,6 +38,7 @@ import { AxisBaseModel } from '../../coord/AxisBaseModel'; import { getECData } from '../../util/innerStore'; import { createTextStyle as innerCreateTextStyle } from '../../label/labelStyle'; import { DisplayState, TextCommonOption } from '../../util/types'; +import { scaleCalcNice2 } from '../../coord/axisNiceTicks'; /** * Create a multi dimension List structure from seriesModel. @@ -74,10 +75,11 @@ export const dataStack = { export {createSymbol} from '../../util/symbol'; /** + * Externally used by echarts-gl. * Create scale - * @param {Array.} dataExtent - * @param {Object|module:echarts/Model} option If `optoin.type` - * is secified, it can only be `'value'` currently. + * @param dataExtent + * @param option If `option.type` + * is specified, it can only be `'value'` currently. */ export function createScale(dataExtent: number[], option: object | AxisBaseModel) { let axisModel = option; @@ -93,22 +95,17 @@ export function createScale(dataExtent: number[], option: object | AxisBaseModel // zrUtil.mixin(axisModel, AxisModelCommonMixin); } - const scale = axisHelper.createScaleByModel(axisModel as AxisBaseModel); - scale.setExtent(dataExtent[0], dataExtent[1]); - - axisHelper.niceScaleExtent(scale, axisModel as AxisBaseModel); + const axisType = axisHelper.determineAxisType(axisModel as AxisBaseModel); + const scale = axisHelper.createScaleByModel(axisModel as AxisBaseModel, axisType, false); + if (dataExtent[1] < dataExtent[0]) { + dataExtent = dataExtent.slice().reverse(); + } + scaleCalcNice2(scale, axisModel as AxisBaseModel, null, null, dataExtent); return scale; } /** - * Mixin common methods to axis model, - * - * Include methods - * `getFormattedLabels() => Array.` - * `getCategories() => Array.` - * `getMin(origin: boolean) => number` - * `getMax(origin: boolean) => number` - * `getNeedCrossZero() => boolean` + * Mixin common methods to axis model */ export function mixinAxisModelCommonMethods(Model: Model) { zrUtil.mixin(Model, AxisModelCommonMixin); diff --git a/src/export/api/number.ts b/src/export/api/number.ts index 40b130d258..835ca1563f 100644 --- a/src/export/api/number.ts +++ b/src/export/api/number.ts @@ -19,7 +19,7 @@ export { linearMap, - round, + roundLegacy as round, asc, getPrecision, getPrecisionSafe, diff --git a/src/label/LabelManager.ts b/src/label/LabelManager.ts index b60a9ac2b2..411af30812 100644 --- a/src/label/LabelManager.ts +++ b/src/label/LabelManager.ts @@ -86,7 +86,7 @@ interface LabelDesc { * Save the original value determined in a pass of echarts main process. That refers to the values * rendered by `SeriesView`, before `series:layoutlabels` is triggered in `renderSeries`. * - * 'series:layoutlabels' may be triggered during some shortcut passes, such as zooming in series.graph/geo + * 'series:layoutlabels' may be triggered during some EC_PARTIAL_UPDATE passes, such as zooming in series.graph/geo * (`updateLabelLayout`), where the modified `Element` props should be restorable by the original value here. * * Regarding `Element` state, simply consider the values here as the normal state values. diff --git a/src/label/labelLayoutHelper.ts b/src/label/labelLayoutHelper.ts index 1136bb63d8..8567811185 100644 --- a/src/label/labelLayoutHelper.ts +++ b/src/label/labelLayoutHelper.ts @@ -509,7 +509,7 @@ export function restoreIgnore(labelList: LabelLayoutData[]): void { /** * [NOTICE - restore]: - * 'series:layoutlabels' may be triggered during some shortcut passes, such as zooming in series.graph/geo + * 'series:layoutlabels' may be triggered during some EC_PARTIAL_UPDATE passes, such as zooming in series.graph/geo * (`updateLabelLayout`), where the modified `Element` props should be restorable from `defaultAttr`. * @see `SavedLabelAttr` in `LabelManager.ts` * `restoreIgnore` can be called to perform the restore, if needed. diff --git a/src/layout/barCommon.ts b/src/layout/barCommon.ts new file mode 100644 index 0000000000..0f8dd1b8cf --- /dev/null +++ b/src/layout/barCommon.ts @@ -0,0 +1,56 @@ +/* +* 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 { getMetricsNonOrdinalLinearPositiveMinGap } from '../chart/helper/axisSnippets'; +import type Axis from '../coord/Axis'; +import { AxisStatKey, requireAxisStatistics } from '../coord/axisStatistics'; +import { EChartsExtensionInstallRegisters } from '../extension'; +import { isNullableNumberFinite } from '../util/number'; + + +export type BaseBarSeriesSubType = 'bar' | 'pictorialBar'; + +export const BAR_SERIES_TYPE = 'bar'; + +export function requireAxisStatisticsForBaseBar( + registers: EChartsExtensionInstallRegisters, + axisStatKey: AxisStatKey, + seriesType: BaseBarSeriesSubType, + coordSysType: 'cartesian2d' | 'polar' +): void { + requireAxisStatistics( + registers, + { + key: axisStatKey, + seriesType, + coordSysType, + getMetrics: getMetricsNonOrdinalLinearPositiveMinGap + } + ); +} + +// See cases in `test/bar-start.html` and `#7412`, `#8747`. +export function getStartValue(baseAxis: Axis): number { + let startValue = baseAxis.scale.rawExtentInfo.makeOthers().startValue; + if (!isNullableNumberFinite(startValue)) { + startValue = 0; + } + return startValue; +} + diff --git a/src/layout/barGrid.ts b/src/layout/barGrid.ts index cb979216fd..dd1368aa24 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -17,58 +17,69 @@ * under the License. */ -import { each, defaults, keys } from 'zrender/src/core/util'; -import { parsePercent } from '../util/number'; +import { each, defaults, hasOwn, assert } from 'zrender/src/core/util'; +import { isNullableNumberFinite, mathAbs, mathMax, mathMin, parsePercent } from '../util/number'; import { isDimensionStacked } from '../data/helper/dataStackHelper'; import createRenderPlanner from '../chart/helper/createRenderPlanner'; -import BarSeriesModel from '../chart/bar/BarSeries'; import Axis2D from '../coord/cartesian/Axis2D'; import GlobalModel from '../model/Global'; -import type Cartesian2D from '../coord/cartesian/Cartesian2D'; -import { StageHandler, Dictionary } from '../util/types'; +import Cartesian2D from '../coord/cartesian/Cartesian2D'; +import { StageHandler, NullUndefined } from '../util/types'; import { createFloat32Array } from '../util/vendor'; +import { + extentHasValue, + initExtentForUnion, + makeCallOnlyOnce, + unionExtentFromNumber, +} from '../util/model'; +import { isOrdinalScale } from '../scale/helper'; +import { + isCartesian2DInjectedAsDataCoordSys +} from '../coord/cartesian/cartesianAxisHelper'; +import type BaseBarSeriesModel from '../chart/bar/BaseBarSeries'; +import type BarSeriesModel from '../chart/bar/BarSeries'; +import { + AxisContainShapeHandler, registerAxisContainShapeHandler, +} from '../coord/scaleRawExtentInfo'; +import { EChartsExtensionInstallRegisters } from '../extension'; +import { + eachAxisOnKey, + eachSeriesOnAxisOnKey, countSeriesOnAxisOnKey, +} from '../coord/axisStatistics'; +import { + AxisBandWidthResult, calcBandWidth +} from '../coord/axisBand'; +import { BaseBarSeriesSubType, getStartValue, requireAxisStatisticsForBaseBar } from './barCommon'; +import { COORD_SYS_TYPE_CARTESIAN_2D } from '../coord/cartesian/GridModel'; +import { makeAxisStatKey2 } from '../chart/helper/axisSnippets'; + + +const callOnlyOnce = makeCallOnlyOnce(); const STACK_PREFIX = '__ec_stack_'; -function getSeriesStackId(seriesModel: BarSeriesModel): string { - return seriesModel.get('stack') || STACK_PREFIX + seriesModel.seriesIndex; +function getSeriesStackId(seriesModel: BaseBarSeriesModel): StackId { + return ((seriesModel as BarSeriesModel).get('stack') || STACK_PREFIX + seriesModel.seriesIndex) as StackId; } -function getAxisKey(axis: Axis2D): string { - return axis.dim + axis.index; +interface BarGridLayoutAxisInfo { + seriesInfo: BarGridLayoutAxisSeriesInfo[]; + // Calculated layout width for a single bars group. + bandWidthResult: AxisBandWidthResult; } -interface LayoutSeriesInfo { - bandWidth: number +interface BarGridLayoutAxisSeriesInfo { barWidth: number barMaxWidth: number barMinWidth: number barGap: number | string defaultBarGap?: number | string barCategoryGap: number | string - axisKey: string - stackId: string + stackId: StackId } -interface StackInfo { - width: number - maxWidth: number - minWidth?: number -} +type StackId = string & {_: 'barGridStackId'}; -/** - * { - * [coordSysId]: { - * [stackId]: {bandWidth, offset, width} - * } - * } - */ -type BarWidthAndOffset = Dictionary>; export interface BarGridLayoutOptionForCustomSeries { count: number @@ -79,36 +90,58 @@ export interface BarGridLayoutOptionForCustomSeries { barGap?: number | string barCategoryGap?: number | string } -interface LayoutOption extends BarGridLayoutOptionForCustomSeries { +interface BarGridLayoutOption extends BarGridLayoutOptionForCustomSeries { axis: Axis2D } -export type BarGridLayoutResult = BarWidthAndOffset[string][string][]; +// The layout of a bar group (may come from different series but on the same value on the base axis). +// The bars with the same `StackId` are stacked; otherwise they are placed side by side, following +// series declaration order. +type BarWidthAndOffsetOnAxis = Record; +export type BarGridColumnLayoutOnAxis = BarGridLayoutAxisInfo & { + columnMap: BarWidthAndOffsetOnAxis; +}; + +type BarGridLayoutResultItemInternal = { + bandWidth: BarGridLayoutAxisInfo['bandWidthResult']['w'] + offset: number // An offset with respect to `dataToPoint` + width: number +}; +type BarGridLayoutResultItem = BarGridLayoutResultItemInternal & { + offsetCenter: number +}; +export type BarGridLayoutResultForCustomSeries = BarGridLayoutResultItem[] | NullUndefined; + /** - * @return {Object} {width, offset, offsetCenter} If axis.type is not 'category', return undefined. + * Return null/undefined if not 'category' axis. + * + * PENDING: The layout on non-'category' axis relies on `bandWidth`, which is calculated + * based on the `linearPositiveMinGap` of series data. This strategy is somewhat heuristic + * and will not be public to custom series until required in future. Additionally, more ec + * options may be introduced for that, because it requires `requireAxisStatistics` to be + * called on custom series that requires this feature. */ -export function getLayoutOnAxis(opt: LayoutOption): BarGridLayoutResult { - const params: LayoutSeriesInfo[] = []; - const baseAxis = opt.axis; - const axisKey = 'axis0'; - - if (baseAxis.type !== 'category') { +export function computeBarLayoutForCustomSeries(opt: BarGridLayoutOption): BarGridLayoutResultForCustomSeries { + if (!isOrdinalScale(opt.axis.scale)) { return; } - const bandWidth = baseAxis.getBandWidth(); + const bandWidthResult = calcBandWidth(opt.axis); + + const params: BarGridLayoutAxisSeriesInfo[] = []; for (let i = 0; i < opt.count || 0; i++) { params.push(defaults({ - bandWidth: bandWidth, - axisKey: axisKey, - stackId: STACK_PREFIX + i - }, opt) as LayoutSeriesInfo); + stackId: STACK_PREFIX + i as StackId + }, opt) as BarGridLayoutAxisSeriesInfo); } - const widthAndOffsets = doCalBarWidthAndOffset(params); + const widthAndOffsets = calcBarWidthAndOffset({ + bandWidthResult, + seriesInfo: params, + }); - const result = []; + const result: BarGridLayoutResultItem[] = []; for (let i = 0; i < opt.count; i++) { - const item = widthAndOffsets[axisKey][STACK_PREFIX + i]; + const item = widthAndOffsets[STACK_PREFIX + i as StackId] as BarGridLayoutResultItem; item.offsetCenter = item.offset + item.width / 2; result.push(item); } @@ -116,351 +149,227 @@ export function getLayoutOnAxis(opt: LayoutOption): BarGridLayoutResult { return result; } -export function prepareLayoutBarSeries(seriesType: string, ecModel: GlobalModel): BarSeriesModel[] { - const seriesModels: BarSeriesModel[] = []; - ecModel.eachSeriesByType(seriesType, function (seriesModel: BarSeriesModel) { - // Check series coordinate, do layout for cartesian2d only - if (isOnCartesian(seriesModel)) { - seriesModels.push(seriesModel); - } - }); - return seriesModels; -} - - /** - * Map from (baseAxis.dim + '_' + baseAxis.index) to min gap of two adjacent - * values. - * This works for time axes, value axes, and log axes. - * For a single time axis, return value is in the form like - * {'x_0': [1000000]}. - * The value of 1000000 is in milliseconds. + * NOTICE: This layout is based on axis pixel extent and scale extent. + * It may be used on estimation, where axis pixel extent and scale extent + * are approximately set. But the result should not be cached since the + * axis pixel extent and scale extent may be changed finally. */ -function getValueAxesMinGaps(barSeries: BarSeriesModel[]) { - /** - * Map from axis.index to values. - * For a single time axis, axisValues is in the form like - * {'x_0': [1495555200000, 1495641600000, 1495728000000]}. - * Items in axisValues[x], e.g. 1495555200000, are time values of all - * series. - */ - const axisValues: Dictionary = {}; - each(barSeries, function (seriesModel) { - const cartesian = seriesModel.coordinateSystem as Cartesian2D; - const baseAxis = cartesian.getBaseAxis(); - if (baseAxis.type !== 'time' && baseAxis.type !== 'value') { - return; - } - - const data = seriesModel.getData(); - const key = baseAxis.dim + '_' + baseAxis.index; - const dimIdx = data.getDimensionIndex(data.mapDimension(baseAxis.dim)); - const store = data.getStore(); - for (let i = 0, cnt = store.count(); i < cnt; ++i) { - const value = store.get(dimIdx, i) as number; - if (!axisValues[key]) { - // No previous data for the axis - axisValues[key] = [value]; - } - else { - // No value in previous series - axisValues[key].push(value); - } - // Ignore duplicated time values in the same axis - } - }); - - const axisMinGaps: Dictionary = {}; - for (const key in axisValues) { - if (axisValues.hasOwnProperty(key)) { - const valuesInAxis = axisValues[key]; - if (valuesInAxis) { - // Sort axis values into ascending order to calculate gaps - valuesInAxis.sort(function (a, b) { - return a - b; - }); - - let min = null; - for (let j = 1; j < valuesInAxis.length; ++j) { - const delta = valuesInAxis[j] - valuesInAxis[j - 1]; - if (delta > 0) { - // Ignore 0 delta because they are of the same axis value - min = min === null ? delta : Math.min(min, delta); - } - } - // Set to null if only have one data - axisMinGaps[key] = min; - } - } - } - return axisMinGaps; +function makeColumnLayoutOnAxisReal( + baseAxis: Axis2D, + seriesType: BaseBarSeriesSubType +): BarGridColumnLayoutOnAxis { + const seriesInfoListOnAxis = createLayoutInfoListOnAxis( + baseAxis, seriesType + ) as BarGridColumnLayoutOnAxis; + seriesInfoListOnAxis.columnMap = calcBarWidthAndOffset(seriesInfoListOnAxis); + return seriesInfoListOnAxis; } -export function makeColumnLayout(barSeries: BarSeriesModel[]) { - const axisMinGaps = getValueAxesMinGaps(barSeries); - - const seriesInfoList: LayoutSeriesInfo[] = []; - each(barSeries, function (seriesModel) { - const cartesian = seriesModel.coordinateSystem as Cartesian2D; - const baseAxis = cartesian.getBaseAxis(); - const axisExtent = baseAxis.getExtent(); - - let bandWidth; - if (baseAxis.type === 'category') { - bandWidth = baseAxis.getBandWidth(); - } - else if (baseAxis.type === 'value' || baseAxis.type === 'time') { - const key = baseAxis.dim + '_' + baseAxis.index; - const minGap = axisMinGaps[key]; - const extentSpan = Math.abs(axisExtent[1] - axisExtent[0]); - const scale = baseAxis.scale.getExtent(); - const scaleSpan = Math.abs(scale[1] - scale[0]); - bandWidth = minGap - ? extentSpan / scaleSpan * minGap - : extentSpan; // When there is only one data value - } - else { - const data = seriesModel.getData(); - bandWidth = Math.abs(axisExtent[1] - axisExtent[0]) / data.count(); - } - - const barWidth = parsePercent( - seriesModel.get('barWidth'), bandWidth - ); - const barMaxWidth = parsePercent( - seriesModel.get('barMaxWidth'), bandWidth - ); - const barMinWidth = parsePercent( - // barMinWidth by default is 0.5 / 1 in cartesian. Because in value axis, - // the auto-calculated bar width might be less than 0.5 / 1. - seriesModel.get('barMinWidth') || (isInLargeMode(seriesModel) ? 0.5 : 1), bandWidth - ); - const barGap = seriesModel.get('barGap'); - const barCategoryGap = seriesModel.get('barCategoryGap'); - const defaultBarGap = seriesModel.get('defaultBarGap'); - - seriesInfoList.push({ - bandWidth: bandWidth, - barWidth: barWidth, - barMaxWidth: barMaxWidth, - barMinWidth: barMinWidth, - barGap: barGap, - barCategoryGap: barCategoryGap, - defaultBarGap: defaultBarGap, - axisKey: getAxisKey(baseAxis), +function createLayoutInfoListOnAxis( + baseAxis: Axis2D, + seriesType: BaseBarSeriesSubType +): BarGridLayoutAxisInfo { + + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_CARTESIAN_2D); + const seriesInfoOnAxis: BarGridLayoutAxisSeriesInfo[] = []; + const bandWidthResult = calcBandWidth( + baseAxis, + {fromStat: {key: axisStatKey}, min: 1} + ); + const bandWidth = bandWidthResult.w; + + eachSeriesOnAxisOnKey(baseAxis, axisStatKey, function (seriesModel: BaseBarSeriesModel) { + seriesInfoOnAxis.push({ + barWidth: parsePercent(seriesModel.get('barWidth'), bandWidth), + barMaxWidth: parsePercent(seriesModel.get('barMaxWidth'), bandWidth), + barMinWidth: parsePercent( + // barMinWidth by default is 0.5 / 1 in cartesian. Because in value axis, + // the auto-calculated bar width might be less than 0.5 / 1. + seriesModel.get('barMinWidth') || (isInLargeMode(seriesModel) ? 0.5 : 1), bandWidth + ), + barGap: seriesModel.get('barGap'), + barCategoryGap: seriesModel.get('barCategoryGap'), + defaultBarGap: seriesModel.get('defaultBarGap'), stackId: getSeriesStackId(seriesModel) }); }); - return doCalBarWidthAndOffset(seriesInfoList); + return { + bandWidthResult, + seriesInfo: seriesInfoOnAxis, + }; } -function doCalBarWidthAndOffset(seriesInfoList: LayoutSeriesInfo[]) { - interface ColumnOnAxisInfo { - bandWidth: number - remainedWidth: number - autoWidthCount: number - categoryGap: number | string - gap: number | string - stacks: Dictionary +/** + * CAUTION: When multiple series are laid out on one axis, relevant ec options effect all series. + * But for historical reason, these options are configured on each series option, which may + * introduce confliction. The legacy implementation uses some options (e.g., `defaultBarGap`) + * from the first declared series, and other options (e.g., `barGap`, `barCategoryGap`) from the last declared + * series. Nevertheless, We remain this design to avoid breaking change. + */ +function calcBarWidthAndOffset( + seriesInfoOnAxis: BarGridLayoutAxisInfo +): BarWidthAndOffsetOnAxis { + interface StackInfo { + width: number + maxWidth: number + minWidth?: number } - // Columns info on each category axis. Key is cartesian name - const columnsMap: Dictionary = {}; - - each(seriesInfoList, function (seriesInfo, idx) { - const axisKey = seriesInfo.axisKey; - const bandWidth = seriesInfo.bandWidth; - const columnsOnAxis: ColumnOnAxisInfo = columnsMap[axisKey] || { - bandWidth: bandWidth, - remainedWidth: bandWidth, - autoWidthCount: 0, - categoryGap: null, - gap: seriesInfo.defaultBarGap || 0, - stacks: {} - }; - const stacks = columnsOnAxis.stacks; - columnsMap[axisKey] = columnsOnAxis; - + const bandWidth = seriesInfoOnAxis.bandWidthResult.w; + let remainedWidth = bandWidth; + let autoWidthCount: number = 0; + let barCategoryGapOption: number | string; + let barGapOption: number | string; + const stackIdList: StackId[] = []; + const stackMap: Record = {}; + + each(seriesInfoOnAxis.seriesInfo, function (seriesInfo, idx) { + if (!idx) { + barGapOption = seriesInfo.defaultBarGap || 0; + } const stackId = seriesInfo.stackId; - if (!stacks[stackId]) { - columnsOnAxis.autoWidthCount++; + if (!hasOwn(stackMap, stackId)) { + autoWidthCount++; + } + let stackItem = stackMap[stackId]; + if (!stackItem) { + stackItem = stackMap[stackId] = { + width: 0, + maxWidth: 0 + }; + stackIdList.push(stackId); } - stacks[stackId] = stacks[stackId] || { - width: 0, - maxWidth: 0 - }; - - // Caution: In a single coordinate system, these barGrid attributes - // will be shared by series. Consider that they have default values, - // only the attributes set on the last series will work. - // Do not change this fact unless there will be a break change. let barWidth = seriesInfo.barWidth; - if (barWidth && !stacks[stackId].width) { + if (barWidth && !stackItem.width) { // See #6312, do not restrict width. - stacks[stackId].width = barWidth; - barWidth = Math.min(columnsOnAxis.remainedWidth, barWidth); - columnsOnAxis.remainedWidth -= barWidth; + stackItem.width = barWidth; + barWidth = mathMin(remainedWidth, barWidth); + remainedWidth -= barWidth; } const barMaxWidth = seriesInfo.barMaxWidth; - barMaxWidth && (stacks[stackId].maxWidth = barMaxWidth); + barMaxWidth && (stackItem.maxWidth = barMaxWidth); const barMinWidth = seriesInfo.barMinWidth; - barMinWidth && (stacks[stackId].minWidth = barMinWidth); + barMinWidth && (stackItem.minWidth = barMinWidth); const barGap = seriesInfo.barGap; - (barGap != null) && (columnsOnAxis.gap = barGap); + (barGap != null) && (barGapOption = barGap); const barCategoryGap = seriesInfo.barCategoryGap; - (barCategoryGap != null) && (columnsOnAxis.categoryGap = barCategoryGap); + (barCategoryGap != null) && (barCategoryGapOption = barCategoryGap); }); - const result: BarWidthAndOffset = {}; - - each(columnsMap, function (columnsOnAxis, coordSysName) { - - result[coordSysName] = {}; - - const stacks = columnsOnAxis.stacks; - const bandWidth = columnsOnAxis.bandWidth; - let categoryGapPercent = columnsOnAxis.categoryGap; - if (categoryGapPercent == null) { - const columnCount = keys(stacks).length; - // More columns in one group - // the spaces between group is smaller. Or the column will be too thin. - categoryGapPercent = Math.max((35 - columnCount * 4), 15) + '%'; - } + if (barCategoryGapOption == null) { + // More columns in one group + // the spaces between group is smaller. Or the column will be too thin. + barCategoryGapOption = mathMax((35 - stackIdList.length * 4), 15) + '%'; + } - const categoryGap = parsePercent(categoryGapPercent, bandWidth); - const barGapPercent = parsePercent(columnsOnAxis.gap, 1); + const barCategoryGapNum = parsePercent(barCategoryGapOption, bandWidth); + const barGapPercent = parsePercent(barGapOption, 1); - let remainedWidth = columnsOnAxis.remainedWidth; - let autoWidthCount = columnsOnAxis.autoWidthCount; - let autoWidth = (remainedWidth - categoryGap) - / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); - autoWidth = Math.max(autoWidth, 0); + let autoWidth = (remainedWidth - barCategoryGapNum) + / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); + autoWidth = mathMax(autoWidth, 0); - // Find if any auto calculated bar exceeded maxBarWidth - each(stacks, function (column) { - const maxWidth = column.maxWidth; - const minWidth = column.minWidth; + // Find if any auto calculated bar exceeded maxBarWidth + each(stackIdList, function (stackId) { + const column = stackMap[stackId]; + const maxWidth = column.maxWidth; + const minWidth = column.minWidth; - if (!column.width) { - let finalWidth = autoWidth; - if (maxWidth && maxWidth < finalWidth) { - finalWidth = Math.min(maxWidth, remainedWidth); - } - // `minWidth` has higher priority. `minWidth` decide that whether the - // bar is able to be visible. So `minWidth` should not be restricted - // by `maxWidth` or `remainedWidth` (which is from `bandWidth`). In - // the extreme cases for `value` axis, bars are allowed to overlap - // with each other if `minWidth` specified. - if (minWidth && minWidth > finalWidth) { - finalWidth = minWidth; - } - if (finalWidth !== autoWidth) { - column.width = finalWidth; - remainedWidth -= finalWidth + barGapPercent * finalWidth; - autoWidthCount--; - } + if (!column.width) { + let finalWidth = autoWidth; + if (maxWidth && maxWidth < finalWidth) { + finalWidth = mathMin(maxWidth, remainedWidth); } - else { - // `barMinWidth/barMaxWidth` has higher priority than `barWidth`, as - // CSS does. Because barWidth can be a percent value, where - // `barMaxWidth` can be used to restrict the final width. - let finalWidth = column.width; - if (maxWidth) { - finalWidth = Math.min(finalWidth, maxWidth); - } - // `minWidth` has higher priority, as described above - if (minWidth) { - finalWidth = Math.max(finalWidth, minWidth); - } + // `minWidth` has higher priority. `minWidth` decide that whether the + // bar is able to be visible. So `minWidth` should not be restricted + // by `maxWidth` or `remainedWidth` (which is from `bandWidth`). In + // the extreme cases for `value` axis, bars are allowed to overlap + // with each other if `minWidth` specified. + if (minWidth && minWidth > finalWidth) { + finalWidth = minWidth; + } + if (finalWidth !== autoWidth) { column.width = finalWidth; remainedWidth -= finalWidth + barGapPercent * finalWidth; autoWidthCount--; } - }); - - // Recalculate width again - autoWidth = (remainedWidth - categoryGap) - / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); + } + else { + // `barMinWidth/barMaxWidth` has higher priority than `barWidth`, as + // CSS does. Because barWidth can be a percent value, where + // `barMaxWidth` can be used to restrict the final width. + let finalWidth = column.width; + if (maxWidth) { + finalWidth = mathMin(finalWidth, maxWidth); + } + // `minWidth` has higher priority, as described above + if (minWidth) { + finalWidth = mathMax(finalWidth, minWidth); + } + column.width = finalWidth; + remainedWidth -= finalWidth + barGapPercent * finalWidth; + autoWidthCount--; + } + }); - autoWidth = Math.max(autoWidth, 0); + // Recalculate width again + autoWidth = (remainedWidth - barCategoryGapNum) + / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); + autoWidth = mathMax(autoWidth, 0); - let widthSum = 0; - let lastColumn: StackInfo; - each(stacks, function (column, idx) { - if (!column.width) { - column.width = autoWidth; - } - lastColumn = column; - widthSum += column.width * (1 + barGapPercent); - }); - if (lastColumn) { - widthSum -= lastColumn.width * barGapPercent; + let widthSum = 0; + let lastColumn: StackInfo; + each(stackIdList, function (stackId) { + const column = stackMap[stackId]; + if (!column.width) { + column.width = autoWidth; } + lastColumn = column; + widthSum += column.width * (1 + barGapPercent); + }); + if (lastColumn) { + widthSum -= lastColumn.width * barGapPercent; + } - let offset = -widthSum / 2; - each(stacks, function (column, stackId) { - result[coordSysName][stackId] = result[coordSysName][stackId] || { - bandWidth: bandWidth, - offset: offset, - width: column.width - } as BarWidthAndOffset[string][string]; + const result: BarWidthAndOffsetOnAxis = {}; - offset += column.width * (1 + barGapPercent); - }); + let offset = -widthSum / 2; + each(stackIdList, function (stackId) { + const column = stackMap[stackId]; + result[stackId] = result[stackId] || { + bandWidth: bandWidth, + offset: offset, + width: column.width + }; + offset += column.width * (1 + barGapPercent); }); return result; } -/** - * @param barWidthAndOffset The result of makeColumnLayout - * @param seriesModel If not provided, return all. - * @return {stackId: {offset, width}} or {offset, width} if seriesModel provided. - */ -function retrieveColumnLayout(barWidthAndOffset: BarWidthAndOffset, axis: Axis2D): typeof barWidthAndOffset[string]; -// eslint-disable-next-line max-len -function retrieveColumnLayout(barWidthAndOffset: BarWidthAndOffset, axis: Axis2D, seriesModel: BarSeriesModel): typeof barWidthAndOffset[string][string]; -function retrieveColumnLayout( - barWidthAndOffset: BarWidthAndOffset, - axis: Axis2D, - seriesModel?: BarSeriesModel -) { - if (barWidthAndOffset && axis) { - const result = barWidthAndOffset[getAxisKey(axis)]; - if (result != null && seriesModel != null) { - return result[getSeriesStackId(seriesModel)]; +export function layout(seriesType: BaseBarSeriesSubType, ecModel: GlobalModel): void { + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_CARTESIAN_2D); + eachAxisOnKey(ecModel, axisStatKey, function (axis: Axis2D) { + if (__DEV__) { + assert(axis instanceof Axis2D); } - return result; - } -} -export {retrieveColumnLayout}; - -export function layout(seriesType: string, ecModel: GlobalModel) { - - const seriesModels = prepareLayoutBarSeries(seriesType, ecModel); - const barWidthAndOffset = makeColumnLayout(seriesModels); - - each(seriesModels, function (seriesModel) { - - const data = seriesModel.getData(); - const cartesian = seriesModel.coordinateSystem as Cartesian2D; - const baseAxis = cartesian.getBaseAxis(); - - const stackId = getSeriesStackId(seriesModel); - const columnLayoutInfo = barWidthAndOffset[getAxisKey(baseAxis)][stackId]; - const columnOffset = columnLayoutInfo.offset; - const columnWidth = columnLayoutInfo.width; - - data.setLayout({ - bandWidth: columnLayoutInfo.bandWidth, - offset: columnOffset, - size: columnWidth + const columnLayout = makeColumnLayoutOnAxisReal(axis, seriesType); + + eachSeriesOnAxisOnKey(axis, axisStatKey, function (seriesModel) { + const columnLayoutInfo = columnLayout.columnMap[getSeriesStackId(seriesModel)]; + seriesModel.getData().setLayout({ + bandWidth: columnLayoutInfo.bandWidth, + offset: columnLayoutInfo.offset, + size: columnLayoutInfo.width + }); }); + }); } @@ -471,8 +380,8 @@ export function createProgressiveLayout(seriesType: string): StageHandler { plan: createRenderPlanner(), - reset: function (seriesModel: BarSeriesModel) { - if (!isOnCartesian(seriesModel)) { + reset: function (seriesModel: BaseBarSeriesModel) { + if (!isCartesian2DInjectedAsDataCoordSys(seriesModel)) { return; } @@ -483,12 +392,14 @@ export function createProgressiveLayout(seriesType: string): StageHandler { const valueAxis = cartesian.getOtherAxis(baseAxis); const valueDimIdx = data.getDimensionIndex(data.mapDimension(valueAxis.dim)); const baseDimIdx = data.getDimensionIndex(data.mapDimension(baseAxis.dim)); - const drawBackground = seriesModel.get('showBackground', true); + const drawBackground = (seriesModel as BarSeriesModel).get('showBackground', true); const valueDim = data.mapDimension(valueAxis.dim); const stackResultDim = data.getCalculationInfo('stackResultDimension'); const stacked = isDimensionStacked(data, valueDim) && !!data.getCalculationInfo('stackedOnSeries'); const isValueAxisH = valueAxis.isHorizontal(); - const valueAxisStart = getValueAxisStart(baseAxis, valueAxis); + + const valueAxisStart = valueAxis.toGlobalCoord(valueAxis.dataToCoord(getStartValue(baseAxis))); + const isLarge = isInLargeMode(seriesModel); const barMinHeight = seriesModel.get('barMinHeight') || 0; @@ -532,30 +443,28 @@ export function createProgressiveLayout(seriesType: string): StageHandler { if (isValueAxisH) { const coord = cartesian.dataToPoint([value, baseValue]); if (stacked) { - const startCoord = cartesian.dataToPoint([stackStartValue, baseValue]); - baseCoord = startCoord[0]; + baseCoord = cartesian.dataToPoint([stackStartValue, baseValue])[0]; } x = baseCoord; y = coord[1] + columnOffset; width = coord[0] - baseCoord; height = columnWidth; - if (Math.abs(width) < barMinHeight) { + if (mathAbs(width) < barMinHeight) { width = (width < 0 ? -1 : 1) * barMinHeight; } } else { const coord = cartesian.dataToPoint([baseValue, value]); if (stacked) { - const startCoord = cartesian.dataToPoint([baseValue, stackStartValue]); - baseCoord = startCoord[1]; + baseCoord = cartesian.dataToPoint([baseValue, stackStartValue])[1]; } x = coord[0] + columnOffset; y = baseCoord; width = columnWidth; height = coord[1] - baseCoord; - if (Math.abs(height) < barMinHeight) { + if (mathAbs(height) < barMinHeight) { // Include zero to has a positive bar height = (height <= 0 ? -1 : 1) * barMinHeight; } @@ -595,23 +504,92 @@ export function createProgressiveLayout(seriesType: string): StageHandler { }; } -function isOnCartesian(seriesModel: BarSeriesModel) { - return seriesModel.coordinateSystem && seriesModel.coordinateSystem.type === 'cartesian2d'; +function isInLargeMode(seriesModel: BaseBarSeriesModel) { + return seriesModel.pipelineContext && seriesModel.pipelineContext.large; } -function isInLargeMode(seriesModel: BarSeriesModel) { - return seriesModel.pipelineContext && seriesModel.pipelineContext.large; + +/** + * NOTICE: + * - Must NOT be called before series-filter due to the series cache in `layoutPre`. + * - It relies on `axis.getExtent` and `scale.getExtent` to calculate `bandWidth` + * for non-'category' axes. Assume `scale.setExtent` has been prepared. + * See the summary of the process of extent determination in the comment of `scaleMapper.setExtent`. + */ +function barGridCreateAxisContainShapeHandler(seriesType: BaseBarSeriesSubType): AxisContainShapeHandler { + return function (axis, scale, ecModel) { + // If bars are placed on 'time', 'value', 'log' axis, handle bars overflow here. + // See #6728, #4862, `test/bar-overflow-time-plot.html` + if (axis && axis instanceof Axis2D && !isOrdinalScale(scale)) { + if (!countSeriesOnAxisOnKey(axis, makeAxisStatKey2(seriesType, COORD_SYS_TYPE_CARTESIAN_2D))) { + return; // Quick path - in most cases there is no bar on non-ordinal axis. + } + const columnLayout = makeColumnLayoutOnAxisReal(axis, seriesType); + return calcShapeOverflowSupplement(columnLayout); + } + }; } -// See cases in `test/bar-start.html` and `#7412`, `#8747`. -function getValueAxisStart(baseAxis: Axis2D, valueAxis: Axis2D) { - let startValue = valueAxis.model.get('startValue'); - if (!startValue) { - startValue = 0; +function calcShapeOverflowSupplement( + columnLayout: BarGridColumnLayoutOnAxis | NullUndefined +): number[] | NullUndefined { + if (columnLayout == null) { + return; + } + const bandWidthResult = columnLayout.bandWidthResult; + const invRatio = (bandWidthResult.fromStat || {}).invRatio; + if (!isNullableNumberFinite(invRatio)) { + return; // No series data or no more than one distinct valid data values. } - return valueAxis.toGlobalCoord( - valueAxis.dataToCoord( - valueAxis.type === 'log' - ? (startValue > 0 ? startValue : 1) - : startValue)); + + // The calculation below is based on a proportion mapping from + // `[barsBoundVal[0], barsBoundVal[1]]` to `[minValNew, maxValNew]`: + // |------|------------------------------|---| + // barsBoundVal[0] minValOld maxValOld barsBoundVal[1] + // |----|----------------------|--| + // minValNew minValOld maxValOld maxValNew + // (Note: `|---|` above represents "pixels" rather than "data".) + + const barsBoundPx = initExtentForUnion(); + const bandWidth = bandWidthResult.w; + // Union `-bandWidth / 2` and `bandWidth / 2` to provide extra space for visually preferred, + // Otherwise the bars on the edges may overlap with axis line. + // And it also includes `0`, which ensures `barsBoundPx[0] <= 0 <= barsBoundPx[1]`. + unionExtentFromNumber(barsBoundPx, -bandWidth / 2); + unionExtentFromNumber(barsBoundPx, bandWidth / 2); + // Shapes may overflow the `bandWidth`. For example, that might happen in `pictorialBar`. + // Therefore, we also involve shape size (mapped to data scale) in this expansion calculation. + each(columnLayout.columnMap, function (item) { + unionExtentFromNumber(barsBoundPx, item.offset); + unionExtentFromNumber(barsBoundPx, item.offset + item.width); + }); + + if (extentHasValue(barsBoundPx)) { + // Convert from pixel domain to data domain, since the `barsBoundPx` is calculated based on + // `minGap` and extent on data domain. + return [barsBoundPx[0] * invRatio, barsBoundPx[1] * invRatio]; + // If AXIS_BAND_WIDTH_KIND_SINGULAR, extent expansion is not needed. + } +} + +export function registerBarGridAxisHandlers(registers: EChartsExtensionInstallRegisters) { + callOnlyOnce(registers, function () { + + function register(seriesType: BaseBarSeriesSubType): void { + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_CARTESIAN_2D); + requireAxisStatisticsForBaseBar( + registers, + axisStatKey, + seriesType, + COORD_SYS_TYPE_CARTESIAN_2D + ); + registerAxisContainShapeHandler( + axisStatKey, + barGridCreateAxisContainShapeHandler(seriesType) + ); + } + + register('bar'); + register('pictorialBar'); + }); } diff --git a/src/layout/barPolar.ts b/src/layout/barPolar.ts index 6e1d054fe0..2cdf2eae15 100644 --- a/src/layout/barPolar.ts +++ b/src/layout/barPolar.ts @@ -17,8 +17,7 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; -import {parsePercent} from '../util/number'; +import {mathAbs, mathMax, mathMin, mathPI, parsePercent} from '../util/number'; import {isDimensionStacked} from '../data/helper/dataStackHelper'; import type BarSeriesModel from '../chart/bar/BarSeries'; import type Polar from '../coord/polar/Polar'; @@ -27,7 +26,19 @@ import RadiusAxis from '../coord/polar/RadiusAxis'; import GlobalModel from '../model/Global'; import ExtensionAPI from '../core/ExtensionAPI'; import { Dictionary } from '../util/types'; -import { PolarAxisModel } from '../coord/polar/AxisModel'; +import { calcBandWidth } from '../coord/axisBand'; +import { createBandWidthBasedAxisContainShapeHandler, makeAxisStatKey2 } from '../chart/helper/axisSnippets'; +import { makeCallOnlyOnce } from '../util/model'; +import { EChartsExtensionInstallRegisters } from '../extension'; +import { registerAxisContainShapeHandler } from '../coord/scaleRawExtentInfo'; +import { getStartValue, requireAxisStatisticsForBaseBar } from './barCommon'; +import { eachAxisOnKey, eachSeriesOnAxisOnKey } from '../coord/axisStatistics'; +import { COORD_SYS_TYPE_POLAR } from '../coord/polar/PolarModel'; +import type Axis from '../coord/Axis'; +import { assert, each } from 'zrender/src/core/util'; + + +const callOnlyOnce = makeCallOnlyOnce(); type PolarAxis = AngleAxis | RadiusAxis; @@ -35,209 +46,187 @@ interface StackInfo { width: number maxWidth: number } -interface LayoutColumnInfo { - autoWidthCount: number - bandWidth: number - remainedWidth: number - categoryGap: string | number - gap: string | number - stacks: Dictionary -} interface BarWidthAndOffset { width: number offset: number } +type StackId = string; + +type BarWidthAndOffsetOnAxis = Record; + +type LastStackCoords = Record; + function getSeriesStackId(seriesModel: BarSeriesModel) { return seriesModel.get('stack') || '__ec_stack_' + seriesModel.seriesIndex; } -function getAxisKey(polar: Polar, axis: PolarAxis) { - return axis.dim + polar.model.componentIndex; -} +export function barLayoutPolar(seriesType: 'bar', ecModel: GlobalModel, api: ExtensionAPI) { + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_POLAR); -function barLayoutPolar(seriesType: string, ecModel: GlobalModel, api: ExtensionAPI) { + eachAxisOnKey(ecModel, axisStatKey, function (axis: PolarAxis) { + if (__DEV__) { + assert((axis instanceof AngleAxis) || axis instanceof RadiusAxis); + } - const lastStackCoords: Dictionary<{p: number, n: number}[]> = {}; + const barWidthAndOffset = calcRadialBar(axis, seriesType); - const barWidthAndOffset = calRadialBar( - zrUtil.filter( - ecModel.getSeriesByType(seriesType) as BarSeriesModel[], - function (seriesModel) { - return !ecModel.isSeriesFiltered(seriesModel) - && seriesModel.coordinateSystem - && seriesModel.coordinateSystem.type === 'polar'; - } - ) - ); - - ecModel.eachSeriesByType(seriesType, function (seriesModel: BarSeriesModel) { + const lastStackCoords: LastStackCoords = {}; + eachSeriesOnAxisOnKey(axis, axisStatKey, function (seriesModel: BarSeriesModel) { + layoutPerAxisPerSeries(axis, seriesModel, barWidthAndOffset, lastStackCoords); + }); + }); +} - // Check series coordinate, do layout for polar only - if (seriesModel.coordinateSystem.type !== 'polar') { - return; +function layoutPerAxisPerSeries( + baseAxis: PolarAxis, + seriesModel: BarSeriesModel, + barWidthAndOffset: BarWidthAndOffsetOnAxis, + lastStackCoords: LastStackCoords +) { + const data = seriesModel.getData(); + const stackId = getSeriesStackId(seriesModel); + const columnLayoutInfo = barWidthAndOffset[stackId]; + const columnOffset = columnLayoutInfo.offset; + const columnWidth = columnLayoutInfo.width; + const polar = seriesModel.coordinateSystem as Polar; + if (__DEV__) { + assert(polar.type === COORD_SYS_TYPE_POLAR); + } + const valueAxis = polar.getOtherAxis(baseAxis); + + const cx = polar.cx; + const cy = polar.cy; + + const barMinHeight = seriesModel.get('barMinHeight') || 0; + const barMinAngle = seriesModel.get('barMinAngle') || 0; + + lastStackCoords[stackId] = lastStackCoords[stackId] || []; + + const valueDim = data.mapDimension(valueAxis.dim); + const baseDim = data.mapDimension(baseAxis.dim); + const stacked = isDimensionStacked(data, valueDim /* , baseDim */); + const clampLayout = baseAxis.dim !== 'radius' + || !seriesModel.get('roundCap', true); + + const valueAxisStart = valueAxis.dataToCoord(getStartValue(baseAxis)); + + for (let idx = 0, len = data.count(); idx < len; idx++) { + const value = data.get(valueDim, idx) as number; + const baseValue = data.get(baseDim, idx) as number; + + const sign = value >= 0 ? 'p' : 'n' as 'p' | 'n'; + let baseCoord = valueAxisStart; + + // Because of the barMinHeight, we can not use the value in + // stackResultDimension directly. + if (stacked) { + // FIXME: follow the same logic in `barGrid.ts`: + // Use stackResultDimension, and lastStackCoords is not needed. + if (!lastStackCoords[stackId][baseValue]) { + lastStackCoords[stackId][baseValue] = { + p: valueAxisStart, // Positive stack + n: valueAxisStart // Negative stack + }; + } + // Should also consider #4243 + baseCoord = lastStackCoords[stackId][baseValue][sign]; } - const data = seriesModel.getData(); - const polar = seriesModel.coordinateSystem as Polar; - const baseAxis = polar.getBaseAxis(); - const axisKey = getAxisKey(polar, baseAxis); - - const stackId = getSeriesStackId(seriesModel); - const columnLayoutInfo = barWidthAndOffset[axisKey][stackId]; - const columnOffset = columnLayoutInfo.offset; - const columnWidth = columnLayoutInfo.width; - const valueAxis = polar.getOtherAxis(baseAxis); - - const cx = seriesModel.coordinateSystem.cx; - const cy = seriesModel.coordinateSystem.cy; - - const barMinHeight = seriesModel.get('barMinHeight') || 0; - const barMinAngle = seriesModel.get('barMinAngle') || 0; - - lastStackCoords[stackId] = lastStackCoords[stackId] || []; - - const valueDim = data.mapDimension(valueAxis.dim); - const baseDim = data.mapDimension(baseAxis.dim); - const stacked = isDimensionStacked(data, valueDim /* , baseDim */); - const clampLayout = baseAxis.dim !== 'radius' - || !seriesModel.get('roundCap', true); - - const valueAxisModel = valueAxis.model as PolarAxisModel; - const startValue = valueAxisModel.get('startValue'); - const valueAxisStart = valueAxis.dataToCoord(startValue || 0); - - for (let idx = 0, len = data.count(); idx < len; idx++) { - const value = data.get(valueDim, idx) as number; - const baseValue = data.get(baseDim, idx) as number; - - const sign = value >= 0 ? 'p' : 'n' as 'p' | 'n'; - let baseCoord = valueAxisStart; - - // Because of the barMinHeight, we can not use the value in - // stackResultDimension directly. - // Only ordinal axis can be stacked. - if (stacked) { - - if (!lastStackCoords[stackId][baseValue]) { - lastStackCoords[stackId][baseValue] = { - p: valueAxisStart, // Positive stack - n: valueAxisStart // Negative stack - }; - } - // Should also consider #4243 - baseCoord = lastStackCoords[stackId][baseValue][sign]; - } + let r0; + let r; + let startAngle; + let endAngle; - let r0; - let r; - let startAngle; - let endAngle; + // radial sector + if (valueAxis.dim === 'radius') { + let radiusSpan = valueAxis.dataToCoord(value) - valueAxisStart; + const angle = baseAxis.dataToCoord(baseValue); - // radial sector - if (valueAxis.dim === 'radius') { - let radiusSpan = valueAxis.dataToCoord(value) - valueAxisStart; - const angle = baseAxis.dataToCoord(baseValue); + if (mathAbs(radiusSpan) < barMinHeight) { + radiusSpan = (radiusSpan < 0 ? -1 : 1) * barMinHeight; + } - if (Math.abs(radiusSpan) < barMinHeight) { - radiusSpan = (radiusSpan < 0 ? -1 : 1) * barMinHeight; - } + r0 = baseCoord; + r = baseCoord + radiusSpan; + startAngle = angle - columnOffset; + endAngle = startAngle - columnWidth; - r0 = baseCoord; - r = baseCoord + radiusSpan; - startAngle = angle - columnOffset; - endAngle = startAngle - columnWidth; + stacked && (lastStackCoords[stackId][baseValue][sign] = r); + } + // tangential sector + else { + let angleSpan = valueAxis.dataToCoord(value, clampLayout) - valueAxisStart; + const radius = baseAxis.dataToCoord(baseValue); - stacked && (lastStackCoords[stackId][baseValue][sign] = r); + if (mathAbs(angleSpan) < barMinAngle) { + angleSpan = (angleSpan < 0 ? -1 : 1) * barMinAngle; } - // tangential sector - else { - let angleSpan = valueAxis.dataToCoord(value, clampLayout) - valueAxisStart; - const radius = baseAxis.dataToCoord(baseValue); - - if (Math.abs(angleSpan) < barMinAngle) { - angleSpan = (angleSpan < 0 ? -1 : 1) * barMinAngle; - } - - r0 = radius + columnOffset; - r = r0 + columnWidth; - startAngle = baseCoord; - endAngle = baseCoord + angleSpan; - - // if the previous stack is at the end of the ring, - // add a round to differentiate it from origin - // let extent = angleAxis.getExtent(); - // let stackCoord = angle; - // if (stackCoord === extent[0] && value > 0) { - // stackCoord = extent[1]; - // } - // else if (stackCoord === extent[1] && value < 0) { - // stackCoord = extent[0]; - // } - stacked && (lastStackCoords[stackId][baseValue][sign] = endAngle); - } - - data.setItemLayout(idx, { - cx: cx, - cy: cy, - r0: r0, - r: r, - // Consider that positive angle is anti-clockwise, - // while positive radian of sector is clockwise - startAngle: -startAngle * Math.PI / 180, - endAngle: -endAngle * Math.PI / 180, - - /** - * Keep the same logic with bar in catesion: use end value to - * control direction. Notice that if clockwise is true (by - * default), the sector will always draw clockwisely, no matter - * whether endAngle is greater or less than startAngle. - */ - clockwise: startAngle >= endAngle - }); + r0 = radius + columnOffset; + r = r0 + columnWidth; + startAngle = baseCoord; + endAngle = baseCoord + angleSpan; + + // if the previous stack is at the end of the ring, + // add a round to differentiate it from origin + // let extent = angleAxis.getExtent(); + // let stackCoord = angle; + // if (stackCoord === extent[0] && value > 0) { + // stackCoord = extent[1]; + // } + // else if (stackCoord === extent[1] && value < 0) { + // stackCoord = extent[0]; + // } + stacked && (lastStackCoords[stackId][baseValue][sign] = endAngle); } - }); + data.setItemLayout(idx, { + cx: cx, + cy: cy, + r0: r0, + r: r, + // Consider that positive angle is anti-clockwise, + // while positive radian of sector is clockwise + startAngle: -startAngle * mathPI / 180, + endAngle: -endAngle * mathPI / 180, + + /** + * Keep the same logic with bar in catesion: use end value to + * control direction. Notice that if clockwise is true (by + * default), the sector will always draw clockwisely, no matter + * whether endAngle is greater or less than startAngle. + */ + clockwise: startAngle >= endAngle + }); + } } /** * Calculate bar width and offset for radial bar charts */ -function calRadialBar(barSeries: BarSeriesModel[]) { - // Columns info on each category axis. Key is polar name - const columnsMap: Dictionary = {}; - - zrUtil.each(barSeries, function (seriesModel, idx) { - const data = seriesModel.getData(); - const polar = seriesModel.coordinateSystem as Polar; - - const baseAxis = polar.getBaseAxis(); - const axisKey = getAxisKey(polar, baseAxis); - - const axisExtent = baseAxis.getExtent(); - const bandWidth = baseAxis.type === 'category' - ? baseAxis.getBandWidth() - : (Math.abs(axisExtent[1] - axisExtent[0]) / data.count()); - - const columnsOnAxis = columnsMap[axisKey] || { - bandWidth: bandWidth, - remainedWidth: bandWidth, - autoWidthCount: 0, - categoryGap: '20%', - gap: '30%', - stacks: {} - }; - const stacks = columnsOnAxis.stacks; - columnsMap[axisKey] = columnsOnAxis; +function calcRadialBar(axis: Axis, seriesType: 'bar'): BarWidthAndOffsetOnAxis { + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_POLAR); + const bandWidth = calcBandWidth( + axis, + {fromStat: {key: axisStatKey}, min: 1} + ).w; + + let remainedWidth: number = bandWidth; + let autoWidthCount: number = 0; + let categoryGapOption: string | number = '20%'; + let gapOption: string | number = '30%'; + const stacks: Dictionary = {}; + + eachSeriesOnAxisOnKey(axis, axisStatKey, function (seriesModel: BarSeriesModel) { const stackId = getSeriesStackId(seriesModel); if (!stacks[stackId]) { - columnsOnAxis.autoWidthCount++; + autoWidthCount++; } stacks[stackId] = stacks[stackId] || { width: 0, @@ -252,82 +241,90 @@ function calRadialBar(barSeries: BarSeriesModel[]) { seriesModel.get('barMaxWidth'), bandWidth ); - const barGap = seriesModel.get('barGap'); - const barCategoryGap = seriesModel.get('barCategoryGap'); + const barGapOption = seriesModel.get('barGap'); + const barCategoryGapOption = seriesModel.get('barCategoryGap'); if (barWidth && !stacks[stackId].width) { - barWidth = Math.min(columnsOnAxis.remainedWidth, barWidth); + barWidth = mathMin(remainedWidth, barWidth); stacks[stackId].width = barWidth; - columnsOnAxis.remainedWidth -= barWidth; + remainedWidth -= barWidth; } barMaxWidth && (stacks[stackId].maxWidth = barMaxWidth); - (barGap != null) && (columnsOnAxis.gap = barGap); - (barCategoryGap != null) && (columnsOnAxis.categoryGap = barCategoryGap); + // For historical design, use the last series declared that. + (barGapOption != null) && (gapOption = barGapOption); + (barCategoryGapOption != null) && (categoryGapOption = barCategoryGapOption); }); + const result: BarWidthAndOffsetOnAxis = {}; - const result: Dictionary> = {}; - - zrUtil.each(columnsMap, function (columnsOnAxis, coordSysName) { + const categoryGap = parsePercent(categoryGapOption, bandWidth); + const barGapPercent = parsePercent(gapOption, 1); - result[coordSysName] = {}; + let autoWidth = (remainedWidth - categoryGap) + / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); + autoWidth = mathMax(autoWidth, 0); - const stacks = columnsOnAxis.stacks; - const bandWidth = columnsOnAxis.bandWidth; - const categoryGap = parsePercent(columnsOnAxis.categoryGap, bandWidth); - const barGapPercent = parsePercent(columnsOnAxis.gap, 1); - - let remainedWidth = columnsOnAxis.remainedWidth; - let autoWidthCount = columnsOnAxis.autoWidthCount; - let autoWidth = (remainedWidth - categoryGap) - / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); - autoWidth = Math.max(autoWidth, 0); - - // Find if any auto calculated bar exceeded maxBarWidth - zrUtil.each(stacks, function (column, stack) { - let maxWidth = column.maxWidth; - if (maxWidth && maxWidth < autoWidth) { - maxWidth = Math.min(maxWidth, remainedWidth); - if (column.width) { - maxWidth = Math.min(maxWidth, column.width); - } - remainedWidth -= maxWidth; - column.width = maxWidth; - autoWidthCount--; + // Find if any auto calculated bar exceeded maxBarWidth + each(stacks, function (column, stack) { + let maxWidth = column.maxWidth; + if (maxWidth && maxWidth < autoWidth) { + maxWidth = mathMin(maxWidth, remainedWidth); + if (column.width) { + maxWidth = mathMin(maxWidth, column.width); } - }); + remainedWidth -= maxWidth; + column.width = maxWidth; + autoWidthCount--; + } + }); - // Recalculate width again - autoWidth = (remainedWidth - categoryGap) - / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); - autoWidth = Math.max(autoWidth, 0); + // Recalculate width again + autoWidth = (remainedWidth - categoryGap) + / (autoWidthCount + (autoWidthCount - 1) * barGapPercent); + autoWidth = mathMax(autoWidth, 0); - let widthSum = 0; - let lastColumn: StackInfo; - zrUtil.each(stacks, function (column, idx) { - if (!column.width) { - column.width = autoWidth; - } - lastColumn = column; - widthSum += column.width * (1 + barGapPercent); - }); - if (lastColumn) { - widthSum -= lastColumn.width * barGapPercent; + let widthSum = 0; + let lastColumn: StackInfo; + each(stacks, function (column, idx) { + if (!column.width) { + column.width = autoWidth; } + lastColumn = column; + widthSum += column.width * (1 + barGapPercent); + }); + if (lastColumn) { + widthSum -= lastColumn.width * barGapPercent; + } + + let offset = -widthSum / 2; + each(stacks, function (column, stackId) { + result[stackId] = result[stackId] || { + offset: offset, + width: column.width + }; - let offset = -widthSum / 2; - zrUtil.each(stacks, function (column, stackId) { - result[coordSysName][stackId] = result[coordSysName][stackId] || { - offset: offset, - width: column.width - } as BarWidthAndOffset; - - offset += column.width * (1 + barGapPercent); - }); + offset += column.width * (1 + barGapPercent); }); return result; } -export default barLayoutPolar; \ No newline at end of file +export function registerBarPolarAxisHandlers( + registers: EChartsExtensionInstallRegisters, + seriesType: 'bar' // Currently only 'bar' is supported. +): void { + callOnlyOnce(registers, function () { + const axisStatKey = makeAxisStatKey2(seriesType, COORD_SYS_TYPE_POLAR); + requireAxisStatisticsForBaseBar( + registers, + axisStatKey, + seriesType, + COORD_SYS_TYPE_POLAR + ); + registerAxisContainShapeHandler( + axisStatKey, + createBandWidthBasedAxisContainShapeHandler(axisStatKey) + ); + }); +} diff --git a/src/model/Component.ts b/src/model/Component.ts index 3ff62785f4..a2a938f25e 100644 --- a/src/model/Component.ts +++ b/src/model/Component.ts @@ -191,7 +191,9 @@ class ComponentModel extends Mode /** * Called immediately after `init` or `mergeOption` of this instance called. */ - optionUpdated(newCptOption: Opt, isInit: boolean): void {} + optionUpdated(newCptOption: Opt, isInit: boolean): void { + // MUST NOT do anything here. + } /** * [How to declare defaultOption]: diff --git a/src/model/Global.ts b/src/model/Global.ts index c1e2be3250..1b5c741c28 100644 --- a/src/model/Global.ts +++ b/src/model/Global.ts @@ -184,7 +184,7 @@ class GlobalModel extends Model { * Key: seriesIndex. * Keep consistent with `_seriesIndices`. */ - private _seriesIndicesMap: HashMap; + private _seriesIndicesMap: HashMap; /** * Model for store update payload @@ -810,9 +810,6 @@ echarts.use([${seriesImportName}]);`); /** * Iterate raw series before filtered. - * - * @param {Function} cb - * @param {*} context */ eachRawSeries( cb: (this: T, series: SeriesModel, rawSeriesIndex: number) => void, @@ -852,6 +849,9 @@ echarts.use([${seriesImportName}]);`); return each(this.getSeriesByType(subType), cb, context); } + /** + * It means "filtered out". + */ isSeriesFiltered(seriesModel: SeriesModel): boolean { assertSeriesInitialized(this); return this._seriesIndicesMap.get(seriesModel.componentIndex) == null; @@ -874,7 +874,7 @@ echarts.use([${seriesImportName}]);`); }, this); this._seriesIndices = newSeriesIndices; - this._seriesIndicesMap = createHashMap(newSeriesIndices); + this._seriesIndicesMap = createHashMap(newSeriesIndices); } restoreData(payload?: Payload): void { @@ -915,7 +915,7 @@ echarts.use([${seriesImportName}]);`); // series may have been removed by `replaceMerge`. series && seriesIndices.push(series.componentIndex); }); - ecModel._seriesIndicesMap = createHashMap(seriesIndices); + ecModel._seriesIndicesMap = createHashMap(seriesIndices); }; assertSeriesInitialized = function (ecModel: GlobalModel): void { diff --git a/src/model/OptionManager.ts b/src/model/OptionManager.ts index ef795ba58e..846d3c2157 100644 --- a/src/model/OptionManager.ts +++ b/src/model/OptionManager.ts @@ -17,12 +17,6 @@ * under the License. */ -/** - * ECharts option manager - */ - - -// import ComponentModel, { ComponentModelConstructor } from './Component'; import ExtensionAPI from '../core/ExtensionAPI'; import { OptionPreprocessor, MediaQuery, ECUnitOption, MediaUnit, ECBasicOption, SeriesOption diff --git a/src/model/Series.ts b/src/model/Series.ts index 17779c912a..5446cdccdf 100644 --- a/src/model/Series.ts +++ b/src/model/Series.ts @@ -434,7 +434,6 @@ class SeriesModel extends ComponentMode */ getBaseAxis(): Axis { const coordSys = this.coordinateSystem; - // @ts-ignore return coordSys && coordSys.getBaseAxis && coordSys.getBaseAxis(); } @@ -537,6 +536,7 @@ class SeriesModel extends ComponentMode } restoreData() { + // See `dataTaskReset`. this.dataTask.dirty(); } diff --git a/src/model/referHelper.ts b/src/model/referHelper.ts index e9010a9835..c612c5eb64 100644 --- a/src/model/referHelper.ts +++ b/src/model/referHelper.ts @@ -61,7 +61,7 @@ import { AxisModelExtendedInCreator } from '../coord/axisModelCreator'; * } */ -class CoordSysInfo { +export class SeriesModelCoordSysInfo { coordSysName: string; @@ -84,14 +84,14 @@ type FetcherAxisModel = & Pick; type Fetcher = ( seriesModel: SeriesModel, - result: CoordSysInfo, + result: SeriesModelCoordSysInfo, axisMap: HashMap, categoryAxisMap: HashMap ) => void; -export function getCoordSysInfoBySeries(seriesModel: SeriesModel) { +export function getCoordSysInfoBySeries(seriesModel: SeriesModel): SeriesModelCoordSysInfo { const coordSysName = seriesModel.get('coordinateSystem') as SupportedCoordSys; - const result = new CoordSysInfo(coordSysName); + const result = new SeriesModelCoordSysInfo(coordSysName); const fetch = fetchers[coordSysName]; if (fetch) { fetch(seriesModel, result, result.axisMap, result.categoryAxisMap); diff --git a/src/scale/Interval.ts b/src/scale/Interval.ts index 917aa3f531..3657efe7e5 100644 --- a/src/scale/Interval.ts +++ b/src/scale/Interval.ts @@ -18,27 +18,102 @@ */ -import * as numberUtil from '../util/number'; -import * as formatUtil from '../util/format'; -import Scale, { ScaleGetTicksOpt, ScaleSettingDefault } from './Scale'; -import * as helper from './helper'; -import {ScaleTick, ParsedAxisBreakList, ScaleDataValue} from '../util/types'; -import { getScaleBreakHelper } from './break'; +import {round, mathRound, mathMin, getPrecision} from '../util/number'; +import {addCommas} from '../util/format'; +import Scale, { ScaleGetTicksOpt } from './Scale'; +import { getIntervalPrecision, IntervalScaleGetLabelOpt } from './helper'; +import {ScaleTick, ScaleDataValue, NullUndefined, AxisBreakOption} from '../util/types'; +import { + AxisBreakParsingResult, getBreaksUnsafe, getScaleBreakHelper, hasBreaks, simplyParseBreakOption +} from './break'; +import { assert, clone } from 'zrender/src/core/util'; +import { getMinorTicks } from './minorTicks'; +import { + getScaleExtentForTickUnsafe, + initBreakOrLinearMapper, ScaleMapperGeneric +} from './scaleMapper'; + + +export type IntervalScaleConfig = { + interval: IntervalScaleConfigParsed['interval']; + intervalPrecision?: IntervalScaleConfigParsed['intervalPrecision'] | NullUndefined; + intervalCount?: IntervalScaleConfigParsed['intervalCount'] | NullUndefined; + niceExtent?: IntervalScaleConfigParsed['niceExtent'] | NullUndefined; +}; + +type IntervalScaleConfigParsed = { + /** + * Step of ticks. + */ + interval: number; + intervalPrecision: number; + /** + * `_intervalCount` effectively specifies the number of "nice segments". This is for special cases, + * such as `alignTicks: true` and min max are fixed. In this case, `_interval` may be specified with + * a "not-nice" value and needs to be rounded with `_intervalPrecision` for better appearance. Then + * merely accumulating `_interval` may generate incorrect number of ticks due to cumulative errors. + * So `_intervalCount` is required to specify the expected nice ticks number. + * Should ensure `_intervalCount >= -1`, + * where `-1` means no nice tick (e.g., `_extent: [5.2, 5.8], _interval: 1`), + * and `0` means only one nice tick (e.g., `_extent: [5, 5.8], _interval: 1`). + * @see setInterval + */ + intervalCount: number | NullUndefined; + /** + * Should ensure: + * `_extent[0] <= _niceExtent[0] && _niceExtent[1] <= _extent[1]` + * But NOTICE: + * `_niceExtent[0] - _niceExtent[1] <= _interval`, rather than always `< 0`, + * because `_niceExtent` is typically calculated by + * `[ Math.ceil(_extent[0] / _interval) * _interval, Math.floor(_extent[1] / _interval) * _interval ]`. + * e.g., `_extent: [5.2, 5.8]` with interval `1` will get `_niceExtent: [6, 5]`. + * e.g., `_extent: [5, 5.8]` with interval `1` will get `_niceExtent: [5, 5]`. + * e.g., `_extent: [5.7, 5.7]` with interval `1` will get `_niceExtent: [6, 5]`. + * @see setInterval + */ + niceExtent: number[] | NullUndefined; +}; -const roundNumber = numberUtil.round; +type IntervalScaleSetting = { + // Either `breakOption` or `parsedBreaks` can be specified. + breakOption?: AxisBreakOption[] | NullUndefined; + breakParsed?: AxisBreakParsingResult | NullUndefined; +}; -class IntervalScale extends Scale { +/** + * @final NEVER inherit me! + */ +interface IntervalScale extends ScaleMapperGeneric {} +class IntervalScale extends Scale { static type = 'interval'; - type = 'interval'; + type = 'interval' as const; + + private _cfg: IntervalScaleConfigParsed; + + + constructor(setting?: IntervalScaleSetting) { + super(); + + this.parse = IntervalScale.parse; - // Step is calculated in adjustExtent. - protected _interval: number = 0; - protected _niceExtent: [number, number]; - protected _intervalPrecision: number = 2; + setting = setting || {}; + const breakParsed = simplyParseBreakOption(this, setting); - parse(val: ScaleDataValue): number { + const res = initBreakOrLinearMapper(this, breakParsed, null); + // @ts-ignore + this.brk = res.brk; + + this._cfg = { + interval: 0, + intervalPrecision: 2, + intervalCount: undefined, + niceExtent: undefined, + }; + } + + static parse(val: ScaleDataValue): number { // `Scale#parse` (and its overrids) are typically applied at the axis values input // in echarts option. e.g., `axis.min/max`, `dataZoom.min/max`, etc. // but `series.data` is not included, which uses `dataValueHelper.ts`#`parseDataValue`. @@ -65,41 +140,55 @@ class IntervalScale e : Number(val); } - contain(val: number): boolean { - return helper.contain(val, this._extent); - } - - normalize(val: number): number { - return this._calculator.normalize(val, this._extent); - } - - scale(val: number): number { - return this._calculator.scale(val, this._extent); - } - - getInterval(): number { - return this._interval; + getConfig(): IntervalScaleConfigParsed { + return clone(this._cfg); } - setInterval(interval: number): void { - this._interval = interval; - // Dropped auto calculated niceExtent and use user-set extent. - // We assume user wants to set both interval, min, max to get a better result. - this._niceExtent = this._extent.slice() as [number, number]; + setConfig(cfg: IntervalScaleConfig): void { + const extent = getScaleExtentForTickUnsafe(this); + + if (__DEV__) { + assert(cfg.interval != null); + if (cfg.intervalCount != null) { + assert( + cfg.intervalCount >= -1 + && cfg.intervalPrecision != null + // Do not support intervalCount on axis break currently. + && !hasBreaks(this) + ); + } + if (cfg.niceExtent != null) { + assert(isFinite(cfg.niceExtent[0]) && isFinite(cfg.niceExtent[1])); + assert(extent[0] <= cfg.niceExtent[0] && cfg.niceExtent[1] <= extent[1]); + assert(round(cfg.niceExtent[0] - cfg.niceExtent[1], getPrecision(cfg.interval)) <= cfg.interval); + } + } - this._intervalPrecision = helper.getIntervalPrecision(interval); + // Reset all. + this._cfg = cfg = clone(cfg) as IntervalScaleConfigParsed; + if (cfg.niceExtent == null) { + // Dropped the auto calculated niceExtent and use user-set extent. + // We assume users want to set both interval and extent to get a better result. + cfg.niceExtent = extent.slice() as [number, number]; + } + if (cfg.intervalPrecision == null) { + cfg.intervalPrecision = getIntervalPrecision(cfg.interval); + } } /** - * @override + * In ascending order. */ getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { opt = opt || {}; - const interval = this._interval; - const extent = this._extent; - const niceTickExtent = this._niceExtent; - const intervalPrecision = this._intervalPrecision; + const cfg = this._cfg; + const interval = cfg.interval; + const extent = getScaleExtentForTickUnsafe(this); + const niceExtent = cfg.niceExtent; + const intervalPrecision = cfg.intervalPrecision; const scaleBreakHelper = getScaleBreakHelper(); + const brk = this.brk; + const brkAvailable = scaleBreakHelper && brk; const ticks = [] as ScaleTick[]; // If interval is 0, return []; @@ -107,18 +196,24 @@ class IntervalScale e return ticks; } - if (opt.breakTicks === 'only_break' && scaleBreakHelper) { - scaleBreakHelper.addBreaksToTicks(ticks, this._brkCtx!.breaks, this._extent); + if (opt.breakTicks === 'only_break' && brkAvailable) { + scaleBreakHelper.addBreaksToTicks(ticks, brk.breaks, extent); return ticks; } + if (__DEV__) { + assert(niceExtent != null); + } + + // [CAVEAT]: If changing this logic, must sync it to `axisAlignTicks.ts`. + // Consider this case: using dataZoom toolbox, zoom and zoom. const safeLimit = 10000; - if (extent[0] < niceTickExtent[0]) { + if (extent[0] < niceExtent[0]) { if (opt.expandToNicedExtent) { ticks.push({ - value: roundNumber(niceTickExtent[0] - interval, intervalPrecision) + value: round(niceExtent[0] - interval, intervalPrecision) }); } else { @@ -129,21 +224,47 @@ class IntervalScale e } const estimateNiceMultiple = (tickVal: number, targetTick: number) => { - return Math.round((targetTick - tickVal) / interval); + return mathRound((targetTick - tickVal) / interval); }; - let tick = niceTickExtent[0]; - while (tick <= niceTickExtent[1]) { + const intervalCount = cfg.intervalCount; + for ( + let tick = niceExtent[0], niceTickIdx = 0; + ; + niceTickIdx++ + ) { + // Consider case `_extent: [5.2, 5.8], _niceExtent: [6, 5], interval: 1`, + // `_intervalCount` makes sense iff `-1`. + // Consider case `_extent: [5, 5.8], _niceExtent: [5, 5], interval: 1`, + // `_intervalCount` makes sense iff `0`. + if (intervalCount == null) { + if (tick > niceExtent[1] || !isFinite(tick) || !isFinite(niceExtent[1])) { + break; + } + } + else { + if (niceTickIdx > intervalCount) { // nice ticks number should be `intervalCount + 1` + break; + } + // Consider cumulative error, especially caused by rounding, the last nice + // `tick` may be less than or greater than `niceExtent[1]` slightly. + tick = mathMin(tick, niceExtent[1]); + if (niceTickIdx === intervalCount) { + tick = niceExtent[1]; + } + } + ticks.push({ value: tick }); // Avoid rounding error - tick = roundNumber(tick + interval, intervalPrecision); - if (this._brkCtx) { - const moreMultiple = this._brkCtx.calcNiceTickMultiple(tick, estimateNiceMultiple); + tick = round(tick + interval, intervalPrecision); + + if (brk) { + const moreMultiple = brk.calcNiceTickMultiple(tick, estimateNiceMultiple); if (moreMultiple >= 0) { - tick = roundNumber(tick + moreMultiple * interval, intervalPrecision); + tick = round(tick + moreMultiple * interval, intervalPrecision); } } @@ -156,13 +277,14 @@ class IntervalScale e return []; } } + // Consider this case: the last item of ticks is smaller - // than niceTickExtent[1] and niceTickExtent[1] === extent[1]. - const lastNiceTick = ticks.length ? ticks[ticks.length - 1].value : niceTickExtent[1]; + // than niceExtent[1] and niceExtent[1] === extent[1]. + const lastNiceTick = ticks.length ? ticks[ticks.length - 1].value : niceExtent[1]; if (extent[1] > lastNiceTick) { if (opt.expandToNicedExtent) { ticks.push({ - value: roundNumber(lastNiceTick + interval, intervalPrecision) + value: round(lastNiceTick + interval, intervalPrecision) }); } else { @@ -172,88 +294,35 @@ class IntervalScale e } } - if (scaleBreakHelper) { + if (brkAvailable) { scaleBreakHelper.pruneTicksByBreak( opt.pruneByBreak, ticks, - this._brkCtx!.breaks, + brk.breaks, item => item.value, - this._interval, - this._extent + cfg.interval, + extent ); } - if (opt.breakTicks !== 'none' && scaleBreakHelper) { - scaleBreakHelper.addBreaksToTicks(ticks, this._brkCtx!.breaks, this._extent); + if (brkAvailable && opt.breakTicks !== 'none') { + scaleBreakHelper.addBreaksToTicks(ticks, brk.breaks, extent); } return ticks; } getMinorTicks(splitNumber: number): number[][] { - const ticks = this.getTicks({ - expandToNicedExtent: true, - }); - // NOTE: In log-scale, do not support minor ticks when breaks exist. - // because currently log-scale minor ticks is calculated based on raw values - // rather than log-transformed value, due to an odd effect when breaks exist. - const minorTicks = []; - const extent = this.getExtent(); - - for (let i = 1; i < ticks.length; i++) { - const nextTick = ticks[i]; - const prevTick = ticks[i - 1]; - - if (prevTick.break || nextTick.break) { - // Do not build minor ticks to the adjacent ticks to breaks ticks, - // since the interval might be irregular. - continue; - } - - let count = 0; - const minorTicksGroup = []; - const interval = nextTick.value - prevTick.value; - const minorInterval = interval / splitNumber; - const minorIntervalPrecision = helper.getIntervalPrecision(minorInterval); - - while (count < splitNumber - 1) { - const minorTick = roundNumber(prevTick.value + (count + 1) * minorInterval, minorIntervalPrecision); - - // For the first and last interval. The count may be less than splitNumber. - if (minorTick > extent[0] && minorTick < extent[1]) { - minorTicksGroup.push(minorTick); - } - count++; - } - - const scaleBreakHelper = getScaleBreakHelper(); - scaleBreakHelper && scaleBreakHelper.pruneTicksByBreak( - 'auto', - minorTicksGroup, - this._getNonTransBreaks(), - value => value, - this._interval, - extent - ); - minorTicks.push(minorTicksGroup); - } - - return minorTicks; - } - - protected _getNonTransBreaks(): ParsedAxisBreakList { - return this._brkCtx ? this._brkCtx.breaks : []; + return getMinorTicks( + this, + splitNumber, + getBreaksUnsafe(this), + this._cfg.interval + ); } - /** - * @param opt.precision If 'auto', use nice presision. - * @param opt.pad returns 1.50 but not 1.5 if precision is 2. - */ getLabel( data: ScaleTick, - opt?: { - precision?: 'auto' | number, - pad?: boolean - } + opt?: IntervalScaleGetLabelOpt ): string { if (data == null) { return ''; @@ -262,109 +331,18 @@ class IntervalScale e let precision = opt && opt.precision; if (precision == null) { - precision = numberUtil.getPrecision(data.value) || 0; + precision = getPrecision(data.value) || 0; } else if (precision === 'auto') { // Should be more precise then tick. - precision = this._intervalPrecision; + precision = this._cfg.intervalPrecision; } // (1) If `precision` is set, 12.005 should be display as '12.00500'. - // (2) Use roundNumber (toFixed) to avoid scientific notation like '3.5e-7'. - const dataNum = roundNumber(data.value, precision as number, true); - - return formatUtil.addCommas(dataNum); - } - - /** - * FIXME: refactor - disallow override, use composition instead. - * - * The override of `calcNiceTicks` should ensure these members are provided: - * this._intervalPrecision - * this._interval - * - * @param splitNumber By default `5`. - */ - calcNiceTicks(splitNumber?: number, minInterval?: number, maxInterval?: number): void { - splitNumber = splitNumber || 5; - let extent = this._extent.slice() as [number, number]; - let span = this._getExtentSpanWithBreaks(); - if (!isFinite(span)) { - return; - } - // User may set axis min 0 and data are all negative - // FIXME If it needs to reverse ? - if (span < 0) { - span = -span; - extent.reverse(); - this._innerSetExtent(extent[0], extent[1]); - extent = this._extent.slice() as [number, number]; - } - - const result = helper.intervalScaleNiceTicks( - extent, span, splitNumber, minInterval, maxInterval - ); - - this._intervalPrecision = result.intervalPrecision; - this._interval = result.interval; - this._niceExtent = result.niceTickExtent; - } - - calcNiceExtent(opt: { - splitNumber: number, // By default 5. - fixMin?: boolean, - fixMax?: boolean, - minInterval?: number, - maxInterval?: number - }): void { - let extent = this._extent.slice() as [number, number]; - // If extent start and end are same, expand them - if (extent[0] === extent[1]) { - if (extent[0] !== 0) { - // Expand extent - // Note that extents can be both negative. See #13154 - const expandSize = Math.abs(extent[0]); - // In the fowllowing case - // Axis has been fixed max 100 - // Plus data are all 100 and axis extent are [100, 100]. - // Extend to the both side will cause expanded max is larger than fixed max. - // So only expand to the smaller side. - if (!opt.fixMax) { - extent[1] += expandSize / 2; - extent[0] -= expandSize / 2; - } - else { - extent[0] -= expandSize / 2; - } - } - else { - extent[1] = 1; - } - } - const span = extent[1] - extent[0]; - // If there are no data and extent are [Infinity, -Infinity] - if (!isFinite(span)) { - extent[0] = 0; - extent[1] = 1; - } - this._innerSetExtent(extent[0], extent[1]); - extent = this._extent.slice() as [number, number]; - - this.calcNiceTicks(opt.splitNumber, opt.minInterval, opt.maxInterval); - const interval = this._interval; - const intervalPrecition = this._intervalPrecision; - - if (!opt.fixMin) { - extent[0] = roundNumber(Math.floor(extent[0] / interval) * interval, intervalPrecition); - } - if (!opt.fixMax) { - extent[1] = roundNumber(Math.ceil(extent[1] / interval) * interval, intervalPrecition); - } - this._innerSetExtent(extent[0], extent[1]); - } + // (2) Use `round` (toFixed) to avoid scientific notation like '3.5e-7'. + const dataNum = round(data.value, precision as number, true); - setNiceExtent(min: number, max: number): void { - this._niceExtent = [min, max]; + return addCommas(dataNum); } } diff --git a/src/scale/Log.ts b/src/scale/Log.ts index bdd799056c..e0d1e1c666 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -17,83 +17,125 @@ * under the License. */ -import * as zrUtil from 'zrender/src/core/util'; import Scale, { ScaleGetTicksOpt } from './Scale'; -import * as numberUtil from '../util/number'; - -// Use some method of IntervalScale import IntervalScale from './Interval'; import { - DimensionLoose, DimensionName, ParsedAxisBreakList, AxisBreakOption, - ScaleTick + ScaleTick, + NullUndefined, + AxisBreakOption } from '../util/types'; -import { getIntervalPrecision, logTransform } from './helper'; -import SeriesData from '../data/SeriesData'; -import { getScaleBreakHelper } from './break'; +import { + logScalePowTick, + IntervalScaleGetLabelOpt, + logScaleLogTick, + ValueTransformLookupOpt, +} from './helper'; +import { getBreaksUnsafe, getScaleBreakHelper, ParseAxisBreakOptionInwardTransformOut } from './break'; +import { getMinorTicks } from './minorTicks'; +import { + DecoratedScaleMapperMethods, decorateScaleMapper, enableScaleMapperFreeze, SCALE_EXTENT_KIND_EFFECTIVE, + SCALE_MAPPER_DEPTH_OUT_OF_BREAK, + ScaleMapperTransformOutOpt +} from './scaleMapper'; +import { map } from 'zrender/src/core/util'; +import { isValidBoundsForExtent, isValidNumberForExtent } from '../util/model'; +import { isNullableNumberFinite } from '../util/number'; -const fixRound = numberUtil.round; -const mathFloor = Math.floor; -const mathCeil = Math.ceil; -const mathPow = Math.pow; -const mathLog = Math.log; -class LogScale extends IntervalScale { +type LogScaleSetting = { + logBase: number | NullUndefined; + breakOption: AxisBreakOption[] | NullUndefined; +}; - static type = 'log'; - readonly type = 'log'; +const LOOKUP_IDX_EXTENT_START = 0; +const LOOKUP_IDX_EXTENT_END = 1; +const LOOKUP_IDX_BREAK_START = 2; - base = 10; +/** + * @final NEVER inherit me! + */ +class LogScale extends Scale { - private _originalScale = new IntervalScale(); + static type = 'log'; + readonly type = 'log' as const; - private _fixMin: boolean; - private _fixMax: boolean; + readonly base: number; /** - * @param Whether expand the ticks to niced extent. + * `powStub` is used to save original values, i.e., values before logarithm + * applied, such as raw extent and raw breaks. + * NOTE: Logarithm transform is probably not inversible by rounding error, which + * may cause min/max tick is displayed like `5.999999999999999`. The extent in + * powStub is used to get the original precise extent for this issue. + * + * [CAVEAT] `powStub` and `intervalStub` should be modified synchronously. */ - getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { - opt = opt || {}; - const extent = this._extent.slice() as [number, number]; - const originalExtent = this._originalScale.getExtent(); + readonly powStub: IntervalScale; + /** + * `intervalStub` provides linear tick arrangement (logarithm applied). + * @see {powStub} + */ + readonly intervalStub: IntervalScale; + + private _lookup: ValueTransformLookupOpt['lookup']; + + + constructor(setting: LogScaleSetting) { + super(); + this.parse = IntervalScale.parse; + this.base = setting.logBase || 10; + + const lookupFrom: number[] = []; + const lookupTo: number[] = []; + const lookup = this._lookup = {from: lookupFrom, to: lookupTo}; + lookupFrom[LOOKUP_IDX_EXTENT_START] = + lookupFrom[LOOKUP_IDX_EXTENT_END] = + lookupTo[LOOKUP_IDX_EXTENT_START] = + lookupTo[LOOKUP_IDX_EXTENT_END] = NaN; + + decorateScaleMapper(this, LogScale.mapperMethods); - const ticks = super.getTicks(opt); - const base = this.base; - const originalBreaks = this._originalScale._innerGetBreaks(); const scaleBreakHelper = getScaleBreakHelper(); + const breakOption = setting.breakOption; + const out: ParseAxisBreakOptionInwardTransformOut = {lookup}; + if (scaleBreakHelper) { + scaleBreakHelper.parseAxisBreakOptionInwardTransform( + breakOption, this, {noNegative: true}, LOOKUP_IDX_BREAK_START, out + ); + } + this.powStub = new IntervalScale({breakParsed: out.original}); + this.intervalStub = new IntervalScale({breakParsed: out.transformed}); - return zrUtil.map(ticks, function (tick) { - const val = tick.value; - let roundingCriterion = null; + enableScaleMapperFreeze(this, this.intervalStub); + } - let powVal = mathPow(base, val); + getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { + const base = this.base; + const powStub = this.powStub; + const scaleBreakHelper = getScaleBreakHelper(); + const intervalStub = this.intervalStub; + const intervalExtent = intervalStub.getExtent(); + const powExtent = powStub.getExtent(); + const powOpt: ValueTransformLookupOpt = {lookup: {from: intervalExtent, to: powExtent}}; - // Fix #4158 - if (val === extent[0] && this._fixMin) { - roundingCriterion = originalExtent[0]; - } - else if (val === extent[1] && this._fixMax) { - roundingCriterion = originalExtent[1]; - } + return map(intervalStub.getTicks(opt || {}), function (tick) { + const val = tick.value; + let powVal = logScalePowTick(val, base, powOpt); let vBreak; if (scaleBreakHelper) { - const transformed = scaleBreakHelper.getTicksLogTransformBreak( + const brkPowResult = scaleBreakHelper.getTicksBreakOutwardTransform( + this, tick, - base, - originalBreaks, - fixRoundingError + getBreaksUnsafe(powStub), + this._lookup, ); - vBreak = transformed.vBreak; - if (roundingCriterion == null) { - roundingCriterion = transformed.brkRoundingCriterion; + if (brkPowResult) { + vBreak = brkPowResult.vBreak; + powVal = brkPowResult.tickVal; } } - if (roundingCriterion != null) { - powVal = fixRoundingError(powVal, roundingCriterion); - } - return { value: powVal, break: vBreak, @@ -101,125 +143,129 @@ class LogScale extends IntervalScale { }, this); } - protected _getNonTransBreaks(): ParsedAxisBreakList { - return this._originalScale._innerGetBreaks(); - } - - setExtent(start: number, end: number): void { - this._originalScale.setExtent(start, end); - const loggedExtent = logTransform(this.base, [start, end]); - super.setExtent(loggedExtent[0], loggedExtent[1]); - } - - /** - * @return {number} end - */ - getExtent() { - const base = this.base; - const extent = super.getExtent(); - extent[0] = mathPow(base, extent[0]); - extent[1] = mathPow(base, extent[1]); - - // Fix #4158 - const originalExtent = this._originalScale.getExtent(); - this._fixMin && (extent[0] = fixRoundingError(extent[0], originalExtent[0])); - this._fixMax && (extent[1] = fixRoundingError(extent[1], originalExtent[1])); - - return extent; - } - - unionExtentFromData(data: SeriesData, dim: DimensionName | DimensionLoose): void { - this._originalScale.unionExtentFromData(data, dim); - const loggedOther = logTransform(this.base, data.getApproximateExtent(dim), true); - this._innerUnionExtent(loggedOther); - } - - /** - * Update interval and extent of intervals for nice ticks - * @param approxTickNum default 10 Given approx tick number - */ - calcNiceTicks(approxTickNum: number): void { - approxTickNum = approxTickNum || 10; - const extent = this._extent.slice() as [number, number]; - const span = this._getExtentSpanWithBreaks(); - if (!isFinite(span) || span <= 0) { - return; - } - - let interval = numberUtil.quantity(span); - const err = approxTickNum / span * interval; - - // Filter ticks to get closer to the desired count. - if (err <= 0.5) { - interval *= 10; - } - - // Interval should be integer - while (!isNaN(interval) && Math.abs(interval) < 1 && Math.abs(interval) > 0) { - interval *= 10; - } - - const niceExtent = [ - fixRound(mathCeil(extent[0] / interval) * interval), - fixRound(mathFloor(extent[1] / interval) * interval) - ] as [number, number]; - - this._interval = interval; - this._intervalPrecision = getIntervalPrecision(interval); - this._niceExtent = niceExtent; + getMinorTicks(splitNumber: number): number[][] { + return getMinorTicks( + this, + splitNumber, + getBreaksUnsafe(this.powStub), + // NOTE: minor ticks are in the log scale value to visually hint users "logarithm". + this.intervalStub.getConfig().interval + ); } - calcNiceExtent(opt: { - splitNumber: number, - fixMin?: boolean, - fixMax?: boolean, - minInterval?: number, - maxInterval?: number - }): void { - super.calcNiceExtent(opt); - - this._fixMin = opt.fixMin; - this._fixMax = opt.fixMax; + getLabel( + data: ScaleTick, + opt?: IntervalScaleGetLabelOpt + ) { + return this.intervalStub.getLabel(data, opt); } - contain(val: number): boolean { - val = mathLog(val) / mathLog(this.base); - return super.contain(val); - } + static mapperMethods: DecoratedScaleMapperMethods = { + + needTransform() { + return true; + }, + + normalize(val) { + return this.intervalStub.normalize(logScaleLogTick(val, this.base)); + }, + + scale(val) { + // PENDING: Input `intervalStub.getExtent()` and `powStub.getExtent()` may + // break monotonicity. Do not do it until real problems found. + return logScalePowTick(this.intervalStub.scale(val), this.base, null); + }, + + transformIn(val, opt) { + val = logScaleLogTick(val, this.base); + return (opt && opt.depth === SCALE_MAPPER_DEPTH_OUT_OF_BREAK) + ? val + : this.intervalStub.transformIn(val, opt); + }, + + transformOut(val, opt) { + const depth = opt ? opt.depth : null; + tmpTransformOutOpt1.depth = depth; + tmpTransformOutOpt2.lookup = this._lookup; + return logScalePowTick( + (depth === SCALE_MAPPER_DEPTH_OUT_OF_BREAK) + ? val + : this.intervalStub.transformOut(val, tmpTransformOutOpt1), + this.base, + tmpTransformOutOpt2 + ); + }, + + contain(val) { + return this.powStub.contain(val); + }, + + /** + * NOTICE: The caller should ensure `start` and `end` are both non-negative. + */ + setExtent(start, end) { + this.setExtent2(SCALE_EXTENT_KIND_EFFECTIVE, start, end); + }, + + setExtent2(kind, start, end) { + if (!isValidBoundsForExtent(start, end) + || start <= 0 || end <= 0 + ) { + return; + } + let lookupTo = tmpNotUsedArr; + let lookupFrom = tmpNotUsedArr; + if (kind === SCALE_EXTENT_KIND_EFFECTIVE) { + const lookup = this._lookup; + lookupTo = lookup.to; + lookupFrom = lookup.from; + } + this.powStub.setExtent2( + kind, + (lookupTo[LOOKUP_IDX_EXTENT_START] = start), + (lookupTo[LOOKUP_IDX_EXTENT_END] = end) + ); + const base = this.base; + this.intervalStub.setExtent2( + kind, + (lookupFrom[LOOKUP_IDX_EXTENT_START] = logScaleLogTick(start, base)), + (lookupFrom[LOOKUP_IDX_EXTENT_END] = logScaleLogTick(end, base)) + ); + }, + + getFilter() { + return {g: 0}; + }, + + sanitize(value, dataExtent) { + // Conservative - if dataExtent is invalid, do not sanitize. + if (isValidBoundsForExtent(dataExtent[0], dataExtent[1]) + && isNullableNumberFinite(value) + ) { + // `DataStore` has ensured that `dataExtent` is valid for LogScale. + value <= 0 && (value = dataExtent[0]); + } + return value; + }, - normalize(val: number): number { - val = mathLog(val) / mathLog(this.base); - return super.normalize(val); - } + getExtent() { + return this.powStub.getExtent(); + }, - scale(val: number): number { - val = super.scale(val); - return mathPow(this.base, val); - } + getExtentUnsafe(kind, depth) { + return depth === null + ? this.powStub.getExtentUnsafe(kind, null) + : this.intervalStub.getExtentUnsafe(kind, depth); + }, - setBreaksFromOption( - breakOptionList: AxisBreakOption[], - ): void { - const scaleBreakHelper = getScaleBreakHelper(); - if (!scaleBreakHelper) { - return; - } - const { parsedOriginal, parsedLogged } = scaleBreakHelper.logarithmicParseBreaksFromOption( - breakOptionList, - this.base, - zrUtil.bind(this.parse, this) - ); - this._originalScale._innerSetBreak(parsedOriginal); - this._innerSetBreak(parsedLogged); - } + }; } -function fixRoundingError(val: number, originalVal: number): number { - return fixRound(val, numberUtil.getPrecision(originalVal)); -} - - Scale.registerClass(LogScale); +const tmpTransformOutOpt1: ScaleMapperTransformOutOpt = {}; +const tmpTransformOutOpt2: ValueTransformLookupOpt = {}; +const tmpNotUsedArr: number[] = []; + export default LogScale; diff --git a/src/scale/Ordinal.ts b/src/scale/Ordinal.ts index cf41374ffc..d4e2fc55b5 100644 --- a/src/scale/Ordinal.ts +++ b/src/scale/Ordinal.ts @@ -22,30 +22,40 @@ * http://en.wikipedia.org/wiki/Level_of_measurement */ -// FIXME only one data - import Scale from './Scale'; import OrdinalMeta from '../data/OrdinalMeta'; -import * as scaleHelper from './helper'; import { OrdinalRawValue, OrdinalNumber, OrdinalSortInfo, OrdinalScaleTick, - ScaleTick + ScaleTick, } from '../util/types'; import { CategoryAxisBaseOption } from '../coord/axisCommonTypes'; import { isArray, map, isObject, isString } from 'zrender/src/core/util'; +import { mathMin, mathRound } from '../util/number'; +import { + DecoratedScaleMapperMethods, + decorateScaleMapper, enableScaleMapperFreeze, getScaleExtentForTickUnsafe, initBreakOrLinearMapper, + ScaleMapper, ScaleMapperGeneric +} from './scaleMapper'; + type OrdinalScaleSetting = { ordinalMeta?: OrdinalMeta | CategoryAxisBaseOption['data']; - extent?: [number, number]; + extent?: number[]; }; -class OrdinalScale extends Scale { +/** + * @final NEVER inherit me! + */ +interface OrdinalScale extends ScaleMapperGeneric { + _mapper: ScaleMapper; +} +class OrdinalScale extends Scale { static type = 'ordinal'; - readonly type = 'ordinal'; + readonly type = 'ordinal' as const; private _ordinalMeta: OrdinalMeta; @@ -91,7 +101,13 @@ class OrdinalScale extends Scale { * 1, // ordinalNumber: 5, yValue: 220 * ] * ``` - * The index of this array is from `0` to `ordinalMeta.categories.length`. + * NOTICE: + * - The index of `_ordinalNumbersByTick` is "tick number", i.e., `tick.value`, + * rather than the index of `scale.getTicks()`, though commonly they are the same, + * except that the `_extent[0]` is delibrately set to be not zero. + * - Currently we only support that the index of `_ordinalNumbersByTick` is + * from `0` to `ordinalMeta.categories.length - 1`. + * - `OrdinalNumber` is always from `0` to `ordinalMeta.categories.length - 1`. * * @see `Ordinal['getRawOrdinalNumber']` * @see `OrdinalSortInfo` @@ -100,7 +116,8 @@ class OrdinalScale extends Scale { /** * This is the inverted map of `_ordinalNumbersByTick`. - * The index of this array is from `0` to `ordinalMeta.categories.length`. + * The index is `OrdinalNumber`, which is from `0` to `ordinalMeta.categories.length - 1`. + * after `_ticksByOrdinalNumber` is initialized. * * @see `Ordinal['_ordinalNumbersByTick']` * @see `Ordinal['_getTickNumber']` @@ -109,10 +126,14 @@ class OrdinalScale extends Scale { private _ticksByOrdinalNumber: number[]; - constructor(setting?: OrdinalScaleSetting) { - super(setting); + constructor(setting: OrdinalScaleSetting) { + super(); + + this.parse = OrdinalScale.parse; - let ordinalMeta = this.getSetting('ordinalMeta'); + decorateScaleMapper(this, OrdinalScale.decoratedMethods); + + let ordinalMeta = setting.ordinalMeta; // Caution: Should not use instanceof, consider ec-extensions using // import approach to get OrdinalMeta class. if (!ordinalMeta) { @@ -124,47 +145,86 @@ class OrdinalScale extends Scale { }); } this._ordinalMeta = ordinalMeta as OrdinalMeta; - this._extent = this.getSetting('extent') || [0, ordinalMeta.categories.length - 1]; + + // Create an interval LinearScaleMapper, and decorate it. + const res = initBreakOrLinearMapper( + null, + null, // Do not support break in OrdinalScale yet. + setting.extent || [0, ordinalMeta.categories.length - 1] + ); + this._mapper = res.mapper; + + enableScaleMapperFreeze(this, res.mapper); } - parse(val: OrdinalRawValue | OrdinalNumber): OrdinalNumber { + private static parse(this: OrdinalScale, val: OrdinalRawValue | OrdinalNumber): OrdinalNumber { // Caution: Math.round(null) will return `0` rather than `NaN` if (val == null) { - return NaN; + val = NaN; + } + else if (isString(val)) { + val = this._ordinalMeta.getOrdinal(val); + if (val == null) { + val = NaN; + } + } + else { + // The val from user input might be float. + val = mathRound(val); } - return isString(val) - ? this._ordinalMeta.getOrdinal(val) - // val might be float. - : Math.round(val); + return val; } - contain(val: OrdinalNumber): boolean { - return scaleHelper.contain(val, this._extent) - && val >= 0 && val < this._ordinalMeta.categories.length; - } + static decoratedMethods: DecoratedScaleMapperMethods = { - /** - * Normalize given rank or name to linear [0, 1] - * @param val raw ordinal number. - * @return normalized value in [0, 1]. - */ - normalize(val: OrdinalNumber): number { - val = this._getTickNumber(val); - return this._calculator.normalize(val, this._extent); - } + needTransform() { + return this._mapper.needTransform(); + }, - /** - * @param val normalized value in [0, 1]. - * @return raw ordinal number. - */ - scale(val: number): OrdinalNumber { - val = Math.round(this._calculator.scale(val, this._extent)); - return this.getRawOrdinalNumber(val); - } + contain(this: OrdinalScale, val: OrdinalNumber): boolean { + return this._mapper.contain(this._getTickNumber(val)) + && val >= 0 && val < this._ordinalMeta.categories.length; + }, + + normalize(this: OrdinalScale, val: OrdinalNumber): number { + val = this._getTickNumber(val); + return this._mapper.normalize(val); + }, + + scale(this: OrdinalScale, val: number): OrdinalNumber { + val = mathRound(this._mapper.scale(val)); + return this.getRawOrdinalNumber(val); + }, + + transformIn(val, opt) { + return this._mapper.transformIn(val, opt); + }, + + transformOut(val, opt) { + return this._mapper.transformOut(val, opt); + }, + + getExtent() { + return this._mapper.getExtent(); + }, + + getExtentUnsafe(kind, depth) { + return this._mapper.getExtentUnsafe(kind, depth); + }, + + setExtent(start, end) { + return this._mapper.setExtent(start, end); + }, + + setExtent2(kind, start, end) { + return this._mapper.setExtent2(kind, start, end); + }, + + }; getTicks(): OrdinalScaleTick[] { const ticks = []; - const extent = this._extent; + const extent = getScaleExtentForTickUnsafe(this._mapper); let rank = extent[0]; while (rank <= extent[1]) { @@ -198,9 +258,8 @@ class OrdinalScale extends Scale { // Unnecessary support negative tick in `realtimeSort`. let tickNum = 0; const allCategoryLen = this._ordinalMeta.categories.length; - for (const len = Math.min(allCategoryLen, infoOrdinalNumbers.length); tickNum < len; ++tickNum) { - const ordinalNumber = infoOrdinalNumbers[tickNum]; - ordinalsByTick[tickNum] = ordinalNumber; + for (const len = mathMin(allCategoryLen, infoOrdinalNumbers.length); tickNum < len; ++tickNum) { + const ordinalNumber = ordinalsByTick[tickNum] = infoOrdinalNumbers[tickNum]; ticksByOrdinal[ordinalNumber] = tickNum; } // Handle that `series.data` only covers part of the `axis.category.data`. @@ -209,7 +268,7 @@ class OrdinalScale extends Scale { while (ticksByOrdinal[unusedOrdinal] != null) { unusedOrdinal++; }; - ordinalsByTick.push(unusedOrdinal); + ordinalsByTick[tickNum] = unusedOrdinal; ticksByOrdinal[unusedOrdinal] = tickNum; } } @@ -226,8 +285,7 @@ class OrdinalScale extends Scale { /** * @usage * ```js - * const ordinalNumber = ordinalScale.getRawOrdinalNumber(tickVal); - * + * const ordinalNumber = ordinalScale.getRawOrdinalNumber(tick.value); * // case0 * const rawOrdinalValue = axisModel.getCategories()[ordinalNumber]; * // case1 @@ -236,11 +294,11 @@ class OrdinalScale extends Scale { * const coord = axis.dataToCoord(ordinalNumber); * ``` * - * @param {OrdinalNumber} tickNumber index of display + * @param tickNumber This is `scale.getTicks()[i].value`. */ getRawOrdinalNumber(tickNumber: number): OrdinalNumber { const ordinalNumbersByTick = this._ordinalNumbersByTick; - // tickNumber may be out of range, e.g., when axis max is larger than `ordinalMeta.categories.length`., + // tickNumber may be out of range, e.g., when axis max is larger than `ordinalMeta.categories.length`, // where ordinal numbers are used as tick value directly. return (ordinalNumbersByTick && tickNumber >= 0 && tickNumber < ordinalNumbersByTick.length) ? ordinalNumbersByTick[tickNumber] @@ -253,34 +311,22 @@ class OrdinalScale extends Scale { getLabel(tick: ScaleTick): string { if (!this.isBlank()) { const ordinalNumber = this.getRawOrdinalNumber(tick.value); - const cateogry = this._ordinalMeta.categories[ordinalNumber]; + const category = this._ordinalMeta.categories[ordinalNumber]; // Note that if no data, ordinalMeta.categories is an empty array. // Return empty if it's not exist. - return cateogry == null ? '' : cateogry + ''; + return category == null ? '' : category + ''; } } count(): number { - return this._extent[1] - this._extent[0] + 1; - } - - /** - * @override - * If value is in extent range - */ - isInExtentRange(value: OrdinalNumber): boolean { - value = this._getTickNumber(value); - return this._extent[0] <= value && this._extent[1] >= value; + const extent = getScaleExtentForTickUnsafe(this._mapper); + return extent[1] - extent[0] + 1; } getOrdinalMeta(): OrdinalMeta { return this._ordinalMeta; } - calcNiceTicks() {} - - calcNiceExtent() {} - } Scale.registerClass(OrdinalScale); diff --git a/src/scale/Scale.ts b/src/scale/Scale.ts index 1f1fbdecf2..7fcf303237 100644 --- a/src/scale/Scale.ts +++ b/src/scale/Scale.ts @@ -19,26 +19,19 @@ import * as clazzUtil from '../util/clazz'; -import { Dictionary } from 'zrender/src/core/types'; -import SeriesData from '../data/SeriesData'; import { - DimensionName, ScaleDataValue, - DimensionLoose, ScaleTick, - AxisBreakOption, NullUndefined, - ParsedAxisBreakList, } from '../util/types'; -import { - ScaleCalculator -} from './helper'; import { ScaleRawExtentInfo } from '../coord/scaleRawExtentInfo'; -import { bind } from 'zrender/src/core/util'; -import { ScaleBreakContext, AxisBreakParsingResult, getScaleBreakHelper, ParamPruneByBreak } from './break'; +import { BreakScaleMapper, ParamPruneByBreak } from './break'; +import { AxisScaleType } from '../coord/axisCommonTypes'; +import { ScaleMapperGeneric } from './scaleMapper'; + export type ScaleGetTicksOpt = { - // Whether expand the ticks to niced extent. + // Whether expand the ticks to nice extent. expandToNicedExtent?: boolean; pruneByBreak?: ParamPruneByBreak; // - not specified or undefined(default): insert the breaks as items into the tick array. @@ -50,170 +43,47 @@ export type ScaleGetTicksOpt = { breakTicks?: 'only_break' | 'none' | NullUndefined; }; -export type ScaleSettingDefault = Dictionary; - -abstract class Scale { - - type: string; - - private _setting: SETTING; +/** + * @see ScaleMapper for the hierarchy structure. + */ +interface Scale extends ScaleMapperGeneric {} +abstract class Scale< + // eslint-disable-next-line @typescript-eslint/no-unused-vars + This = unknown // Just required for keeping identical to `interface Scale`. +> { - // [CAVEAT]: Should update only by `_innerSetExtent`! - // Make sure that extent[0] always <= extent[1]. - protected _extent: [number, number]; + type: AxisScaleType; - // FIXME: Effectively, both logorithmic scale and break scale are numeric axis transformation - // mechanisms. However, for historical reason, logorithmic scale is implemented as a subclass, - // while break scale is implemented inside the base class `Scale`. If more transformations - // need to be introduced in futher, we should probably refactor them for better orthogonal - // composition. (e.g. use decorator-like patterns rather than the current class inheritance?) - protected _brkCtx: ScaleBreakContext | NullUndefined; - - protected _calculator: ScaleCalculator = new ScaleCalculator(); + /** + * CAUTION: Do not visit it directly - use helper methods in `scale/break.ts` instead. + */ + readonly brk: BreakScaleMapper | NullUndefined; private _isBlank: boolean; // Inject - readonly rawExtentInfo: ScaleRawExtentInfo; - - constructor(setting?: SETTING) { - this._setting = setting || {} as SETTING; - this._extent = [Infinity, -Infinity]; - const scaleBreakHelper = getScaleBreakHelper(); - if (scaleBreakHelper) { - this._brkCtx = scaleBreakHelper.createScaleBreakContext(); - this._brkCtx!.update(this._extent); - } - } - - getSetting(name: KEY): SETTING[KEY] { - return this._setting[name]; - } + readonly rawExtentInfo: ScaleRawExtentInfo | NullUndefined; /** + * NOTICE: + * - Must be available in constructor. + * - Must ensure the return is a number. + * null/undefined is not allowed. + * `NaN` represents invalid data. + * * Parse input val to valid inner number. * Notice: This would be a trap here, If the implementation * of this method depends on extent, and this method is used * before extent set (like in dataZoom), it would be wrong. * Nevertheless, parse does not depend on extent generally. */ - abstract parse(val: ScaleDataValue): number; - - /** - * Whether contain the given value. - */ - abstract contain(val: number): boolean; - - /** - * Normalize value to linear [0, 1], return 0.5 if extent span is 0. - */ - abstract normalize(val: number): number; - - /** - * Scale normalized value to extent. - */ - abstract scale(val: number): number; - - /** - * [CAVEAT]: It should not be overridden! - */ - _innerUnionExtent(other: [number, number]): void { - const extent = this._extent; - // Considered that number could be NaN and should not write into the extent. - this._innerSetExtent( - other[0] < extent[0] ? other[0] : extent[0], - other[1] > extent[1] ? other[1] : extent[1] - ); - } - - /** - * Set extent from data - */ - unionExtentFromData(data: SeriesData, dim: DimensionName | DimensionLoose): void { - this._innerUnionExtent(data.getApproximateExtent(dim)); - } - - /** - * Get a new slice of extent. - * Extent is always in increase order. - */ - getExtent(): [number, number] { - return this._extent.slice() as [number, number]; - } - - setExtent(start: number, end: number): void { - this._innerSetExtent(start, end); - } - - /** - * [CAVEAT]: It should not be overridden! - */ - protected _innerSetExtent(start: number, end: number): void { - const thisExtent = this._extent; - if (!isNaN(start)) { - thisExtent[0] = start; - } - if (!isNaN(end)) { - thisExtent[1] = end; - } - this._brkCtx && this._brkCtx.update(thisExtent); - } - - /** - * Prerequisite: Scale#parse is ready. - */ - setBreaksFromOption( - breakOptionList: AxisBreakOption[], - ): void { - const scaleBreakHelper = getScaleBreakHelper(); - if (scaleBreakHelper) { - this._innerSetBreak( - scaleBreakHelper.parseAxisBreakOption(breakOptionList, bind(this.parse, this)) - ); - } - } - - /** - * [CAVEAT]: It should not be overridden! - */ - _innerSetBreak(parsed: AxisBreakParsingResult) { - if (this._brkCtx) { - this._brkCtx.setBreaks(parsed); - this._calculator.updateMethods(this._brkCtx); - this._brkCtx.update(this._extent); - } - } - - /** - * [CAVEAT]: It should not be overridden! - */ - _innerGetBreaks(): ParsedAxisBreakList { - return this._brkCtx ? this._brkCtx.breaks : []; - } - - /** - * Do not expose the internal `_breaks` unless necessary. - */ - hasBreaks(): boolean { - return this._brkCtx ? this._brkCtx.hasBreaks() : false; - } - - protected _getExtentSpanWithBreaks() { - return (this._brkCtx && this._brkCtx.hasBreaks()) - ? this._brkCtx.getExtentSpan() - : this._extent[1] - this._extent[0]; - } - - /** - * If value is in extent range - */ - isInExtentRange(value: number): boolean { - return this._extent[0] <= value && this._extent[1] >= value; - } + parse: (val: ScaleDataValue) => number; /** * When axis extent depends on data and no data exists, * axis ticks should not be drawn, which is named 'blank'. + * + * @final NEVER override! */ isBlank(): boolean { return this._isBlank; @@ -222,37 +92,13 @@ abstract class Scale /** * When axis extent depends on data and no data exists, * axis ticks should not be drawn, which is named 'blank'. + * + * @final NEVER override! */ setBlank(isBlank: boolean) { this._isBlank = isBlank; } - /** - * Update interval and extent of intervals for nice ticks - * - * @param splitNumber Approximated tick numbers. Optional. - * The implementation of `niceTicks` should decide tick numbers - * whether `splitNumber` is given. - * @param minInterval Optional. - * @param maxInterval Optional. - */ - abstract calcNiceTicks( - // FIXME:TS make them in a "opt", the same with `niceExtent`? - splitNumber?: number, - minInterval?: number, - maxInterval?: number - ): void; - - abstract calcNiceExtent( - opt?: { - splitNumber?: number, - fixMin?: boolean, - fixMax?: boolean, - minInterval?: number, - maxInterval?: number - } - ): void; - /** * @return label of the tick. */ @@ -270,4 +116,5 @@ abstract class Scale type ScaleConstructor = typeof Scale & clazzUtil.ClassManager; clazzUtil.enableClassManagement(Scale as ScaleConstructor); -export default Scale; \ No newline at end of file + +export default Scale; diff --git a/src/scale/Time.ts b/src/scale/Time.ts index a3a7de479c..d7f1992181 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -74,8 +74,7 @@ import { primaryTimeUnits, roundTime } from '../util/time'; -import * as scaleHelper from './helper'; -import IntervalScale from './Interval'; +import { ensureValidSplitNumber } from './helper'; import Scale, { ScaleGetTicksOpt } from './Scale'; import {TimeScaleTick, ScaleTick, AxisBreakOption, NullUndefined} from '../util/types'; import {TimeAxisLabelFormatterParsed} from '../coord/axisCommonTypes'; @@ -83,7 +82,15 @@ import { warn } from '../util/log'; import { LocaleOption } from '../core/locale'; import Model from '../model/Model'; import { each, filter, indexOf, isNumber, map } from 'zrender/src/core/util'; -import { ScaleBreakContext, getScaleBreakHelper } from './break'; +import { BreakScaleMapper, getBreaksUnsafe, getScaleBreakHelper, simplyParseBreakOption } from './break'; +import type { ScaleCalcNiceMethod } from '../coord/axisNiceTicks'; +import { getMinorTicks } from './minorTicks'; +import { + getScaleLinearSpanEffective, + getScaleExtentForTickUnsafe, + initBreakOrLinearMapper, ScaleMapperGeneric +} from './scaleMapper'; +import { removeDuplicates, removeDuplicatesGetKeyFromValueProp } from '../util/model'; // FIXME 公用? const bisect = function ( @@ -107,34 +114,51 @@ const bisect = function ( type TimeScaleSetting = { locale: Model; useUTC: boolean; - modelAxisBreaks?: AxisBreakOption[]; + breakOption: AxisBreakOption[] | NullUndefined; }; -class TimeScale extends IntervalScale { +/** + * @final NEVER inherit me! + */ +interface TimeScale extends ScaleMapperGeneric {} +class TimeScale extends Scale { static type = 'time'; - readonly type = 'time'; + readonly type = 'time' as const; + private _locale: Model; + private _useUTC: boolean; private _approxInterval: number; + private _interval: number; private _minLevelUnit: TimeUnit; - constructor(settings?: TimeScaleSetting) { - super(settings); + constructor(setting: TimeScaleSetting) { + super(); + this.parse = TimeScale.parse; + + this._locale = setting.locale; + this._useUTC = setting.useUTC; + this._interval = 0; + + const breakParsed = simplyParseBreakOption(this, setting); + + const res = initBreakOrLinearMapper(this, breakParsed, null); + // @ts-ignore + this.brk = res.brk; } /** * Get label is mainly for other components like dataZoom, tooltip. */ - getLabel(tick: TimeScaleTick): string { - const useUTC = this.getSetting('useUTC'); + getLabel(tick: ScaleTick): string { return format( tick.value, fullLeveledFormatter[ getDefaultFormatPrecisionOfInterval(getPrimaryTimeUnit(this._minLevelUnit)) ] || fullLeveledFormatter.second, - useUTC, - this.getSetting('locale') + this._useUTC, + this._locale ); } @@ -143,20 +167,17 @@ class TimeScale extends IntervalScale { idx: number, labelFormatter: TimeAxisLabelFormatterParsed ): string { - const isUTC = this.getSetting('useUTC'); - const lang = this.getSetting('locale'); - return leveledFormat(tick, idx, labelFormatter, lang, isUTC); + return leveledFormat(tick, idx, labelFormatter, this._locale, this._useUTC); } - /** - * @override - */ getTicks(opt?: ScaleGetTicksOpt): TimeScaleTick[] { opt = opt || {}; const interval = this._interval; - const extent = this._extent; + const extent = getScaleExtentForTickUnsafe(this); const scaleBreakHelper = getScaleBreakHelper(); + const brk = this.brk; + const brkAvailable = scaleBreakHelper && brk; let ticks = [] as TimeScaleTick[]; // If interval is 0, return []; @@ -164,45 +185,22 @@ class TimeScale extends IntervalScale { return ticks; } - const useUTC = this.getSetting('useUTC'); + const useUTC = this._useUTC; - if (scaleBreakHelper && opt.breakTicks === 'only_break') { - getScaleBreakHelper().addBreaksToTicks(ticks, this._brkCtx!.breaks, this._extent); + if (brkAvailable && opt.breakTicks === 'only_break') { + getScaleBreakHelper().addBreaksToTicks(ticks, brk.breaks, extent); return ticks; } - const extent0Unit = getUnitFromValue(extent[1], useUTC); - ticks.push({ - value: extent[0], - time: { - level: 0, - upperTimeUnit: extent0Unit, - lowerTimeUnit: extent0Unit, - } - }); - - const innerTicks = getIntervalTicks( + ticks = createIntervalTicks( this._minLevelUnit, this._approxInterval, useUTC, extent, - this._getExtentSpanWithBreaks(), - this._brkCtx + getScaleLinearSpanEffective(this), + brk ); - ticks = ticks.concat(innerTicks); - - const extent1Unit = getUnitFromValue(extent[1], useUTC); - ticks.push({ - value: extent[1], - time: { - level: 0, - upperTimeUnit: extent1Unit, - lowerTimeUnit: extent1Unit, - } - }); - - const isUTC = this.getSetting('useUTC'); let upperUnitIndex = primaryTimeUnits.length - 1; let maxLevel = 0; each(ticks, tick => { @@ -212,27 +210,27 @@ class TimeScale extends IntervalScale { } }); - if (scaleBreakHelper) { + if (brkAvailable) { getScaleBreakHelper().pruneTicksByBreak( opt.pruneByBreak, ticks, - this._brkCtx!.breaks, + brk.breaks, item => item.value, this._approxInterval, - this._extent + extent ); } - if (scaleBreakHelper && opt.breakTicks !== 'none') { - getScaleBreakHelper().addBreaksToTicks(ticks, this._brkCtx!.breaks, this._extent, trimmedBrk => { + if (brkAvailable && opt.breakTicks !== 'none') { + getScaleBreakHelper().addBreaksToTicks(ticks, brk.breaks, extent, trimmedBrk => { // @see `parseTimeAxisLabelFormatterDictionary`. const lowerBrkUnitIndex = Math.max( - indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmin, isUTC)), - indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmax, isUTC)), + indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmin, useUTC)), + indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmax, useUTC)), ); let upperBrkUnitIndex = 0; for (let unitIdx = 0; unitIdx < primaryTimeUnits.length; unitIdx++) { if (!isPrimaryUnitValueAndGreaterSame( - primaryTimeUnits[unitIdx], trimmedBrk.vmin, trimmedBrk.vmax, isUTC + primaryTimeUnits[unitIdx], trimmedBrk.vmin, trimmedBrk.vmax, useUTC )) { upperBrkUnitIndex = unitIdx; break; @@ -251,75 +249,28 @@ class TimeScale extends IntervalScale { return ticks; } - calcNiceExtent( - opt?: { - splitNumber?: number, - fixMin?: boolean, - fixMax?: boolean, - minInterval?: number, - maxInterval?: number - } - ): void { - const extent = this.getExtent(); - // If extent start and end are same, expand them - if (extent[0] === extent[1]) { - // Expand extent - extent[0] -= ONE_DAY; - extent[1] += ONE_DAY; - } - // If there are no data and extent are [Infinity, -Infinity] - if (extent[1] === -Infinity && extent[0] === Infinity) { - const d = new Date(); - extent[1] = +new Date(d.getFullYear(), d.getMonth(), d.getDate()); - extent[0] = extent[1] - ONE_DAY; - } - this._innerSetExtent(extent[0], extent[1]); - - this.calcNiceTicks(opt.splitNumber, opt.minInterval, opt.maxInterval); - } - - calcNiceTicks(approxTickNum: number, minInterval: number, maxInterval: number): void { - approxTickNum = approxTickNum || 10; - - const span = this._getExtentSpanWithBreaks(); - this._approxInterval = span / approxTickNum; - - if (minInterval != null && this._approxInterval < minInterval) { - this._approxInterval = minInterval; - } - if (maxInterval != null && this._approxInterval > maxInterval) { - this._approxInterval = maxInterval; - } - - const scaleIntervalsLen = scaleIntervals.length; - const idx = Math.min( - bisect(scaleIntervals, this._approxInterval, 0, scaleIntervalsLen), - scaleIntervalsLen - 1 + getMinorTicks(splitNumber: number): number[][] { + return getMinorTicks( + this, + splitNumber, + getBreaksUnsafe(this), + this._interval ); - - // Interval that can be used to calculate ticks - this._interval = scaleIntervals[idx][1]; - this._intervalPrecision = scaleHelper.getIntervalPrecision(this._interval); - // Min level used when picking ticks from top down. - // We check one more level to avoid the ticks are to sparse in some case. - this._minLevelUnit = scaleIntervals[Math.max(idx - 1, 0)][0]; - } - - parse(val: number | string | Date): number { - // val might be float. - return isNumber(val) ? val : +numberUtil.parseDate(val); - } - - contain(val: number): boolean { - return scaleHelper.contain(val, this._extent); } - normalize(val: number): number { - return this._calculator.normalize(val, this._extent); + setTimeInterval(opt: { + interval: number; + approxInterval: number; + minLevelUnit: TimeUnit; + }): void { + this._interval = opt.interval; + this._approxInterval = opt.approxInterval; + this._minLevelUnit = opt.minLevelUnit; } - scale(val: number): number { - return this._calculator.scale(val, this._extent); + static parse(val: number | string | Date): number { + // `val` might be a float (e.g., calculated from percent), so call `round`. + return isNumber(val) ? Math.round(val) : +numberUtil.parseDate(val); } } @@ -517,13 +468,13 @@ function createEstimateNiceMultiple( }; } -function getIntervalTicks( +function createIntervalTicks( bottomUnitName: TimeUnit, approxInterval: number, isUTC: boolean, extent: number[], - extentSpanWithBreaks: number, - brkCtx: ScaleBreakContext | NullUndefined, + innermostSpan: number, + brk: BreakScaleMapper | NullUndefined, ): TimeScaleTick[] { const safeLimit = 10000; const unitNames = timeUnits; @@ -569,8 +520,8 @@ function getIntervalTicks( date[setMethodName](date[getMethodName]() + interval); dateTime = date.getTime(); - if (brkCtx) { - const moreMultiple = brkCtx.calcNiceTickMultiple(dateTime, estimateNiceMultiple); + if (brk) { + const moreMultiple = brk.calcNiceTickMultiple(dateTime, estimateNiceMultiple); if (moreMultiple > 0) { date[setMethodName](date[getMethodName]() + moreMultiple * interval); dateTime = date.getTime(); @@ -578,7 +529,7 @@ function getIntervalTicks( } } - // This extra tick is for calcuating ticks of next level. Will not been added to the final result + // This extra tick is for calculating ticks of next level. Will not been added to the final result out.push({ value: dateTime, notAdd: true @@ -713,7 +664,7 @@ function getIntervalTicks( } } - const targetTickNum = extentSpanWithBreaks / approxInterval; + const targetTickNum = innermostSpan / approxInterval; // Added too much in this level and not too less in last level if (tickCount > targetTickNum * 1.5 && lastLevelTickCount > targetTickNum / 1.5) { break; @@ -737,8 +688,9 @@ function getIntervalTicks( return filter(levelTicks, tick => tick.value >= extent[0] && tick.value <= extent[1] && !tick.notAdd); }), levelTicks => levelTicks.length > 0); - const ticks: TimeScaleTick[] = []; const maxLevel = levelsTicksInExtent.length - 1; + const ticks: TimeScaleTick[] = []; + for (let i = 0; i < levelsTicksInExtent.length; ++i) { const levelTicks = levelsTicksInExtent[i]; for (let k = 0; k < levelTicks.length; ++k) { @@ -754,18 +706,79 @@ function getIntervalTicks( } } + // Remove duplicates, which may cause jitter of `splitArea` and other bad cases. + removeDuplicates(ticks, removeDuplicatesGetKeyFromValueProp, null); + ticks.sort((a, b) => a.value - b.value); - // Remove duplicates - const result: TimeScaleTick[] = []; - for (let i = 0; i < ticks.length; ++i) { - if (i === 0 || ticks[i].value !== ticks[i - 1].value) { - result.push(ticks[i]); - } + + const currMinTick = ticks[0]; + const currMaxTick = ticks[ticks.length - 1]; + const extent0Unit = getUnitFromValue(extent[0], isUTC); + const extent1Unit = getUnitFromValue(extent[1], isUTC); + if (!currMinTick || currMinTick.value > extent[0]) { + ticks.unshift({ + value: extent[0], + time: {level: 0, upperTimeUnit: extent0Unit, lowerTimeUnit: extent0Unit}, + notNice: true, + }); + } + if (!currMaxTick || currMaxTick.value < extent[1]) { + ticks.push({ + value: extent[1], + time: {level: 0, upperTimeUnit: extent1Unit, lowerTimeUnit: extent1Unit}, + notNice: true, + }); } - return result; + return ticks; } +export const calcNiceForTimeScale: ScaleCalcNiceMethod = function (scale: TimeScale, opt) { + const extent = scale.getExtent(); + // If extent start and end are same, expand them + if (extent[0] === extent[1]) { + // Expand extent + extent[0] -= ONE_DAY; + extent[1] += ONE_DAY; + } + // If there are no data and extent are [Infinity, -Infinity] + if (extent[1] === -Infinity && extent[0] === Infinity) { + const d = new Date(); + extent[1] = +new Date(d.getFullYear(), d.getMonth(), d.getDate()); + extent[0] = extent[1] - ONE_DAY; + } + scale.setExtent(extent[0], extent[1]); + + const splitNumber = ensureValidSplitNumber(opt.splitNumber, 10); + let approxInterval = getScaleLinearSpanEffective(scale) / splitNumber; + + const minInterval = opt.minInterval; + const maxInterval = opt.maxInterval; + if (minInterval != null && approxInterval < minInterval) { + approxInterval = minInterval; + } + if (maxInterval != null && approxInterval > maxInterval) { + approxInterval = maxInterval; + } + + const scaleIntervalsLen = scaleIntervals.length; + const idx = Math.min( + bisect(scaleIntervals, approxInterval, 0, scaleIntervalsLen), + scaleIntervalsLen - 1 + ); + + // Interval that can be used to calculate ticks + const interval = scaleIntervals[idx][1]; + // Min level used when picking ticks from top down. + // We check one more level to avoid the ticks are to sparse in some case. + const minLevelUnit = scaleIntervals[Math.max(idx - 1, 0)][0]; + + scale.setTimeInterval({ + approxInterval, + interval, + minLevelUnit + }); +}; Scale.registerClass(TimeScale); diff --git a/src/scale/break.ts b/src/scale/break.ts index ccac046806..ccd7f0c708 100644 --- a/src/scale/break.ts +++ b/src/scale/break.ts @@ -22,10 +22,12 @@ import type { NullUndefined, ParsedAxisBreak, ParsedAxisBreakList, AxisBreakOption, AxisBreakOptionIdentifierInAxis, ScaleTick, VisualAxisBreak, } from '../util/types'; +import { ValueTransformLookupOpt } from './helper'; import type Scale from './Scale'; +import { ScaleMapper } from './scaleMapper'; /** - * @file The fasade of scale break. + * @file The facade of scale break. * Separate the impl to reduce code size. * * @caution @@ -33,13 +35,10 @@ import type Scale from './Scale'; * Must not implement anything in this file. */ -export interface ScaleBreakContext { - readonly breaks: ParsedAxisBreakList; - - setBreaks(parsed: AxisBreakParsingResult): void; +export interface BreakScaleMapper extends ScaleMapper { - update(scaleExtent: [number, number]): void; + readonly breaks: ParsedAxisBreakList; hasBreaks(): boolean; @@ -48,54 +47,58 @@ export interface ScaleBreakContext { estimateNiceMultiple: (tickVal: number, brkEnd: number) => number ): number; - getExtentSpan(): number; - - normalize(val: number): number; - - scale(val: number): number; - - elapse(val: number): number; - - unelapse(elapsedVal: number): number; - }; export type AxisBreakParsingResult = { breaks: ParsedAxisBreakList; }; +export type ParseBreakOptionOpt = { + noNegative?: boolean; +}; + +export type ParseAxisBreakOptionInwardTransformOut = { + lookup: ValueTransformLookupOpt['lookup']; + original?: AxisBreakParsingResult; + transformed?: AxisBreakParsingResult; +}; + /** * Whether to remove any normal ticks that are too close to axis breaks. * - 'auto': Default. Remove any normal ticks that are too close to axis breaks. * - 'no': Do nothing pruning. * - 'exclude_scale_bound': Prune but keep scale extent boundary. * For example: - * - For splitLine, if remove the tick on extent, split line on the bounary of cartesian - * will not be displayed, causing werid effect. + * - For splitLine, if remove the tick on extent, split line on the boundary of cartesian + * will not be displayed, causing weird effect. * - For labels, scale extent boundary should be pruned if in break, otherwise duplicated * labels will displayed. */ export type ParamPruneByBreak = 'auto' | 'no' | 'preserve_extent_bound' | NullUndefined; -export type ScaleBreakHelper = { - createScaleBreakContext(): ScaleBreakContext; +type BreakScaleHelper = { + createBreakScaleMapper( + breakParsed: AxisBreakParsingResult | NullUndefined, + initialExtent: number[] | NullUndefined + ): BreakScaleMapper; pruneTicksByBreak( pruneByBreak: ParamPruneByBreak, ticks: TItem[], breaks: ParsedAxisBreakList, getValue: (item: TItem) => number, interval: number, - scaleExtent: [number, number] + scaleExtent: number[] ): void; addBreaksToTicks( ticks: ScaleTick[], breaks: ParsedAxisBreakList, - scaleExtent: [number, number], + scaleExtent: number[], getTimeProps?: (clampedBrk: ParsedAxisBreak) => ScaleTick['time'], ): void; parseAxisBreakOption( + // raw user input breaks, retrieved from axis model. breakOptionList: AxisBreakOption[] | NullUndefined, - parse: Scale['parse'], + scale: {parse: Scale['parse']}, opt?: { noNegative: boolean; } @@ -114,37 +117,62 @@ export type ScaleBreakHelper = { ): ( TReturnIdx extends false ? TItem[][] : number[][] ); - getTicksLogTransformBreak( + getTicksBreakOutwardTransform( + scale: ScaleMapper, tick: ScaleTick, - logBase: number, - logOriginalBreaks: ParsedAxisBreakList, - fixRoundingError: (val: number, originalVal: number) => number + outermostBreaks: ParsedAxisBreakList, + lookup: ValueTransformLookupOpt['lookup'] ): { - brkRoundingCriterion: number | NullUndefined; + tickVal: number | NullUndefined; vBreak: VisualAxisBreak | NullUndefined; - }; - logarithmicParseBreaksFromOption( - breakOptionList: AxisBreakOption[], - logBase: number, - parse: Scale['parse'], - ): { - parsedOriginal: AxisBreakParsingResult; - parsedLogged: AxisBreakParsingResult; - }; + } | NullUndefined; + parseAxisBreakOptionInwardTransform( + breakOptionList: AxisBreakOption[] | NullUndefined, + scale: Scale, + parseOpt: ParseBreakOptionOpt, + lookupStartIdx: number, + out: ParseAxisBreakOptionInwardTransformOut + ): void; makeAxisLabelFormatterParamBreak( extraParam: AxisLabelFormatterExtraParams | NullUndefined, vBreak: VisualAxisBreak | NullUndefined ): AxisLabelFormatterExtraParams | NullUndefined; }; -let _impl: ScaleBreakHelper = null; +let _impl: BreakScaleHelper = null; -export function registerScaleBreakHelperImpl(impl: ScaleBreakHelper): void { +export function registerScaleBreakHelperImpl(impl: BreakScaleHelper): void { if (!_impl) { _impl = impl; } } -export function getScaleBreakHelper(): ScaleBreakHelper | NullUndefined { +export function getScaleBreakHelper(): BreakScaleHelper | NullUndefined { return _impl; } + +export function simplyParseBreakOption( + scale: {parse: Scale['parse']}, + opt: { + breakOption?: AxisBreakOption[] | NullUndefined; + breakParsed?: AxisBreakParsingResult | NullUndefined; + } +): AxisBreakParsingResult | NullUndefined { + const scaleBreakHelper = getScaleBreakHelper(); + const breakOption = opt.breakOption; + let breakParsed = opt.breakParsed; + if (!breakParsed && scaleBreakHelper) { + breakParsed = scaleBreakHelper.parseAxisBreakOption(breakOption, scale); + } + return breakParsed; +} + +export function getBreaksUnsafe(scale: Scale): ParsedAxisBreakList { + const brk = scale.brk; + return brk ? brk.breaks : []; +} + +export function hasBreaks(scale: Scale): boolean { + const brk = scale.brk; + return brk ? brk.hasBreaks() : false; +} diff --git a/src/scale/breakImpl.ts b/src/scale/breakImpl.ts index 67d2eaa657..394092c82f 100644 --- a/src/scale/breakImpl.ts +++ b/src/scale/breakImpl.ts @@ -24,38 +24,50 @@ import { } from '../util/types'; import { error } from '../util/log'; import type Scale from './Scale'; -import { ScaleBreakContext, AxisBreakParsingResult, registerScaleBreakHelperImpl, ParamPruneByBreak } from './break'; -import { round as fixRound } from '../util/number'; +import { + AxisBreakParsingResult, registerScaleBreakHelperImpl, ParamPruneByBreak, BreakScaleMapper, ParseBreakOptionOpt +} from './break'; +import { mathMax, mathMin, mathRound } from '../util/number'; import { AxisLabelFormatterExtraParams } from '../coord/axisCommonTypes'; +import { + DecoratedScaleMapperMethods, + decorateScaleMapper, enableScaleMapperFreeze, initLinearScaleMapper, + SCALE_EXTENT_KIND_EFFECTIVE, + SCALE_MAPPER_DEPTH_OUT_OF_BREAK, + ScaleMapper, + ScaleMapperTransformOutOpt +} from './scaleMapper'; +import { isValidBoundsForExtent } from '../util/model'; +import { ValueTransformLookupOpt } from './helper'; /** * @caution * Must not export anything except `installScaleBreakHelper` */ -class ScaleBreakContextImpl implements ScaleBreakContext { +interface BreakScaleMapperImpl extends BreakScaleMapper { + // Linear space, the elapsed result. + _linear: ScaleMapper; +} +class BreakScaleMapperImpl { - // [CAVEAT]: Should set only by `ScaleBreakContext#setBreaks`! - readonly breaks: ParsedAxisBreakList = []; + // [CAVEAT]: Should set only by the constructor! + readonly breaks: ParsedAxisBreakList; - // [CAVEAT]: Should update only by `ScaleBreakContext#update`! - // They are the values that scaleExtent[0] and scaleExtent[1] are mapped to a numeric axis - // that breaks are applied, primarily for optimization of `Scale#normalize`. - private _elapsedExtent: [number, number] = [Infinity, -Infinity]; + // Only used to save the original extent to avoid rounding error. + private _outOfBrk: ScaleMapper; - setBreaks(parsed: AxisBreakParsingResult): void { - // @ts-ignore - this.breaks = parsed.breaks; - } + constructor( + breakParsed: AxisBreakParsingResult | NullUndefined, + initialExtent: number[] | NullUndefined + ) { + decorateScaleMapper(this, BreakScaleMapperImpl.decoratedMethods); - /** - * [CAVEAT]: Must be called immediately each time scale extent and breaks are updated! - */ - update(scaleExtent: [number, number]): void { - updateAxisBreakGapReal(this, scaleExtent); - const elapsedExtent = this._elapsedExtent; - elapsedExtent[0] = this.elapse(scaleExtent[0]); - elapsedExtent[1] = this.elapse(scaleExtent[1]); + this._outOfBrk = initLinearScaleMapper(null, initialExtent); + const mapper = this._linear = initLinearScaleMapper(null, initialExtent); + enableScaleMapperFreeze(this, mapper); + + this.breaks = breakParsed && breakParsed.breaks || []; } hasBreaks(): boolean { @@ -83,7 +95,7 @@ class ScaleBreakContextImpl implements ScaleBreakContext { const multiple = estimateNiceMultiple(tickVal, brk.vmax); if (__DEV__) { // If not, it may cause dead loop or not nice tick. - assert(multiple >= 0 && Math.round(multiple) === multiple); + assert(multiple >= 0 && mathRound(multiple) === multiple); } return multiple; } @@ -91,130 +103,177 @@ class ScaleBreakContextImpl implements ScaleBreakContext { return 0; } - getExtentSpan(): number { - return this._elapsedExtent[1] - this._elapsedExtent[0]; - } + static decoratedMethods: DecoratedScaleMapperMethods = { - normalize(val: number): number { - const elapsedSpan = this._elapsedExtent[1] - this._elapsedExtent[0]; - // The same logic as `Scale#normalize`. - if (elapsedSpan === 0) { - return 0.5; - } - return (this.elapse(val) - this._elapsedExtent[0]) / elapsedSpan; - } + needTransform() { + return !this.breaks.length; + }, - scale(val: number): number { - return this.unelapse( - val * (this._elapsedExtent[1] - this._elapsedExtent[0]) + this._elapsedExtent[0] - ); - } + getExtent() { + return this._outOfBrk.getExtent(); + }, - /** - * Suppose: - * AXIS_BREAK_LAST_BREAK_END_BASE: 0 - * AXIS_BREAK_ELAPSED_BASE: 0 - * breaks: [ - * {start: -400, end: -300, gap: 27}, - * {start: -100, end: 100, gap: 10}, - * {start: 200, end: 400, gap: 300}, - * ] - * The mapping will be: - * | | - * 400 + -> + 237 - * | | | | (gap: 300) - * 200 + -> + -63 - * | | - * 100 + -> + -163 - * | | | | (gap: 10) - * -100 + -> + -173 - * | | - * -300 + -> + -373 - * | | | | (gap: 27) - * -400 + -> + -400 - * | | - * origianl elapsed - * - * Note: - * The mapping has nothing to do with "scale extent". - */ - elapse(val: number): number { - // If the value is in the break, return the normalized value in the break - let elapsedVal = AXIS_BREAK_ELAPSED_BASE; - let lastBreakEnd = AXIS_BREAK_LAST_BREAK_END_BASE; - let stillOver = true; - for (let i = 0; i < this.breaks.length; i++) { - const brk = this.breaks[i]; - if (val <= brk.vmax) { - if (val > brk.vmin) { - elapsedVal += brk.vmin - lastBreakEnd - + (val - brk.vmin) / (brk.vmax - brk.vmin) * brk.gapReal; + getExtentUnsafe(kind, depth) { + return (depth == null + || depth === SCALE_MAPPER_DEPTH_OUT_OF_BREAK + ) + ? this._outOfBrk.getExtentUnsafe(kind, null) + : this._linear.getExtentUnsafe(kind, null); + }, + + setExtent(start, end) { + this.setExtent2(SCALE_EXTENT_KIND_EFFECTIVE, start, end); + }, + + setExtent2(kind, start, end) { + if (isValidBoundsForExtent(start, end)) { + if (kind === SCALE_EXTENT_KIND_EFFECTIVE) { + updateAxisBreakGapReal(this, [start, end]); } - else { - elapsedVal += val - lastBreakEnd; + this._outOfBrk.setExtent2(kind, start, end); + this._linear.setExtent2( + kind, + this.transformIn(start, null), + this.transformIn(end, null) + ); + } + }, + + normalize(val) { + return this._linear.normalize(this.transformIn(val, null)); + }, + + scale(val) { + return this.transformOut(this._linear.scale(val), null); + }, + + contain(val) { + return this._outOfBrk.contain(val); + }, + + /** + * a.k.a., "elapse" + * Suppose: + * AXIS_BREAK_LAST_BREAK_END_BASE: 0 + * AXIS_BREAK_ELAPSED_BASE: 0 + * breaks: [ + * {start: -400, end: -300, gap: 27}, + * {start: -100, end: 100, gap: 10}, + * {start: 200, end: 400, gap: 300}, + * ] + * The mapping will be: + * | | + * 400 + -> + 237 + * | | | | (gap: 300) + * 200 + -> + -63 + * | | + * 100 + -> + -163 + * | | | | (gap: 10) + * -100 + -> + -173 + * | | + * -300 + -> + -373 + * | | | | (gap: 27) + * -400 + -> + -400 + * | | + * origianl elapsed + * + * Note: + * `transformIn` and `transformOut` has nothing to do with "scale extent" - out of extent is supported. + */ + transformIn(val, opt) { + if (opt && opt.depth === SCALE_MAPPER_DEPTH_OUT_OF_BREAK) { + return val; + } + + // If the value is in the break, return the normalized value in the break + let elapsedVal = AXIS_BREAK_ELAPSED_BASE; + let lastBreakEnd = AXIS_BREAK_LAST_BREAK_END_BASE; + let stillOver = true; + for (let i = 0; i < this.breaks.length; i++) { + const brk = this.breaks[i]; + if (val <= brk.vmax) { + if (val > brk.vmin) { + elapsedVal += brk.vmin - lastBreakEnd + + (val - brk.vmin) / (brk.vmax - brk.vmin) * brk.gapReal; + } + else { + elapsedVal += val - lastBreakEnd; + } + lastBreakEnd = brk.vmax; + stillOver = false; + break; } + elapsedVal += brk.vmin - lastBreakEnd + brk.gapReal; lastBreakEnd = brk.vmax; - stillOver = false; - break; } - elapsedVal += brk.vmin - lastBreakEnd + brk.gapReal; - lastBreakEnd = brk.vmax; - } - if (stillOver) { - elapsedVal += val - lastBreakEnd; - } - return elapsedVal; - } + if (stillOver) { + elapsedVal += val - lastBreakEnd; + } + return elapsedVal; + }, + + /** + * @see transformIn + * a.k.a., "unelapse" + */ + transformOut(elapsedVal, opt) { + if (opt && opt.depth === SCALE_MAPPER_DEPTH_OUT_OF_BREAK) { + return elapsedVal; + } - unelapse(elapsedVal: number): number { - let lastElapsedEnd = AXIS_BREAK_ELAPSED_BASE; - let lastBreakEnd = AXIS_BREAK_LAST_BREAK_END_BASE; - let stillOver = true; - let unelapsedVal = 0; - for (let i = 0; i < this.breaks.length; i++) { - const brk = this.breaks[i]; - const elapsedStart = lastElapsedEnd + brk.vmin - lastBreakEnd; - const elapsedEnd = elapsedStart + brk.gapReal; - if (elapsedVal <= elapsedEnd) { - if (elapsedVal > elapsedStart) { - unelapsedVal = brk.vmin - + (elapsedVal - elapsedStart) / (elapsedEnd - elapsedStart) * (brk.vmax - brk.vmin); - } - else { - unelapsedVal = lastBreakEnd + elapsedVal - lastElapsedEnd; + let lastElapsedEnd = AXIS_BREAK_ELAPSED_BASE; + let lastBreakEnd = AXIS_BREAK_LAST_BREAK_END_BASE; + let stillOver = true; + let unelapsedVal = 0; + for (let i = 0; i < this.breaks.length; i++) { + const brk = this.breaks[i]; + const elapsedStart = lastElapsedEnd + brk.vmin - lastBreakEnd; + const elapsedEnd = elapsedStart + brk.gapReal; + if (elapsedVal <= elapsedEnd) { + if (elapsedVal > elapsedStart) { + unelapsedVal = brk.vmin + + (elapsedVal - elapsedStart) / (elapsedEnd - elapsedStart) * (brk.vmax - brk.vmin); + } + else { + unelapsedVal = lastBreakEnd + elapsedVal - lastElapsedEnd; + } + lastBreakEnd = brk.vmax; + stillOver = false; + break; } + lastElapsedEnd = elapsedEnd; lastBreakEnd = brk.vmax; - stillOver = false; - break; } - lastElapsedEnd = elapsedEnd; - lastBreakEnd = brk.vmax; - } - if (stillOver) { - unelapsedVal = lastBreakEnd + elapsedVal - lastElapsedEnd; - } - return unelapsedVal; - } + if (stillOver) { + unelapsedVal = lastBreakEnd + elapsedVal - lastElapsedEnd; + } + return unelapsedVal; + }, + + }; }; -function createScaleBreakContext(): ScaleBreakContext { - return new ScaleBreakContextImpl(); +function createBreakScaleMapper( + breakParsed: AxisBreakParsingResult | NullUndefined, + initialExtent: number[] | NullUndefined +): BreakScaleMapper { + return new BreakScaleMapperImpl(breakParsed, initialExtent); } -// Both can start with any finite value, and are not necessaryily equal. But they need to +// Both can start with any finite value, and are not necessarily equal. But they need to // be the same in `axisBreakElapse` and `axisBreakUnelapse` respectively. const AXIS_BREAK_ELAPSED_BASE = 0; const AXIS_BREAK_LAST_BREAK_END_BASE = 0; /** - * `gapReal` in brkCtx.breaks will be calculated. + * `gapReal` in brkMapper.breaks will be calculated. */ function updateAxisBreakGapReal( - brkCtx: ScaleBreakContext, - scaleExtent: [number, number] + brkMapper: BreakScaleMapper, + scaleExtent: number[] ): void { // Considered the effect: // - Use dataZoom to move some of the breaks outside the extent. @@ -265,7 +324,7 @@ function updateAxisBreakGapReal( E: {tpAbs: init(), tpPrct: init()}, }; - each(brkCtx.breaks, brk => { + each(brkMapper.breaks, brk => { const gapParsed = brk.gapParsed; if (gapParsed.type === 'tpPrct') { @@ -315,12 +374,12 @@ function updateAxisBreakGapReal( - (semiInExtBrk.E.tpPrct.has ? semiInExtBrk.E.tpPrct.val * semiInExtBrk.E.tpPrct.inExtFrac : 0) ); - each(brkCtx.breaks, brk => { + each(brkMapper.breaks, brk => { const gapParsed = brk.gapParsed; if (gapParsed.type === 'tpPrct') { brk.gapReal = gapPrctSum !== 0 // prctBrksGapRealSum is supposed to be non-negative but add a safe guard - ? Math.max(prctBrksGapRealSum, 0) * gapParsed.val / gapPrctSum : 0; + ? mathMax(prctBrksGapRealSum, 0) * gapParsed.val / gapPrctSum : 0; } if (gapParsed.type === 'tpAbs') { brk.gapReal = gapParsed.val; @@ -337,7 +396,7 @@ function pruneTicksByBreak( breaks: ParsedAxisBreakList, getValue: (item: TItem) => number, interval: number, - scaleExtent: [number, number], + scaleExtent: number[], ): void { if (pruneByBreak === 'no') { return; @@ -381,7 +440,7 @@ function addBreaksToTicks( // The input ticks should be in accending order. ticks: ScaleTick[], breaks: ParsedAxisBreakList, - scaleExtent: [number, number], + scaleExtent: number[], // Keep the break ends at the same level to avoid an awkward appearance. getTimeProps?: (clampedBrk: ParsedAxisBreak) => ScaleTick['time'], ): void { @@ -428,10 +487,10 @@ function addBreaksToTicks( */ function clampBreakByExtent( brk: ParsedAxisBreak, - scaleExtent: [number, number] + scaleExtent: number[] ): NullUndefined | ParsedAxisBreak { - const vmin = Math.max(brk.vmin, scaleExtent[0]); - const vmax = Math.min(brk.vmax, scaleExtent[1]); + const vmin = mathMax(brk.vmin, scaleExtent[0]); + const vmax = mathMin(brk.vmax, scaleExtent[1]); return ( vmin < vmax || (vmin === vmax && vmin > scaleExtent[0] && vmin < scaleExtent[1]) @@ -449,10 +508,8 @@ function clampBreakByExtent( function parseAxisBreakOption( // raw user input breaks, retrieved from axis model. breakOptionList: AxisBreakOption[] | NullUndefined, - parse: Scale['parse'], - opt?: { - noNegative: boolean; - } + scale: {parse: Scale['parse']}, + opt?: ParseBreakOptionOpt ): AxisBreakParsingResult { const parsedBreaks: ParsedAxisBreakList = []; @@ -483,8 +540,8 @@ function parseAxisBreakOption( const parsedBrk: ParsedAxisBreak = { breakOption: clone(brkOption), - vmin: parse(brkOption.start), - vmax: parse(brkOption.end), + vmin: scale.parse(brkOption.start), + vmax: scale.parse(brkOption.end), gapParsed: {type: 'tpAbs', val: 0}, gapReal: null }; @@ -504,7 +561,7 @@ function parseAxisBreakOption( } } if (!isPrct) { - let absolute = parse(brkOption.gap); + let absolute = scale.parse(brkOption.gap); if (!isFinite(absolute) || absolute < 0) { if (__DEV__) { error(`Axis breaks gap must positive finite rather than (${brkOption.gap}).`); @@ -619,72 +676,73 @@ function retrieveAxisBreakPairs( return result; } -function getTicksLogTransformBreak( +function getTicksBreakOutwardTransform( + scale: ScaleMapper, tick: ScaleTick, - logBase: number, - logOriginalBreaks: ParsedAxisBreakList, - fixRoundingError: (val: number, originalVal: number) => number + outermostBreaks: ParsedAxisBreakList, + lookup: ValueTransformLookupOpt['lookup'] ): { - brkRoundingCriterion: number; + tickVal: number; vBreak: VisualAxisBreak | NullUndefined; -} { - let vBreak: VisualAxisBreak | NullUndefined; - let brkRoundingCriterion: number; - - if (tick.break) { - const brk = tick.break.parsedBreak; - const originalBreak = find(logOriginalBreaks, brk => identifyAxisBreak( - brk.breakOption, tick.break.parsedBreak.breakOption - )); - const vmin = fixRoundingError(Math.pow(logBase, brk.vmin), originalBreak.vmin); - const vmax = fixRoundingError(Math.pow(logBase, brk.vmax), originalBreak.vmax); - const gapParsed = { - type: brk.gapParsed.type, - val: brk.gapParsed.type === 'tpAbs' - ? fixRound(Math.pow(logBase, brk.vmin + brk.gapParsed.val)) - vmin - : brk.gapParsed.val, - }; - vBreak = { - type: tick.break.type, - parsedBreak: { - breakOption: brk.breakOption, - vmin, - vmax, - gapParsed, - gapReal: brk.gapReal, - } - }; - brkRoundingCriterion = originalBreak[tick.break.type]; + // Return: If no break, return null/undefined. +} | NullUndefined { + + if (!tick.break) { + return; } + const brk = tick.break.parsedBreak; + const originalBrkItem = find(outermostBreaks, brk => identifyAxisBreak( + brk.breakOption, tick.break.parsedBreak.breakOption + )); + // NOTE: `tick.break` may have been clamped by scale extent. + const opt: ScaleMapperTransformOutOpt = {lookup, depth: SCALE_MAPPER_DEPTH_OUT_OF_BREAK}; + const vmin = scale.transformOut(brk.vmin, opt); + const vmax = scale.transformOut(brk.vmax, opt); + const parsedBreak = { + vmin, + vmax, + breakOption: brk.breakOption, // It is not changed by extent clamping. + gapParsed: clone(originalBrkItem.gapParsed), + gapReal: brk.gapReal, + }; return { - brkRoundingCriterion, - vBreak, + tickVal: parsedBreak[tick.break.type], + vBreak: {type: tick.break.type, parsedBreak}, }; } -function logarithmicParseBreaksFromOption( - breakOptionList: AxisBreakOption[], - logBase: number, - parse: Scale['parse'], -): { - parsedOriginal: AxisBreakParsingResult; - parsedLogged: AxisBreakParsingResult; -} { - const opt = {noNegative: true}; - const parsedOriginal = parseAxisBreakOption(breakOptionList, parse, opt); - - const parsedLogged = parseAxisBreakOption(breakOptionList, parse, opt); - const loggedBase = Math.log(logBase); - parsedLogged.breaks = map(parsedLogged.breaks, brk => { - const vmin = Math.log(brk.vmin) / loggedBase; - const vmax = Math.log(brk.vmax) / loggedBase; +function parseAxisBreakOptionInwardTransform( + breakOptionList: AxisBreakOption[] | NullUndefined, + scale: Scale, + parseOpt: ParseBreakOptionOpt, + lookupStartIdx: number, + out: { + lookup: ValueTransformLookupOpt['lookup']; + original?: AxisBreakParsingResult; + transformed?: AxisBreakParsingResult; + } +): void { + out.original = parseAxisBreakOption(breakOptionList, scale, parseOpt); + const transformed = out.transformed = parseAxisBreakOption(breakOptionList, scale, parseOpt); + const lookup = out.lookup; + + transformed.breaks = map(transformed.breaks, (brk, idx) => { + const transOpt = {depth: SCALE_MAPPER_DEPTH_OUT_OF_BREAK} as const; + const vmin = scale.transformIn(brk.vmin, transOpt); + const vmax = scale.transformIn(brk.vmax, transOpt); const gapParsed = { type: brk.gapParsed.type, val: brk.gapParsed.type === 'tpAbs' - ? (Math.log(brk.vmin + brk.gapParsed.val) / loggedBase) - vmin + ? scale.transformIn(brk.vmin + brk.gapParsed.val, transOpt) - vmin : brk.gapParsed.val, }; + + lookup.from[lookupStartIdx + idx] = vmin; + lookup.to[lookupStartIdx + idx] = brk.vmin; + lookup.from[lookupStartIdx + idx + 1] = vmax; + lookup.to[lookupStartIdx + idx + 1] = brk.vmax; + return { vmin, vmax, @@ -693,8 +751,6 @@ function logarithmicParseBreaksFromOption( breakOption: brk.breakOption }; }); - - return {parsedOriginal, parsedLogged}; } const BREAK_MIN_MAX_TO_PARAM = {vmin: 'start', vmax: 'end'} as const; @@ -715,15 +771,15 @@ function makeAxisLabelFormatterParamBreak( export function installScaleBreakHelper(): void { registerScaleBreakHelperImpl({ - createScaleBreakContext, + createBreakScaleMapper, pruneTicksByBreak, addBreaksToTicks, parseAxisBreakOption, identifyAxisBreak, serializeAxisBreakIdentifier, retrieveAxisBreakPairs, - getTicksLogTransformBreak, - logarithmicParseBreaksFromOption, + getTicksBreakOutwardTransform, + parseAxisBreakOptionInwardTransform, makeAxisLabelFormatterParamBreak, }); } diff --git a/src/scale/helper.ts b/src/scale/helper.ts index 31f41945ec..59b3c2c170 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -17,12 +17,19 @@ * under the License. */ -import {getPrecision, round, nice, quantityExponent} from '../util/number'; -import IntervalScale from './Interval'; -import LogScale from './Log'; +import { + getPrecision, round, nice, quantityExponent, + mathPow, mathMax, mathRound, + mathLog, mathAbs, mathFloor, mathCeil +} from '../util/number'; +import type IntervalScale from './Interval'; +import type LogScale from './Log'; import type Scale from './Scale'; -import { bind } from 'zrender/src/core/util'; -import type { ScaleBreakContext } from './break'; +import type TimeScale from './Time'; +import { NullUndefined } from '../util/types'; +import type OrdinalScale from './Ordinal'; +import type { ScaleExtentFixMinMax } from '../coord/scaleRawExtentInfo'; +import { isValidNumberForExtent } from '../util/model'; type intervalScaleNiceTicksResult = { interval: number, @@ -30,19 +37,50 @@ type intervalScaleNiceTicksResult = { niceTickExtent: [number, number] }; -export function isValueNice(val: number) { - const exp10 = Math.pow(10, quantityExponent(Math.abs(val))); - const f = Math.abs(val / exp10); - return f === 0 - || f === 1 - || f === 2 - || f === 3 - || f === 5; +export type IntervalScaleGetLabelOpt = { + // If 'auto', use nice precision. + precision?: 'auto' | number, + // `true`: returns 1.50 but not 1.5 if precision is 2. + pad?: boolean +}; + +/** + * See also method `nice` in `src/util/number.ts`. + */ +// export function isValueNice(val: number) { +// const exp10 = Math.pow(10, quantityExponent(Math.abs(val))); +// const f = Math.abs(round(val / exp10, 0)); +// return f === 0 +// || f === 1 +// || f === 2 +// || f === 3 +// || f === 5; +// } + +export function isIntervalOrLogScale(scale: Scale): scale is (LogScale | IntervalScale) { + return isIntervalScale(scale) || isLogScale(scale); +} + +export function isIntervalOrTimeScale(scale: Scale): scale is (IntervalScale | TimeScale) { + return isIntervalScale(scale) || isTimeScale(scale); +} + +export function isIntervalScale(scale: Scale): scale is IntervalScale { + return scale.type === 'interval'; +} + +export function isTimeScale(scale: Scale): scale is TimeScale { + return scale.type === 'time'; +} + +export function isLogScale(scale: Scale): scale is LogScale { + return scale.type === 'log'; } -export function isIntervalOrLogScale(scale: Scale): scale is LogScale | IntervalScale { - return scale.type === 'interval' || scale.type === 'log'; +export function isOrdinalScale(scale: Scale): scale is OrdinalScale { + return scale.type === 'ordinal'; } + /** * @param extent Both extent[0] and extent[1] should be valid number. * Should be extent[0] < extent[1]. @@ -65,23 +103,27 @@ export function intervalScaleNiceTicks( if (maxInterval != null && interval > maxInterval) { interval = result.interval = maxInterval; } - // Tow more digital for tick. const precision = result.intervalPrecision = getIntervalPrecision(interval); // Niced extent inside original extent - const niceTickExtent = result.niceTickExtent = [ - round(Math.ceil(extent[0] / interval) * interval, precision), - round(Math.floor(extent[1] / interval) * interval, precision) + result.niceTickExtent = [ + round(mathCeil(extent[0] / interval) * interval, precision), + round(mathFloor(extent[1] / interval) * interval, precision) ]; - fixExtent(niceTickExtent, extent); - return result; } -export function increaseInterval(interval: number) { - const exp10 = Math.pow(10, quantityExponent(interval)); - // Increase interval - let f = interval / exp10; +/** + * The input `niceInterval` should be generated + * from `nice` method in `src/util/number.ts`, or + * from `increaseInterval` itself. + */ +export function increaseInterval(niceInterval: number) { + const exponent = quantityExponent(niceInterval); + // No rounding error in Math.pow(10, integer). + const exp10 = mathPow(10, exponent); + // Fix IEEE 754 float rounding error + let f = mathRound(niceInterval / exp10); if (!f) { f = 1; } @@ -94,82 +136,133 @@ export function increaseInterval(interval: number) { else { // f is 1 or 5 f *= 2; } - return round(f * exp10); + // Fix IEEE 754 float rounding error + return round(f * exp10, -exponent); } -/** - * @return interval precision - */ -export function getIntervalPrecision(interval: number): number { +export function getIntervalPrecision(niceInterval: number): number { // Tow more digital for tick. - return getPrecision(interval) + 2; + // NOTE: `2` was introduced in commit `af2a2a9f6303081d7c3b52f0a38add07b4c6e0c7`; + // it works on "nice" interval, but seems not necessarily mathematically required. + return getPrecision(niceInterval) + 2; } -function clamp( - niceTickExtent: [number, number], idx: number, extent: [number, number] -): void { - niceTickExtent[idx] = Math.max(Math.min(niceTickExtent[idx], extent[1]), extent[0]); -} +/** + * Lookup table to avoid rounding error - if the value before transformed is in `lookup.from[i]`, + * return `lookup.to[i]` directly without transform. + * Rounding errors typically arise in logarithm transform, which can cause the tick to be displayed + * like `5.999999999999999` when it is expected to be `6`. + */ +export type ValueTransformLookupOpt = { + lookup?: { + from: number[]; + to: number[]; + } | NullUndefined; +}; -// In some cases (e.g., splitNumber is 1), niceTickExtent may be out of extent. -export function fixExtent( - niceTickExtent: [number, number], extent: [number, number] -): void { - !isFinite(niceTickExtent[0]) && (niceTickExtent[0] = extent[0]); - !isFinite(niceTickExtent[1]) && (niceTickExtent[1] = extent[1]); - clamp(niceTickExtent, 0, extent); - clamp(niceTickExtent, 1, extent); - if (niceTickExtent[0] > niceTickExtent[1]) { - niceTickExtent[0] = niceTickExtent[1]; - } +/** + * NOTE: + * - If `val` is `NaN`, return `NaN`. + * - If `val` is `0`, return `-Infinity`. + * - If `val` is negative, return `NaN`. + * + * @see {DataStore#getDataExtent} It handles non-positive values for logarithm scale. + */ +export function logScaleLogTick( + val: number, + base: number, +): number { + // NOTE: + // - rounding error may happen above, typically expecting `log10(1000)` but actually + // getting `2.9999999999999996`, but generally it does not matter since they are not + // used to display. + // - Consider backward compatibility and other log bases, do not use `Math.log10`. + return mathLog(val) / mathLog(base); } -export function contain(val: number, extent: [number, number]): boolean { - return val >= extent[0] && val <= extent[1]; +/** + * Cumulative rounding errors cause the logarithm operation to become non-invertible by simply exponentiation. + * - `Math.pow(10, integer)` itself has no rounding error. But, + * - If `linearTickVal` is generated internally by `calcNiceTicks`, it may be still "not nice" (not an integer) + * when it is `extent[i]`. + * - If `linearTickVal` is generated outside (e.g., by `scaleCalcAlign`) and set by `setExtent`, + * `logScaleLogTick` may already have introduced rounding errors even for "nice" values. + * But invertible is required when the original `extent[i]` need to be respected, or "nice" ticks need to be + * displayed instead of something like `5.999999999999999`, which is addressed in this function. + * See also `#4158`. + * + * [CAUTION]: + * Monotonicity may be broken on extent ends - callers must make sure it does not matter. + */ +export function logScalePowTick( + // `tickVal` should be in the linear space. + linearTickVal: number, + base: number, + opt: ValueTransformLookupOpt | NullUndefined +): number { + const lookup = opt && opt.lookup; + if (lookup) { + for (let i = 0; i < lookup.from.length; i++) { + if (linearTickVal === lookup.from[i]) { + return lookup.to[i]; + } + } + } + return mathPow(base, linearTickVal); } -export class ScaleCalculator { - - normalize: (val: number, extent: [number, number]) => number = normalize; - scale: (val: number, extent: [number, number]) => number = scale; - - updateMethods(brkCtx: ScaleBreakContext) { - if (brkCtx.hasBreaks()) { - this.normalize = bind(brkCtx.normalize, brkCtx); - this.scale = bind(brkCtx.scale, brkCtx); +/** + * For `IntervalScale`, convert `rawExtent` to: + * - Be no non-finite number. + * - Be `extent[0] < extent[1]`; no equal, which brings convenience to "nice" calculation. + */ +export function intervalScaleEnsureValidExtent( + rawExtent: number[], + fixMinMax: ScaleExtentFixMinMax, +): number[] { + const extent = rawExtent.slice(); + // If extent start and end are same, expand them + if (extent[0] === extent[1]) { + if (extent[0] !== 0) { + // Expand extent + // Note that extents can be both negative. See #13154 + const expandSize = mathAbs(extent[0]); + // In the fowllowing case + // Axis has been fixed max 100 + // Plus data are all 100 and axis extent are [100, 100]. + // Extend to the both side will cause expanded max is larger than fixed max. + // So only expand to the smaller side. + if (!fixMinMax[1]) { + extent[1] += expandSize / 2; + extent[0] -= expandSize / 2; + } + else { + extent[0] -= expandSize / 2; + } } else { - this.normalize = normalize; - this.scale = scale; + extent[1] = 1; } } -} - -function normalize( - val: number, - extent: [number, number], - // Dont use optional arguments for performance consideration here. -): number { - if (extent[1] === extent[0]) { - return 0.5; + // For example, if there are no series data, extent may be `[Infinity, -Infinity]` here. + if (!isValidNumberForExtent(extent[0]) || !isValidNumberForExtent(extent[1])) { + extent[0] = 0; + extent[1] = 1; + } + if (extent[1] < extent[0]) { + extent.reverse(); } - return (val - extent[0]) / (extent[1] - extent[0]); + + return extent; } -function scale( - val: number, - extent: [number, number], -): number { - return val * (extent[1] - extent[0]) + extent[0]; +export function extentDiffers(extent1: number[], extent2: number[]): boolean[] { + return [extent1[0] !== extent2[0], extent1[1] !== extent2[1]]; } -export function logTransform(base: number, extent: number[], noClampNegative?: boolean): [number, number] { - const loggedBase = Math.log(base); - return [ - // log(negative) is NaN, so safe guard here. - // PENDING: But even getting a -Infinity still does not make sense in extent. - // Just keep it as is, getting a NaN to make some previous cases works by coincidence. - Math.log(noClampNegative ? extent[0] : Math.max(0, extent[0])) / loggedBase, - Math.log(noClampNegative ? extent[1] : Math.max(0, extent[1])) / loggedBase - ]; +export function ensureValidSplitNumber( + rawSplitNumber: number | NullUndefined, defaultSplitNumber: number +): number { + rawSplitNumber = rawSplitNumber || defaultSplitNumber; + return mathRound(mathMax(rawSplitNumber, 1)); } diff --git a/src/scale/minorTicks.ts b/src/scale/minorTicks.ts new file mode 100644 index 0000000000..85b8cf953e --- /dev/null +++ b/src/scale/minorTicks.ts @@ -0,0 +1,81 @@ +/* +* 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 { round } from '../util/number'; +import { ParsedAxisBreakList } from '../util/types'; +import { getScaleBreakHelper } from './break'; +import { getIntervalPrecision } from './helper'; +import Scale from './Scale'; + + +export function getMinorTicks( + scale: Scale, + splitNumber: number, + breaks: ParsedAxisBreakList, + scaleInterval: number +): number[][] { + const ticks = scale.getTicks({ + expandToNicedExtent: true, + }); + // NOTE: In log-scale, do not support minor ticks when breaks exist. + // because currently log-scale minor ticks is calculated based on raw values + // rather than log-transformed value, due to an odd effect when breaks exist. + const minorTicks = []; + const extent = scale.getExtent(); + + for (let i = 1; i < ticks.length; i++) { + const nextTick = ticks[i]; + const prevTick = ticks[i - 1]; + + if (prevTick.break || nextTick.break) { + // Do not build minor ticks to the adjacent ticks to breaks ticks, + // since the interval might be irregular. + continue; + } + + let count = 0; + const minorTicksGroup = []; + const interval = nextTick.value - prevTick.value; + const minorInterval = interval / splitNumber; + const minorIntervalPrecision = getIntervalPrecision(minorInterval); + + while (count < splitNumber - 1) { + const minorTick = round(prevTick.value + (count + 1) * minorInterval, minorIntervalPrecision); + + // For the first and last interval. The count may be less than splitNumber. + if (minorTick > extent[0] && minorTick < extent[1]) { + minorTicksGroup.push(minorTick); + } + count++; + } + + const scaleBreakHelper = getScaleBreakHelper(); + scaleBreakHelper && scaleBreakHelper.pruneTicksByBreak( + 'auto', + minorTicksGroup, + breaks, + value => value, + scaleInterval, + extent + ); + minorTicks.push(minorTicksGroup); + } + + return minorTicks; +} diff --git a/src/scale/scaleMapper.ts b/src/scale/scaleMapper.ts new file mode 100644 index 0000000000..da8b02bee6 --- /dev/null +++ b/src/scale/scaleMapper.ts @@ -0,0 +1,488 @@ +/* +* 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 { assert, bind, each, extend, keys, noop } from 'zrender/src/core/util'; +import { initExtentForUnion, isValidBoundsForExtent } from '../util/model'; +import { NullUndefined } from '../util/types'; +import { AxisBreakParsingResult, BreakScaleMapper, getScaleBreakHelper } from './break'; +import { error } from '../util/log'; +import { ValueTransformLookupOpt } from './helper'; +import { DataSanitizationFilter } from '../data/helper/dataValueHelper'; + + +// ------ START: Scale Mapper Core ------ + +/** + * - `SCALE_EXTENT_KIND_EFFECTIVE`: + * It is a portion of a scale extent that is functional on most features, including: + * - All tick/label-related calculation. + * - `dataZoom` controlled ends. + * - Cartesian2D `clampData`. + * - line series start. + * - heatmap series range. + * - markerArea range. + * - etc. + * `SCALE_EXTENT_KIND_EFFECTIVE` always exists. + * + * - `SCALE_EXTENT_KIND_MAPPING`: + * It is an expanded extent from the start and end of `SCALE_EXTENT_KIND_EFFECTIVE`. In the + * expanded parts, axis ticks and labels are considered meaningless and are not rendered. They + * can be typically created by `xxxAxis.containShape` feature. In this case, we need to: + * - Prevent "nice strategy" from triggering unexpectedly by the "contain shape expansion". + * Otherwise, for example, the original extent is `[0, 1000]`, then the expanded + * extent, say `[-5, 1000]`, can cause a considerable negative expansion by "nice", + * like `[-200, 10000]`, which is commonly unexpected. And it is exacerbated in LogScale. + * - Prevent the min/max tick label from displaying, since they are commonly meaningless + * and probably misleading. + * Therefore, `SCALE_EXTENT_KIND_MAPPING` is only used for: + * - mapping between data and pixel, such as, + * - `scaleMapper.normalize/scale`; + * - Cartesian2D `calcAffineTransform` (a quick path of `scaleMapper.normalize/scale`). + * - `grid` boundary related calculation in view rendering, such as, `barGrid` calculates + * `barWidth` for numeric scales based on the data extent. + * - Axis line position determination (such as `canOnZeroToAxis`); + * - `axisPointer` triggering (otherwise users may be confused if using `SCALE_EXTENT_KIND_EFFECTIVE`). + * `SCALE_EXTENT_KIND_MAPPING` can be absent, which can be used to determine whether it is used. + * + * Illustration: + * `SCALE_EXTENT_KIND_EFFECTIVE`: |------------| (always exist) + * `SCALE_EXTENT_KIND_MAPPING`: |---|------------|--| (present only when it is specified by `setExtent2`) + */ +export type ScaleExtentKind = + typeof SCALE_EXTENT_KIND_EFFECTIVE + | typeof SCALE_EXTENT_KIND_MAPPING; +export const SCALE_EXTENT_KIND_EFFECTIVE = 0; +export const SCALE_EXTENT_KIND_MAPPING = 1; + + +const SCALE_MAPPER_METHOD_NAMES_MAP: Record = { + needTransform: 1, + normalize: 1, + scale: 1, + transformIn: 1, + transformOut: 1, + contain: 1, + getExtent: 1, + getExtentUnsafe: 1, + setExtent: 1, + setExtent2: 1, + getFilter: 1, + sanitize: 1, + freeze: 1, +}; +const SCALE_MAPPER_METHOD_NAMES = keys(SCALE_MAPPER_METHOD_NAMES_MAP); + +/** + * - `SCALE_MAPPER_DEPTH_OUT_OF_BREAK`: + * In `transformIn`, it transforms a value from the outermost space to the space before break being applied. + * In `transformOut`, it transforms a value from the space before break being applied to the outermost space. + * Typically nice axis ticks are picked in that space due to the current design of nice ticks + * algorithm, while size related features may use `SCALE_MAPPER_DEPTH_INNERMOST`. + * - `SCALE_MAPPER_DEPTH_INNERMOST`: + * Currently only linear space is used as the innermost space. + */ +export type ScaleMapperDepthOpt = { + depth?: NullUndefined + | typeof SCALE_MAPPER_DEPTH_OUT_OF_BREAK + | typeof SCALE_MAPPER_DEPTH_INNERMOST; +}; +export const SCALE_MAPPER_DEPTH_OUT_OF_BREAK = 2; +export const SCALE_MAPPER_DEPTH_INNERMOST = 3; + +export type ScaleMapperTransformOutOpt = ( + // depth: NullUndefined means SCALE_MAPPER_DEPTH_INNERMOST. + ScaleMapperDepthOpt + & ValueTransformLookupOpt +); +export type ScaleMapperTransformInOpt = + // depth: NullUndefined means SCALE_MAPPER_DEPTH_INNERMOST. + ScaleMapperDepthOpt; + +/** + * `ScaleMapper` is designed for multiple steps of numeric transformations from a certain space to a linear space, + * or vice versa. Each steps is implemented as a `ScaleMapper`, and composed like a decorator pattern. And some + * steps, such as "axis breaks transfromation", can be skipped when no breaks for performance consideration. + * + * Currently we support: + * - step#0: extent based linear scaling. + * This is implemented in `LinearScaleMapper`. + * It is mixed into `IntervalScale`, `TimeScale`; + * and it is also composited into `BreakScaleMapper`, `OrdinalScale`, `LogScale`. + * - step#1: axis breaks. + * This is implemented in `BreakScaleMapper`. + * This step may be absent if no breaks. + * - step#2: logarithmic (implemented in `LogScale`), or + * ordinal-related handling (implemented in `OrdinalScale`), or + * others to be supported, such as asinh ... + * + * Illustration of some currently supported cases: + * - linear_space(in an IntervalScale) + * - break_space(in an IntervalScale method bound by a BreakScaleMapper) + * └─break_transform─► linear_space(in a LinearScaleMapper owned by a BreakScaleMapper) + * - log_space(in a LogScale) + * └─log_transform─► linear_space(in an IntervalScale) + * - log_space(in a LogScale) + * └─log_transform─► break_space(in an IntervalScale method bound by a BreakScaleMapper) + * └─break_transform─► linear_space(in a LinearScaleMapper owned by a BreakScaleMapper) + * - linear_space(in a TimeScale) + * - break_space(in a TimeScale method bound by a BreakScaleMapper) + * └─break_transform─► linear_space(in a LinearScaleMapper owned by a BreakScaleMapper) + * - category_values(in a OrdinalScale) + * └─category_to_numeric─► linear_space(in a LinearScaleMapper owned by a BreakScaleMapper) + */ +export interface ScaleMapper extends ScaleMapperGeneric {} +export interface ScaleMapperGeneric { + + /** + * Enable a fast path in large data traversal - the call of `transformIn`/`transformOut` + * can be omitted, and this is the most case. + */ + needTransform(this: This): boolean; + + /** + * Normalize a value to linear [0, 1], return 0.5 if extent span is 0. + * The typical logic is: + * `transformIn_self` -> `transformIn_inner` -> ... to the innermost space, + * then do linear normalization based on innermost extent. + */ + normalize: (this: This, val: number) => number; + + /** + * Scale a normalized value to extent. It's the inverse of `normalize`. + */ + scale: (this: This, val: number) => number; + + /** + * [NOTICE]: + * - This method must be available since the instance is constructed. + * - This method has nothing to do with extent - transforming out of extent is supported. + * + * This method transforms a value forward into a inner space. + * The typical logic is: + * `transformIn_self` -> `transformIn_inner` -> ... to the innermost space. + * In most cases axis ticks are laid out in linear space, and some features + * (such as LogScale, axis breaks) transform values from their own spaces into linear space. + */ + transformIn: ( + this: This, + val: number, + opt: ScaleMapperTransformInOpt | NullUndefined + ) => number; + + /** + * [NOTICE]: + * - This method must be available since the instance is constructed. + * - This method has nothing to do with extent - transforming out of extent is supported. + * + * The inverse method of `transformIn`. + */ + transformOut: ( + this: This, + val: number, + opt: ScaleMapperTransformOutOpt | NullUndefined + ) => number; + + /** + * Whether the extent contains the given value. + */ + contain: (this: This, val: number) => boolean; + + /** + * [NOTICE]: + * In EC_MAIN_CYCLE, scale extent is finally determined at `coordSys#update` stage. + * + * Get a clone of the scale extent. + * An extent is always in an increase order. + * It always returns an array - never be a null/undefined. + */ + getExtent(this: This): number[]; + + /** + * [NOTICE]: + * Callers must NOT modify the return. + */ + getExtentUnsafe( + this: This, + kind: ScaleExtentKind, + // NullUndefined means the outermost space. + depth: ScaleMapperDepthOpt['depth'] | NullUndefined + ): number[] | NullUndefined; + + /** + * [NOTICE]: + * The caller must ensure `start <= end` and both are finite number! + * + * `setExtent` is identical to `setExtent2(SCALE_EXTENT_KIND_EFFECTIVE)`. + * + * [The steps of extent construction in EC_MAIN_CYCLE]: + * - step#1. At `CoordinateSystem#create` stage, requirements of collecting series data extents are + * committed to `associateSeriesWithAxis`, and `Scale` instances are created. + * - step#2. Call `scaleRawExtentInfoCreate` to really collect series data extent and create + * `ScaleRawExtentInfo` instances to manage extent related configurations + * - at "data processing" stage for dataZoom controlled axes, if any, or + * - at "CoordinateSystem#update" stage for all other axes. + * Some strategies like "containShape" is performed then to expand the extent if needed. + * - step#3. Perform "nice" (see `scaleCalcNice`) or "align" (see `scaleCalcAlign`) strategies to + * modify the original extent from `ScaleRawExtentInfo` instance, if needed, at + * "CoordinateSystem#update" stage. + * - step#4. Set `SCALE_EXTENT_KIND_MAPPING` if needed (see `adoptScaleExtentKindMapping`; introduced + * by features like "containShape") at "CoordinateSystem#update" stage. + */ + setExtent(this: This, start: number, end: number): void; + setExtent2(this: This, kind: ScaleExtentKind, start: number, end: number): void; + + /** + * Filter for sanitization. + */ + getFilter?: () => DataSanitizationFilter; + + /** + * NOTICE: + * Should not sanitize invalid values (e.g., NaN, Infinity, null, undefined), + * since it probably has special meaning, and always properly handled in every Scale. + * + * Sanitize the value if possible. For example, for LogScale, the negative part will be clampped. + * This provides some permissiveness to ec option like `xxxAxis.min/max`. + */ + sanitize?: ( + (this: This, values: number | NullUndefined, dataExtent: number[]) => number | NullUndefined + ) | NullUndefined; + + /** + * Restrict the modification behavior of a scale for robustness. e.g., avoid subsequently + * modifying `SCALE_EXTENT_KIND_EFFECTIVE` but no sync to `SCALE_EXTENT_KIND_MAPPING`. + */ + freeze(this: This): void; +} + +export function initBreakOrLinearMapper( + // If input `null/undefined`, a mapper will be created. + mapper: ScaleMapper | NullUndefined, + breakParsed: AxisBreakParsingResult | NullUndefined, + initialExtent: number[] | NullUndefined, +): { + // If breaks are not available, `brk` is `null/undefined`. + brk: BreakScaleMapper | NullUndefined; + // Never be `null/undefined`. + mapper: ScaleMapper; +} { + let brk: BreakScaleMapper | NullUndefined; + mapper = mapper || {} as ScaleMapper; + + const scaleBreakHelper = getScaleBreakHelper(); + if (scaleBreakHelper) { + + const brkMapper = scaleBreakHelper.createBreakScaleMapper(breakParsed, initialExtent); + + if (brkMapper.hasBreaks()) { + // Some `ScaleMapper` methods (such as `normalize`) needs to be fast for large data + // when no breaks, so mount break methods only when breaks really exist. + each(SCALE_MAPPER_METHOD_NAMES, function (methodName) { + (mapper as any)[methodName] = bind(brkMapper[methodName], brkMapper); + }); + brk = brkMapper; + } + } + + if (brk == null) { + initLinearScaleMapper(mapper, initialExtent); + } + + return {brk, mapper}; +} + +export type DecoratedScaleMapperMethods = Omit, 'freeze'>; + +export function decorateScaleMapper( + host: THost, + decoratedMapperMethods: Omit, 'freeze'> +): void { + each(SCALE_MAPPER_METHOD_NAMES, function (methodName) { + (host as any)[methodName] = (decoratedMapperMethods as ScaleMapperGeneric)[methodName]; + }); +} + +export function enableScaleMapperFreeze(host: ScaleMapper, subMapper: ScaleMapper): void { + host.freeze = noop; + if (__DEV__) { + host.freeze = function () { + subMapper.freeze(); + }; + }; +} + +export function getScaleExtentForTickUnsafe(mapper: ScaleMapper): number[] { + return mapper.getExtentUnsafe(SCALE_EXTENT_KIND_EFFECTIVE, SCALE_MAPPER_DEPTH_OUT_OF_BREAK); +} + +export function getScaleExtentForMappingUnsafe( + mapper: ScaleMapper, + // NullUndefined means the outermost space. + depth: ScaleMapperDepthOpt['depth'] | NullUndefined +): number[] { + return mapper.getExtentUnsafe(SCALE_EXTENT_KIND_MAPPING, depth) + || mapper.getExtentUnsafe(SCALE_EXTENT_KIND_EFFECTIVE, depth); +} + +export function getScaleLinearSpanForMapping(mapper: ScaleMapper): number { + const extent = getScaleExtentForMappingUnsafe(mapper, SCALE_MAPPER_DEPTH_INNERMOST); + return extent[1] - extent[0]; +} + +export function getScaleLinearSpanEffective(mapper: ScaleMapper): number { + const extent = mapper.getExtentUnsafe(SCALE_EXTENT_KIND_EFFECTIVE, SCALE_MAPPER_DEPTH_INNERMOST); + return extent[1] - extent[0]; +} + +// ------ END: Scale Mapper Core ------ + + +// ------ START: Linear Scale Mapper ------ + +/** + * Generally, no need to export `LinearScaleMapper` and not recommended + * to visit `_extent` directly outside, otherwise it may be incorrect + * due to possible polymorphism - use `getExtentUnsafe()` instead. + */ +interface LinearScaleMapper extends ScaleMapper { + /** + * [CAVEAT]: + * - Should update only by `setExtent` or `setExtent2`! + * - The caller of `setExtent()` should ensure `extent[0] <= extent[1]`, + * but it is initialized as `[Infinity, -Infinity]`. + * With these restriction, `extent` can only be either: + * + `extent[0] < extent[1]` and both finite, or + * + `extent[0] === extent[1]` and both finite, or + * + `extent[0] === Infinity && extent[1] === -Infinity` + * + * Structure: `_extent[ScaleExtentKind][]` + */ + readonly _extents: number[][]; + readonly _frozen: boolean; +} + +export function initLinearScaleMapper( + // If input `null/undefined`, a mapper will be created. + mapper: ScaleMapper | NullUndefined, + initialExtent: number[] | NullUndefined +): ScaleMapper { + const linearMapper = (mapper || {}) as LinearScaleMapper; + + const extendList: number[][] = []; + // @ts-ignore + linearMapper._extents = extendList; + + extendList[SCALE_EXTENT_KIND_EFFECTIVE] = initialExtent ? initialExtent.slice() : initExtentForUnion(); + + extend(linearMapper, linearScaleMapperMethods); + + return linearMapper; +} + +const linearScaleMapperMethods: ScaleMapperGeneric = { + + needTransform() { + return false; + }, + + /** + * NOTICE: Don't use optional arguments for performance consideration here. + */ + normalize(val) { + const extent = this._extents[SCALE_EXTENT_KIND_MAPPING] || this._extents[SCALE_EXTENT_KIND_EFFECTIVE]; + if (extent[1] === extent[0]) { + return 0.5; + } + return (val - extent[0]) / (extent[1] - extent[0]); + }, + + scale(val) { + const extent = this._extents[SCALE_EXTENT_KIND_MAPPING] || this._extents[SCALE_EXTENT_KIND_EFFECTIVE]; + return val * (extent[1] - extent[0]) + extent[0]; + }, + + transformIn(val) { + return val; + }, + + transformOut(val) { + return val; + }, + + contain(val) { + // This method is typically used in axis trigger and markers. + // Users may be confused if the extent is restricted to `SCALE_EXTENT_KIND_EFFECTIVE`. + const extent = getScaleExtentForMappingUnsafe(this, null); + return val >= extent[0] && val <= extent[1]; + }, + + getExtent() { + return this._extents[SCALE_EXTENT_KIND_EFFECTIVE].slice(); + }, + + getExtentUnsafe(kind) { + return this._extents[kind]; + }, + + setExtent(start, end) { + if (__DEV__) { + assert(!this._frozen); + } + writeExtent(this._extents, SCALE_EXTENT_KIND_EFFECTIVE, start, end); + }, + + setExtent2(kind, start, end) { + if (__DEV__) { + assert(!this._frozen); + } + const extentList = this._extents; + if (!extentList[kind]) { + extentList[kind] = extentList[SCALE_EXTENT_KIND_EFFECTIVE].slice(); + } + writeExtent(extentList, kind, start, end); + }, + + freeze() { + if (__DEV__) { + // @ts-ignore + this._frozen = true; + } + } + +}; + +function writeExtent( + extentList: number[][], kind: ScaleExtentKind, start: number, end: number +): void { + // NOTE: `NaN` should be excluded. e.g., `scaleRawExtentInfo.resultMinMax` may be `[NaN, NaN]`. + if (isValidBoundsForExtent(start, end)) { + extentList[kind][0] = start; + extentList[kind][1] = end; + } + else { + if (__DEV__) { + // PENDING: should use `assert` after fixing all invalid calls. + if (start != null && end != null && start <= end) { + error(`Invalid setExtent call - start: ${start}, end: ${end}`); + } + } + } +} + +// ------ END: Linear Scale Mapper ------ diff --git a/src/util/cycleCache.ts b/src/util/cycleCache.ts new file mode 100644 index 0000000000..7d653bb72f --- /dev/null +++ b/src/util/cycleCache.ts @@ -0,0 +1,73 @@ +/* +* 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 GlobalModel from '../model/Global'; +import { makeInner } from './model'; + + +const ecModelCacheInner = makeInner<{ + fullUpdate: GlobalModelCachePerECFullUpdate; + prepare: GlobalModelCachePerECPrepare; +}, GlobalModel>(); + +export type GlobalModelCachePerECPrepare = {__: 'prepare'}; // Nominal to distinguish. +export type GlobalModelCachePerECFullUpdate = {__: 'fullUpdate'}; // Nominal to distinguish. + +/** + * CAVEAT: Can only be called by `echarts.ts` + */ +export function resetCachePerECPrepare(ecModel: GlobalModel): void { + ecModelCacheInner(ecModel).prepare = {} as GlobalModelCachePerECPrepare; +} + +/** + * CAVEAT: Can only be called by `echarts.ts` + */ +export function resetCachePerECFullUpdate(ecModel: GlobalModel): void { + ecModelCacheInner(ecModel).fullUpdate = {} as GlobalModelCachePerECFullUpdate; +} + +/** + * The cache is auto cleared at the beginning of EC_PREPARE_UPDATE. + * See also comments in EC_CYCLE. + * + * NOTICE: + * - EC_PREPARE_UPDATE is not necessarily executed before each EC_FULL_UPDATE performing. + * Typically, `setOption` trigger EC_PREPARE_UPDATE, but `dispatchAction` does not. + * - It is not cleared in EC_PARTIAL_UPDATE and EC_PROGRESSIVE_CYCLE. + * + */ +export function getCachePerECPrepare(ecModel: GlobalModel): GlobalModelCachePerECPrepare { + return ecModelCacheInner(ecModel).prepare; +} + +/** + * The cache is auto cleared at the beginning of EC_FULL_UPDATE. + * See also comments in EC_CYCLE. + * + * NOTICE: + * - It is not cleared in EC_PARTIAL_UPDATE and EC_PROGRESSIVE_CYCLE. + * - The cache should NOT be written before EC_FULL_UPDATE started, such as: + * - should NOT in `getTargetSeries` methods of data processors. + * - should NOT in `init`/`mergeOption`/`optionUpdated`/`getData` methods of component/series models. + * - See `getCachePerECPrepare` for details. + */ +export function getCachePerECFullUpdate(ecModel: GlobalModel): GlobalModelCachePerECFullUpdate { + return ecModelCacheInner(ecModel).fullUpdate; +} diff --git a/src/util/format.ts b/src/util/format.ts index afe9625b42..6378072327 100644 --- a/src/util/format.ts +++ b/src/util/format.ts @@ -19,7 +19,7 @@ import * as zrUtil from 'zrender/src/core/util'; import { encodeHTML } from 'zrender/src/core/dom'; -import { parseDate, isNumeric, numericToNumber } from './number'; +import { parseDate, isNumeric, numericToNumber, isNullableNumberFinite } from './number'; import { TooltipRenderMode, ColorString, ZRColor, DimensionType } from './types'; import { Dictionary } from 'zrender/src/core/types'; import { GradientObject } from 'zrender/src/graphic/Gradient'; @@ -72,7 +72,7 @@ export function makeValueReadable( return (str && zrUtil.trim(str)) ? str : '-'; } function isNumberUserReadable(num: number): boolean { - return !!(num != null && !isNaN(num) && isFinite(num)); + return isNullableNumberFinite(num); } const isTypeTime = valueType === 'time'; diff --git a/src/util/graphic.ts b/src/util/graphic.ts index 45bf8b556a..ef104b7fd1 100644 --- a/src/util/graphic.ts +++ b/src/util/graphic.ts @@ -97,6 +97,14 @@ type ExtendShapeReturn = ReturnType; export const XY = ['x', 'y'] as const; export const WH = ['width', 'height'] as const; +/** + * NOTICE: Only canvas renderer can set these hoverLayer flags. + * @see ElementCommonState['hoverLayer'] + */ +export const HOVER_LAYER_NO = 0; +export const HOVER_LAYER_FROM_THRESHOLD = 1; +export const HOVER_LAYER_FOR_INCREMENTAL = 2; + /** * Extend shape with parameters */ diff --git a/src/util/jitter.ts b/src/util/jitter.ts index 567a359686..89460bdaa4 100644 --- a/src/util/jitter.ts +++ b/src/util/jitter.ts @@ -18,10 +18,14 @@ */ import type Axis from '../coord/Axis'; +import { calcBandWidth } from '../coord/axisBand'; import type { AxisBaseModel } from '../coord/AxisBaseModel'; import Axis2D from '../coord/cartesian/Axis2D'; +import { COORD_SYS_TYPE_CARTESIAN_2D } from '../coord/cartesian/GridModel'; +import { COORD_SYS_TYPE_SINGLE_AXIS } from '../coord/single/AxisModel'; import type SingleAxis from '../coord/single/SingleAxis'; import type SeriesModel from '../model/Series'; +import { isOrdinalScale } from '../scale/helper'; import { makeInner } from './model'; export function needFixJitter(seriesModel: SeriesModel, axis: Axis): boolean { @@ -29,8 +33,8 @@ export function needFixJitter(seriesModel: SeriesModel, axis: Axis): boolean { const coordType = coordinateSystem && coordinateSystem.type; const baseAxis = coordinateSystem && coordinateSystem.getBaseAxis && coordinateSystem.getBaseAxis(); const scaleType = baseAxis && baseAxis.scale && baseAxis.scale.type; - const seriesValid = coordType === 'cartesian2d' && scaleType === 'ordinal' - || coordType === 'single'; + const seriesValid = coordType === COORD_SYS_TYPE_CARTESIAN_2D && scaleType === 'ordinal' + || coordType === COORD_SYS_TYPE_SINGLE_AXIS; const axisValid = (axis.model as AxisBaseModel).get('jitter') > 0; return seriesValid && axisValid; @@ -61,7 +65,7 @@ export function fixJitter( ): number { if (fixedAxis instanceof Axis2D) { const scaleType = fixedAxis.scale.type; - if (scaleType !== 'category' && scaleType !== 'ordinal') { + if (scaleType !== 'ordinal') { return floatCoord; } } @@ -73,8 +77,8 @@ export function fixJitter( const jitterOverlap = axisModel.get('jitterOverlap'); const jitterMargin = axisModel.get('jitterMargin') || 0; // Get band width to limit jitter range - const bandWidth = fixedAxis.scale.type === 'ordinal' - ? fixedAxis.getBandWidth() + const bandWidth = isOrdinalScale(fixedAxis.scale) + ? calcBandWidth(fixedAxis).w : null; if (jitterOverlap) { return fixJitterIgnoreOverlaps(floatCoord, jitter, bandWidth, radius); @@ -117,8 +121,8 @@ function fixJitterAvoidOverlaps( const minFloat = Math.abs(overlapA - floatCoord) < Math.abs(overlapB - floatCoord) ? overlapA : overlapB; // Clamp only category axis - const bandWidth = fixedAxis.scale.type === 'ordinal' - ? fixedAxis.getBandWidth() + const bandWidth = isOrdinalScale(fixedAxis.scale) + ? calcBandWidth(fixedAxis).w : null; const distance = Math.abs(minFloat - floatCoord); diff --git a/src/util/layout.ts b/src/util/layout.ts index 1c138e57cd..41858807bd 100644 --- a/src/util/layout.ts +++ b/src/util/layout.ts @@ -34,7 +34,10 @@ import Element from 'zrender/src/Element'; import { Dictionary } from 'zrender/src/core/types'; import ExtensionAPI from '../core/ExtensionAPI'; import { error } from './log'; -import { BoxCoordinateSystemCoordFrom, getCoordForBoxCoordSys } from '../core/CoordinateSystem'; +import { + BOX_COORD_SYS_COORD_FROM_PROP_COORD2, BoxCoordinateSystemCoordFrom, + getCoordForCoordSysUsageKindBox +} from '../core/CoordinateSystem'; import SeriesModel from '../model/Series'; import type Model from '../model/Model'; import type ComponentModel from '../model/Component'; @@ -208,7 +211,7 @@ function getViewRectAndCenterForCircleLayout() { +export function makeInner() { const key = '__ec_inner_' + innerUniqueIndex++; return function (hostObj: Host): T { return (hostObj as any)[key] || ((hostObj as any)[key] = {}); @@ -1173,3 +1176,156 @@ export function clearTmpModel(model: Model): void { // Clear to avoid memory leak. model.option = model.parentModel = model.ecModel = null; } + +export function initExtentForUnion(): [number, number] { + return [Infinity, -Infinity]; +} + +/** + * NOTICE: + * - The input `val` must be a number - type checking is not performed. + * - `extent` should be initialized as `initExtentForUnion()`. + */ +export function unionExtentFromNumber(extent: number[], val: number | NullUndefined): void { + if (isValidNumberForExtent(val)) { + val < extent[0] && (extent[0] = val); + val > extent[1] && (extent[1] = val); + } +} + +/** + * NOTICE: + * - The input `val` must be a number - type checking is not performed. + * - `extent` should be initialized as `initExtentForUnion()`. + */ +export function unionExtentStartFromNumber(extent: number[], val: number | NullUndefined): void { + if (isValidNumberForExtent(val) && val < extent[0]) { + extent[0] = val; + } +} + +/** + * NOTICE: + * - The input `val` must be a number - type checking is not performed. + * - `extent` should be initialized as `initExtentForUnion()`. + */ +export function unionExtentEndFromNumber(extent: number[], val: number | NullUndefined): void { + if (isValidNumberForExtent(val) && val > extent[1]) { + extent[1] = val; + } +} + +/** + * NOTICE: + * - `extent` should be initialized as `initExtentForUnion()`. + */ +export function unionExtentFromExtent(tarExtent: number[], srcExtent: number[]): void { + // Accept both or neither. + if (isValidBoundsForExtent(srcExtent[0], srcExtent[1])) { + srcExtent[0] < tarExtent[0] && (tarExtent[0] = srcExtent[0]); + srcExtent[1] > tarExtent[1] && (tarExtent[1] = srcExtent[1]); + } +} + +/** + * PENDING: `Infinity` from user data is not necessarily meaningless, but visualizing it requires + * special handling and it will not be supported until required. So we simply ignore it here. + */ +export function isValidNumberForExtent(val: number | NullUndefined): boolean { + // Considered that number could be `NaN` and `Infinity` and should not write into the extent. + // Note that `Infinity` is the initialized value from `initExtentForUnion` and may be inputted. + return val != null && isFinite(val); +} + +export function isValidBoundsForExtent(start: number, end: number): boolean { + return isValidNumberForExtent(start) && isValidNumberForExtent(end) && start <= end; +} + +/** + * `extent` should be initialized by `initExtentForUnion()`, and unioned by `unionExtent()`. + * `extent` may contain `Infinity` / `NaN`, but assume no `null`/`undefined`. + */ +export function extentHasValue(extent: number[]): boolean { + // Also considered extent may have `NaN` and `Infinity`. + const span = extent[1] - extent[0]; + return isFinite(span) && span >= 0; +} + +/** + * A util for ensuring the callback is called only once. + * @usage + * const callOnlyOnce = makeCallOnlyOnce(); // Should be static (ESM top level). + * function someFunc(registers: EChartsExtensionInstallRegisters): void { + * callOnlyOnce(registers, function () { + * // Do something immediately and only once per registers. + * } + * } + */ +export function makeCallOnlyOnce() { + const hiddenKey = '__ec_once_' + onceUniqueIndex++; + return function (hostObj: Host, cb: () => void) { + if (__DEV__) { + assert(hostObj); + } + if (!hasOwn(hostObj, hiddenKey)) { + (hostObj as any)[hiddenKey] = 1; + cb(); + } + }; +} +let onceUniqueIndex = getRandomIdBase(); + + +/** + * @usage + * - The earlier item takes precedence for duplicate items. + * - The input `arr` will be modified if `resolve` is null/undefined. + * - Callers can use `resolve` to manually modify the `currItem`. + * The input `arr` will not be modified if `resolve` is passed. + * `resolve` will be called on every item. + * - Callers need to handle null/undefined (if existing) in `getKey`. + */ +export function removeDuplicates( + arr: (TItem | NullUndefined)[], + getKey: (item: TItem) => string, + // `existingCount`: the count before this item is added. + resolve: ((item: TItem, existingCount: number) => void) | NullUndefined, +): void { + const dupMap = createHashMap(); + let writeIdx = 0; + each(arr, function (item) { + const key = getKey(item); + if (__DEV__) { + assert(isString(key)); + } + const count = dupMap.get(key) || 0; + if (resolve) { + resolve(item, count); + } + if (!count && !resolve) { + arr[writeIdx++] = item; + } + dupMap.set(key, count + 1); + }); + if (!resolve) { + arr.length = writeIdx; + } +} + +export function removeDuplicatesGetKeyFromValueProp( + item: {value: TValue} +): string { + if (__DEV__) { + assert(item.value != null); + } + return item.value + ''; +} + +export function removeDuplicatesGetKeyFromItemItself( + item: TValue +): string { + if (__DEV__) { + assert(item != null); + } + return item + ''; +} diff --git a/src/util/number.ts b/src/util/number.ts index f156e6d3de..4c75782f4a 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -27,11 +27,19 @@ */ import * as zrUtil from 'zrender/src/core/util'; +import { NullUndefined } from './types'; const RADIAN_EPSILON = 1e-4; -// Although chrome already enlarge this number to 100 for `toFixed`, but -// we sill follow the spec for compatibility. -const ROUND_SUPPORTED_PRECISION_MAX = 20; + +// A `RangeError` may be thrown if `n` is out of this range when calling `toFixed(n)`. +// Although Chrome and ES2017+ have enlarged this number to 100, but we sill follow +// the ES3~ES6 spec (0 <= n <= 20) for backward and cross-platform compatibility. +const TO_FIXED_SUPPORTED_PRECISION_MAX = 20; + +// For rounding error like `2.9999999999999996`, with respect to IEEE754 64bit float. +// NOTICE: It only works when the expected result is a rational number with low +// precision. See method `round` for details. +export const DEFAULT_PRECISION_FOR_ROUNDING_ERROR = 14; function _trim(str: string): string { return str.replace(/^\s+|\s+$/g, ''); @@ -40,6 +48,14 @@ function _trim(str: string): string { export const mathMin = Math.min; export const mathMax = Math.max; export const mathAbs = Math.abs; +export const mathRound = Math.round; +export const mathFloor = Math.floor; +export const mathCeil = Math.ceil; +export const mathPow = Math.pow; +export const mathLog = Math.log; +export const mathLN10 = Math.LN10; +export const mathPI = Math.PI; +export const mathRandom = Math.random; /** * Linear mapping a value from domain to range @@ -149,25 +165,66 @@ export function parsePositionSizeOption(option: unknown, percentBase: number, pe } /** - * (1) Fix rounding error of float numbers. - * (2) Support return string to avoid scientific notation like '3.5e-7'. + * [Feature_1] Round at specified precision. + * FIXME: this is not a general-purpose rounding implementation yet due to `TO_FIXED_SUPPORTED_PRECISION_MAX`. + * e.g., `round(1.25 * 1e-150, 151)` has no overflow in IEEE754 64bit float, but can not be handled by + * this method. + * + * [Feature_2] Support return string to avoid scientific notation like '3.5e-7'. + * + * [Feature_3] Fix rounding error of float numbers !!!ONLY SUITABLE FOR SPECIAL CASES!!!. + * [CAVEAT]: + * Rounding is NEVER a general-purpose solution for rounding errors. + * Consider a case: `expect=123.99994999`, `actual=123.99995000` (suppose rounding error occurs). + * Calling `round(expect, 4)` gets `123.9999`. + * Calling `round(actual, 4)` gets `124.0000`. + * A unacceptable result arises, even if the original difference is only `0.00000001` (tiny + * and not strongly correlated with the digit pattern). + * So the rounding approach works only if: + * The digit next to the `precision` won't cross the rounding boundary. Typically, it works if + * the digit next to the `precision` is expected to be `0`, and the rounding error is small + * enough and impossible to affect that digit (`roundingError < Math.pow(10, -precision) / 2`). + * The quantity of a rounding error can be roughly estimated by formula: + * `minPrecisionRoundingErrorMayOccur ~= max(0, floor(14 - quantityExponent(val)))` + * MEMO: This is derived from: + * Let ` EXP52B10 = log10(pow(2, 52)) = 15.65355977452702 ` + * (`52` is IEEE754 float64 mantissa bits count) + * We require: ` abs(val) * pow(10, precision) < pow(10, EXP52B10) ` + * Hence: ` precision < EXP52B10 - log10(abs(val)) ` + * Hence: ` precision = floor( EXP52B10 - log10(abs(val)) ) ` + * Since: ` quantityExponent(val) = floor(log10(abs(val))) ` + * Hence: ` precision ~= floor(EXP52B10 - 1 - quantityExponent(val)) */ -export function round(x: number | string, precision?: number): number; +export function round(x: number | string, precision: number): number; export function round(x: number | string, precision: number, returnStr: false): number; export function round(x: number | string, precision: number, returnStr: true): string; -export function round(x: number | string, precision?: number, returnStr?: boolean): string | number { - if (precision == null) { - // FIXME: the default precision should not be provided, since there is no universally adaptable - // precision. The caller need to input a precision according to the scenarios. - precision = 10; +export function round(x: number | string, precision: number, returnStr?: boolean): string | number { + if (__DEV__) { + // NOTICE: We should not provided a default precision, since there is no universally adaptable + // precision. The caller need to input a precision according to the scenarios. + zrUtil.assert(precision != null); + } + if (isNaN(precision)) { + // precision utils (such as getAcceptableTickPrecision) may return NaN. + return returnStr ? '' + x : +x; } // Avoid range error - precision = Math.min(Math.max(0, precision), ROUND_SUPPORTED_PRECISION_MAX); + precision = mathMin(mathMax(0, precision), TO_FIXED_SUPPORTED_PRECISION_MAX); // PENDING: 1.005.toFixed(2) is '1.00' rather than '1.01' x = (+x).toFixed(precision); return (returnStr ? x : +x); } +export function roundLegacy(x: number | string, precision?: number): number; +export function roundLegacy(x: number | string, precision: number, returnStr: false): number; +export function roundLegacy(x: number | string, precision: number, returnStr: true): string; +export function roundLegacy(x: number | string, precision?: number, returnStr?: boolean): string | number { + if (precision == null) { + precision = 10; + } + return round(x, precision, returnStr as any); +} + /** * Inplacd asc sort arr. * The input arr will be modified. @@ -181,6 +238,8 @@ export function asc(arr: T): T { /** * Get precision. + * e.g. `getPrecisionSafe(100.123)` return `3`. + * e.g. `getPrecisionSafe(100)` return `0`. */ export function getPrecision(val: string | number): number { val = +val; @@ -200,7 +259,7 @@ export function getPrecision(val: string | number): number { if (val > 1e-14) { let e = 1; for (let i = 0; i < 15; i++, e *= 10) { - if (Math.round(val * e) / e === val) { + if (mathRound(val * e) / e === val) { return i; } } @@ -211,6 +270,8 @@ export function getPrecision(val: string | number): number { /** * Get precision with slow but safe method + * e.g. `getPrecisionSafe(100.123)` return `3`. + * e.g. `getPrecisionSafe(100)` return `0`. */ export function getPrecisionSafe(val: string | number): number { // toLowerCase for: '3.4E-12' @@ -222,20 +283,68 @@ export function getPrecisionSafe(val: string | number): number { const significandPartLen = eIndex > 0 ? eIndex : str.length; const dotIndex = str.indexOf('.'); const decimalPartLen = dotIndex < 0 ? 0 : significandPartLen - 1 - dotIndex; - return Math.max(0, decimalPartLen - exp); + return mathMax(0, decimalPartLen - exp); } /** - * Minimal dicernible data precisioin according to a single pixel. + * @deprecated Use `getAcceptableTickPrecision` instead. See bad case in `test/ut/spec/util/number.test.ts` + * NOTE: originally introduced in commit `ff93e3e7f9ff24902e10d4469fd3187393b05feb` + * + * Minimal discernible data precision according to a single pixel. */ export function getPixelPrecision(dataExtent: [number, number], pixelExtent: [number, number]): number { - const log = Math.log; - const LN10 = Math.LN10; - const dataQuantity = Math.floor(log(dataExtent[1] - dataExtent[0]) / LN10); - const sizeQuantity = Math.round(log(mathAbs(pixelExtent[1] - pixelExtent[0])) / LN10); + const dataQuantity = mathFloor(mathLog(dataExtent[1] - dataExtent[0]) / mathLN10); + const sizeQuantity = mathRound(mathLog(mathAbs(pixelExtent[1] - pixelExtent[0])) / mathLN10); // toFixed() digits argument must be between 0 and 20. - const precision = Math.min(Math.max(-dataQuantity + sizeQuantity, 0), 20); - return !isFinite(precision) ? 20 : precision; + const precision = mathMin(mathMax(-dataQuantity + sizeQuantity, 0), TO_FIXED_SUPPORTED_PRECISION_MAX); + return !isFinite(precision) ? TO_FIXED_SUPPORTED_PRECISION_MAX : precision; +} + +/** + * This method chooses a reasonable "data" precision that can be used in `round` method. + * A reasonable precision is suitable for display; it may cause cumulative error but acceptable. + * + * "data" is linearly mapped to pixel according to the ratio determined by `dataSpan` and `pxSpan`. + * The diff from the original "data" to the rounded "data" (with the result precision) should be + * equal or less than `pxDiffAcceptable`, which is typically `1` pixel. + * And the result precision should be as small as possible for a concise display. + * + * [NOTICE]: using arbitrary parameters is NOT preferable - a discernible misalign (e.g., over 1px) + * may occur, especially when `splitLine` is displayed. + * + * PENDING: Only the linear case is addressed for now; other mapping methods (like logarithm) will + * not be covered until necessary. + */ +export function getAcceptableTickPrecision( + dataExtent: number[], + // Typically, `Math.abs(pixelExtent[1] - pixelExtent[0])`. + pxSpan: number, + // By default, `1`. + pxDiffAcceptable: number | NullUndefined + // Return a precision >= 0 + // This precision can be used in method `round`. + // Return `NaN` for edge case or illegal inputs. Callers need to handle that. +): number { + const dataSpan = mathAbs(dataExtent[1] - dataExtent[0]); + if (!isFinite(dataSpan) || dataSpan === 0) { + return NaN; + } + // Formula for choosing an acceptable precision: + // Let `pxDiff = abs(dataSpan - round(dataSpan, precision))`. + // We require `pxDiff <= dataSpan * pxDiffAcceptable / pxSpan`. + // Consider the nature of "round", the max `pxDiff` is: `pow(10, -precision) / 2`, + // Hence: `pow(10, -precision) / 2 <= dataSpan * pxDiffAcceptable / pxSpan` + // Hence: `precision >= -log10(2 * dataSpan * pxDiffAcceptable / pxSpan)` + const dataExp2 = mathLog(2 * mathAbs(pxDiffAcceptable || 1) * mathAbs(dataSpan)) / mathLN10; + const pxExp = mathLog(mathAbs(pxSpan)) / mathLN10; + // PENDING: Rounding error generally does not matter; do not fix it before `Math.ceil` + // until bad case occur. + let precision = mathMax(0, mathCeil(-dataExp2 + pxExp)); + if (!isFinite(precision)) { + // If dataSpan is near `0`, the result should not be too big or even `Infinity`. + precision = NaN; + } + return precision; } /** @@ -277,7 +386,7 @@ export function getPercentSeats(valueList: number[], precision: number): number[ return []; } - const digits = Math.pow(10, precision); + const digits = mathPow(10, precision); const votesPerQuota = zrUtil.map(valueList, function (val) { return (isNaN(val) ? 0 : val) / sum * digits * 100; }); @@ -285,7 +394,7 @@ export function getPercentSeats(valueList: number[], precision: number): number[ const seats = zrUtil.map(votesPerQuota, function (votes) { // Assign automatic seats. - return Math.floor(votes); + return mathFloor(votes); }); let currentSum = zrUtil.reduce(seats, function (acc, val) { return acc + val; @@ -322,23 +431,23 @@ export function getPercentSeats(valueList: number[], precision: number): number[ * See */ export function addSafe(val0: number, val1: number): number { - const maxPrecision = Math.max(getPrecision(val0), getPrecision(val1)); + const maxPrecision = mathMax(getPrecision(val0), getPrecision(val1)); // const multiplier = Math.pow(10, maxPrecision); - // return (Math.round(val0 * multiplier) + Math.round(val1 * multiplier)) / multiplier; + // return (mathRound(val0 * multiplier) + mathRound(val1 * multiplier)) / multiplier; const sum = val0 + val1; // // PENDING: support more? - return maxPrecision > ROUND_SUPPORTED_PRECISION_MAX + return maxPrecision > TO_FIXED_SUPPORTED_PRECISION_MAX ? sum : round(sum, maxPrecision); } // Number.MAX_SAFE_INTEGER, ie do not support. -export const MAX_SAFE_INTEGER = 9007199254740991; +export const MAX_SAFE_INTEGER = mathPow(2, 53) - 1; /** * To 0 - 2 * PI, considering negative radian. */ export function remRadian(radian: number): number { - const pi2 = Math.PI * 2; + const pi2 = mathPI * 2; return (radian % pi2 + pi2) % pi2; } @@ -427,7 +536,7 @@ export function parseDate(value: unknown): Date { return new Date(NaN); } - return new Date(Math.round(value as number)); + return new Date(mathRound(value as number)); } /** @@ -437,50 +546,73 @@ export function parseDate(value: unknown): Date { * @return */ export function quantity(val: number): number { - return Math.pow(10, quantityExponent(val)); + return mathPow(10, quantityExponent(val)); } /** * Exponent of the quantity of a number - * e.g., 1234 equals to 1.234*10^3, so quantityExponent(1234) is 3 + * e.g., 9876 equals to 9.876*10^3, so quantityExponent(9876) is 3 + * e.g., 0.09876 equals to 9.876*10^-2, so quantityExponent(0.09876) is -2 * * @param val non-negative value * @return */ export function quantityExponent(val: number): number { if (val === 0) { + // PENDING: like IEEE754 use exponent `0` in this case. + // but methematically, exponent of zero is `-Infinity`. return 0; } - let exp = Math.floor(Math.log(val) / Math.LN10); + let exp = mathFloor(mathLog(val) / mathLN10); /** * exp is expected to be the rounded-down result of the base-10 log of val. * But due to the precision loss with Math.log(val), we need to restore it * using 10^exp to make sure we can get val back from exp. #11249 */ - if (val / Math.pow(10, exp) >= 10) { + if (val / mathPow(10, exp) >= 10) { exp++; } return exp; } +export const NICE_MODE_ROUND = 1 as const; +export const NICE_MODE_MIN = 2 as const; + /** - * find a “nice” number approximately equal to x. Round the number if round = true, - * take ceiling if round = false. The primary observation is that the “nicest” + * find a “nice” number approximately equal to x. Round the number if 'round', + * take ceiling if 'round'. The primary observation is that the “nicest” * numbers in decimal are 1, 2, and 5, and all power-of-ten multiples of these numbers. * * See "Nice Numbers for Graph Labels" of Graphic Gems. * * @param val Non-negative value. - * @param round * @return Niced number */ -export function nice(val: number, round?: boolean): number { +export function nice( + val: number, + // All non-`NICE_MODE_MIN`-truthy values means `NICE_MODE_ROUND`, for backward compatibility. + mode?: boolean | typeof NICE_MODE_ROUND | typeof NICE_MODE_MIN +): number { + // Consider the scientific notation of `val`: + // - `exponent` is its exponent. + // - `f` is its coefficient. `1 <= f < 10`. + // e.g., if `val` is `0.0054321`, `exponent` is `-3`, `f` is `5.4321`, + // The result is `0.005` on NICE_MODE_ROUND. + // e.g., if `val` is `987.12345`, `exponent` is `2`, `f` is `9.8712345`, + // The result is `1000` on NICE_MODE_ROUND. + // e.g., if `val` is `0`, + // The result is `1`. const exponent = quantityExponent(val); - const exp10 = Math.pow(10, exponent); - const f = val / exp10; // 1 <= f < 10 + // No rounding error in Math.pow(10, integer). + const exp10 = mathPow(10, exponent); + const f = val / exp10; + let nf; - if (round) { + if (mode === NICE_MODE_MIN) { + nf = 1; + } + else if (mode) { if (f < 1.5) { nf = 1; } @@ -516,9 +648,8 @@ export function nice(val: number, round?: boolean): number { } val = nf * exp10; - // Fix 3 * 0.1 === 0.30000000000000004 issue (see IEEE 754). - // 20 is the uppper bound of toFixed. - return exponent >= -20 ? +val.toFixed(exponent < 0 ? -exponent : 0) : val; + // Fix IEEE 754 float rounding error + return round(val, -exponent); } /** @@ -529,7 +660,7 @@ export function nice(val: number, round?: boolean): number { */ export function quantile(ascArr: number[], p: number): number { const H = (ascArr.length - 1) * p + 1; - const h = Math.floor(H); + const h = mathFloor(H); const v = +ascArr[h - 1]; const e = H - h; return e ? v + e * (ascArr[h] - v) : v; @@ -640,7 +771,7 @@ export function isNumeric(val: unknown): val is number { * @return An positive integer. */ export function getRandomIdBase(): number { - return Math.round(Math.random() * 9); + return mathRound(mathRandom() * 9); } /** @@ -671,3 +802,13 @@ export function getLeastCommonMultiple(a: number, b: number) { } return a * b / getGreatestCommonDividor(a, b); } + +/** + * NOTICE: Assume the input `val` is number or null/undefined, no type check, no support of BitInt. + * Therefore, it is NOT suitable for processing user input, but sufficient for + * internal usage in most cases. + * For platform-agnosticism, `Number.isFinite` is not used. + */ +export function isNullableNumberFinite(val: number | NullUndefined) { + return val != null && isFinite(val); +} diff --git a/src/util/types.ts b/src/util/types.ts index 1d6521d51e..c08d5948e9 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -59,7 +59,15 @@ import { PrimaryTimeUnit } from './time'; export {Dictionary}; export type RendererType = 'canvas' | 'svg'; +/** + * NOTICE: For historical reason, echarts and zrender have not enabled TS config + * `strictNullChecks` yet. Therefore, a explicitly declared `NullUndefined` can + * indicate a variable can be `null` or `undefined` without more investigation, + * but a variable without `NullUndefined` may also be `null` or `undefined`, + * which has to be determined by the implementation. + */ export type NullUndefined = null | undefined; +export const UNDEFINED_STR = 'undefined'; export type LayoutOrient = 'vertical' | 'horizontal'; export type HorizontalAlign = 'left' | 'center' | 'right'; @@ -328,18 +336,26 @@ export interface StageHandlerOverallReset { (ecModel: GlobalModel, api: ExtensionAPI, payload?: Payload): void } export interface StageHandler { + /** - * Indicate that the task will be piped all series - * (`performRawSeries` indicate whether includes filtered series). + * Indicate that the "series stage task" will be piped for all series + * (filtered series is included iff `performRawSeries: true`). + * + * OVERALL_STAGE_TASK (See `overallReset`) can not set `createOnAllSeries: true`. */ createOnAllSeries?: boolean; + /** - * Indicate that the task will be only piped in the pipeline of this type of series. - * (`performRawSeries` indicate whether includes filtered series). + * Indicate that the task will only be piped in the pipeline of this type of series. + * (filtered series is included iff `performRawSeries: true`). + * It is available for both `reset` and `overallReset`. */ seriesType?: string; + /** - * Indicate that the task will be only piped in the pipeline of the returned series. + * Indicate that the task will only be piped in the pipeline of the returned series. + * It is called in EC_PREPARE_UPDATE, before `CoordinateSystem['create']`. + * It is available for both `reset` and `overallReset`. */ getTargetSeries?: (ecModel: GlobalModel, api: ExtensionAPI) => HashMap; @@ -352,18 +368,46 @@ export interface StageHandler { * Called only when this task in a pipeline. */ plan?: StageHandlerPlan; + /** - * If `overallReset` specified, an "overall task" will be created. - * "overall task" does not belong to a certain pipeline. - * They always be "performed" in certain phase (depends on when they declared). - * They has "stub"s to connect with pipelines (one stub for one pipeline), - * delivering info like "dirty" and "output end". + * If `overallReset` is specified, an OVERALL_STAGE_TASK will be created. + * An OVERALL_STAGE_TASK resides across multiple pipelines, and is associated with + * pipelines by "stub"s, which deliver messages like "dirty" and "output end". + * OVERALL_STAGE_TASK does not support `progess` method. + * + * The `overallReset` method is called iff this task is "dirty" (See `Task['dirty']`). + * See `StageHandler['reset']` for a summary of possible `dirty()` calls. */ overallReset?: StageHandlerOverallReset; + /** - * Called only when this task in a pipeline, and "dirty". + * If `reset` is specified, a SERIES_STAGE_TASK will be created. + * A SERIES_STAGE_TASK is owned by a pipeline and is specific to a single series. + * + * The `reset` method is called iff this task is "dirty" (See `Task['dirty']`). + * Task `dirty()` call typically originates from a trigger of EC_MAIN_CYCLE (including + * EC_FULL_UPDATE and EC_PARTIAL_UPDATE) (See comments in EC_CYCLE) + * + * NOTICE: `dirtyOnOverallProgress: true` cause that the corresponding `overallReset` + * and `reset` of downsteams tasks may also be called in EC_PROGRESSIVE_CYCLE. + * But in this case, `CoordinateSystem#create` and `CoordinateSystem#update` are not called. + * Only lifecycle like `coordsys:aftercreate` can be ensured to be only called in EC_FULL_UPDATE + * of EC_MAIN_CYCLE, but not in EC_PROGRESSIVE_CYCLE and EC_APPEND_DATA_CYCLE. */ reset?: StageHandlerReset; + + /** + * This is a temporary mechanism for dataZoom case in `appendData`. + * + * It will set the OVERALL_STAGE_TASK dirty when the pipeline progress. + * Moreover, to avoid call the OVERALL_STAGE_TASK each frame (too frequent), + * it set the pipeline block (via `task.__block`) in this stage. + * + * Otherwise, (usually it is legacy case), the OVERALL_STAGE_TASK will only be + * executed when upstream is dirty. Otherwise the progressive rendering of all + * pipelines will be disabled unexpectedly. + */ + dirtyOnOverallProgress?: boolean; } export interface StageHandlerInternal extends StageHandler { @@ -421,12 +465,20 @@ export type OrdinalRawValue = string | number; export type OrdinalNumber = number; // The number mapped from each OrdinalRawValue. /** - * @usage For example, - * ```js - * { ordinalNumbers: [2, 5, 3, 4] } - * ``` - * means that ordinal 2 should be displayed on tick 0, - * ordinal 5 should be displayed on tick 1, ... + * @usage + * For example, + * ```js + * { ordinalNumbers: [2, 5, 3, 4] } + * ``` + * means that "ordinal number" 2 should be displayed on `tick.value` 0, + * "ordinal number" 5 should be displayed on `tick.value` 1, ... + * NOTICE: + * - The index/key of `ordinalNumbers` is "tick.value" rather than the index of + * `scale.getTicks()`, though in most cases they are the same, except that the + * `axis.min` is delibrately set to be not zero. + * - The value of `ordinalNumbers` must be a valid `OrdinalNumber`; + * null/undefined is not supported. + * - `OrdinalNumber` is always from `0` to `ordinalMeta.categories.length - 1`. */ export type OrdinalSortInfo = { ordinalNumbers: OrdinalNumber[]; @@ -533,9 +585,11 @@ export type AxisLabelFormatterExtraBreakPart = { }; export interface ScaleTick { - value: number, - break?: VisualAxisBreak, - time?: TimeScaleTick['time'], + value: number; + break?: VisualAxisBreak; + time?: TimeScaleTick['time']; + // NOTICE: null/undefined mean it is unknown whether this tick is "nice". + notNice?: boolean | NullUndefined; }; export interface TimeScaleTick extends ScaleTick { time: { @@ -708,6 +762,8 @@ export type ECUnitOption = { hoverLayerThreshold?: number legacyViewCoordSysCenterBase?: boolean + // A temporary guard in case that some unexpected effect occurs after axis impl refactoring. + legacyMinMaxDontInverseAxis?: boolean [key: string]: ComponentOption | ComponentOption[] | Dictionary | unknown @@ -1530,8 +1586,9 @@ export interface CommonTooltipOption { /** * When to trigger + * NOTE: mousewheel may modify view by dataZoom. */ - triggerOn?: 'mousemove' | 'click' | 'none' | 'mousemove|click' + triggerOn?: 'mousemove' | 'click' | 'none' | 'mousewheel' | 'mousemove|click|mousewheel' /** * Whether to not hide popup content automatically */ @@ -1761,8 +1818,10 @@ export interface ComponentOption { } /** - * - "data": Use it as "dataCoordSys", each data item is laid out based on a coord sys. - * - "box": Use it as "boxCoordSys", the overall bounding rect or anchor point is calculated based on a coord sys. + * - "data": Each data item is laid out based on a coord sys. + * See `COORD_SYS_USAGE_KIND_DATA`. + * - "box": The overall bounding rect or anchor point is calculated based on a coord sys. + * See `COORD_SYS_USAGE_KIND_BOX`. * e.g., * grid rect (cartesian rect) is calculate based on matrix/calendar coord sys; * pie center is calculated based on calendar/cartesian; diff --git a/src/util/vendor.ts b/src/util/vendor.ts index ab4db32d39..8be2f5c8bc 100644 --- a/src/util/vendor.ts +++ b/src/util/vendor.ts @@ -17,18 +17,118 @@ * under the License. */ -import { isArray } from 'zrender/src/core/util'; +import { assert } from 'zrender/src/core/util'; +import { error } from './log'; +import { UNDEFINED_STR } from './types'; +import { MAX_SAFE_INTEGER } from './number'; -/* global Float32Array */ -const supportFloat32Array = typeof Float32Array !== 'undefined'; +/* global Int8Array, Int16Array, Int32Array, Uint8Array, Uint16Array, Uint32Array, + Uint8ClampedArray, Float32Array, Float64Array */ -const Float32ArrayCtor = !supportFloat32Array ? Array : Float32Array; +export const Int8ArrayCtor = typeof Int8Array !== UNDEFINED_STR ? Int8Array : undefined; +export const Int16ArrayCtor = typeof Int16Array !== UNDEFINED_STR ? Int16Array : undefined; +export const Int32ArrayCtor = typeof Int32Array !== UNDEFINED_STR ? Int32Array : undefined; +export const Uint8ArrayCtor = typeof Uint8Array !== UNDEFINED_STR ? Uint8Array : undefined; +export const Uint16ArrayCtor = typeof Uint16Array !== UNDEFINED_STR ? Uint16Array : undefined; +export const Uint32ArrayCtor = typeof Uint32Array !== UNDEFINED_STR ? Uint32Array : undefined; +export const Uint8ClampedArrayCtor = typeof Uint8ClampedArray !== UNDEFINED_STR ? Uint8ClampedArray : undefined; +export const Float32ArrayCtor = typeof Float32Array !== UNDEFINED_STR ? Float32Array : undefined; +export const Float64ArrayCtor = typeof Float64Array !== UNDEFINED_STR ? Float64Array : undefined; -export function createFloat32Array(arg: number | number[]): number[] | Float32Array { - if (isArray(arg)) { - // Return self directly if don't support TypedArray. - return supportFloat32Array ? new Float32Array(arg) : arg; +// PENDING: `BigInt64Array` `BigUint64Array` is not suppored yet. +export type TypedArrayCtor = + typeof Int8ArrayCtor + | typeof Int16ArrayCtor + | typeof Int32ArrayCtor + | typeof Uint8ArrayCtor + | typeof Uint16ArrayCtor + | typeof Uint32ArrayCtor + | typeof Uint8ClampedArrayCtor + | typeof Float32ArrayCtor + | typeof Float64ArrayCtor; + +export type TypedArrayType = + Int8Array + | Int16Array + | Int32Array + | Uint8Array + | Uint16Array + | Uint32Array + | Uint8ClampedArray + | Float32Array + | Float64Array; + + +export function createFloat32Array(capacity: number): number[] | Float32Array { + return tryEnsureTypedArray({ctor: Float32ArrayCtor}, capacity).arr as number[] | Float32Array; +} + +/** + * Use Typed Array if possible for performance optimization, otherwise fallback to a normal array. + * + * Usage + * const tyArr = tryEnsureCompatibleTypedArray({ctor: Float64ArrayCtor}, capacity); + */ +export type CompatibleTypedArray = { + // Write by this method. + // If null/undefined, create one. + // Never be null/undefined after `tryEnsureTypedArray` is called. + arr?: TypedArrayType | number[]; + // Write by this method. + // Whether is actually typed array. + // Never be null/undefined after `tryEnsureTypedArray` is called. + typed?: boolean; + + // Need to be provided by callers. + // Expected constructor. Do not change it. + ctor: TypedArrayCtor; +}; +export function tryEnsureTypedArray( + tyArr: CompatibleTypedArray, + // Can add more types if needed. + // NOTICE: Callers need to manage data length themselves. + // Do not consider `capacity` as the data length. + capacity: number +): CompatibleTypedArray { + if (__DEV__) { + assert( + capacity != null && isFinite(capacity) && capacity >= 0 + && tyArr.hasOwnProperty('ctor') + ); } - // Else is number - return new Float32ArrayCtor(arg); -} \ No newline at end of file + const existingArr = tyArr.arr; + const ctor = tyArr.ctor; + + if (capacity > MAX_SAFE_INTEGER) { + capacity = MAX_SAFE_INTEGER; + } + + if (!existingArr || (tyArr.typed && existingArr.length < capacity)) { + let nextArr: TypedArrayType | number[]; + if (ctor) { + try { + // A large contiguous memory allocation may cause OOM. + nextArr = new ctor(capacity); + tyArr.typed = true; + existingArr && nextArr.set(existingArr); + } + catch (e) { + if (__DEV__) { + error(e); + } + } + } + if (!nextArr) { + nextArr = []; + tyArr.typed = false; + if (existingArr) { + for (let i = 0, len = existingArr.length; i < len; i++) { + nextArr[i] = existingArr[i]; + } + } + } + tyArr.arr = nextArr; + } + + return tyArr; +} diff --git a/src/view/Chart.ts b/src/view/Chart.ts index 734dece866..a3b9b726c7 100644 --- a/src/view/Chart.ts +++ b/src/view/Chart.ts @@ -249,6 +249,9 @@ function toggleHighlight(data: SeriesData, payload: Payload, state: DisplayState }); } else { + // In progressive mode, `data._graphicEls` has typically no items, + // thereby skipping this hover style changing. + // PENDING: more robust approaches? data.eachItemGraphicEl(function (el) { elSetState(el, state, highlightDigit); }); diff --git a/test/area-stack.html b/test/area-stack.html index c60eaa1b64..ceb63aacec 100644 --- a/test/area-stack.html +++ b/test/area-stack.html @@ -126,6 +126,7 @@ var option = { legend: { + top: 5, }, toolbox: { feature: { diff --git a/test/axis-align-edge-cases.html b/test/axis-align-edge-cases.html new file mode 100644 index 0000000000..4c1bc4b887 --- /dev/null +++ b/test/axis-align-edge-cases.html @@ -0,0 +1,837 @@ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/axis-align-ticks-random.html b/test/axis-align-ticks-random.html index 42e0858dba..8b502e713d 100644 --- a/test/axis-align-ticks-random.html +++ b/test/axis-align-ticks-random.html @@ -83,6 +83,8 @@

Right axis should follow split numbers from left axis.

'echarts' ], function (echarts) { + const __EC_OPTIONS_FOR_DEBUG = window.__EC_OPTIONS_FOR_DEBUG = {}; + function makeOption(leftMin, leftMax, rightMin, rightMax, splitNumber) { @@ -145,11 +147,13 @@

Right axis should follow split numbers from left axis.

} function makeTestCharts(containerId, leftMin, leftMax, rightMin, rightMax) { + __EC_OPTIONS_FOR_DEBUG[containerId] = []; const container = document.querySelector(containerId); for (let i = 0; i < 15; i++) { const dom = document.createElement('div'); dom.className = 'chart'; const option = makeOption(leftMin, leftMax, rightMin, rightMax); + __EC_OPTIONS_FOR_DEBUG[containerId].push(option); container.appendChild(dom); const chart = echarts.init(dom); chart.setOption(option); diff --git a/test/axis-align-ticks.html b/test/axis-align-ticks.html index c1e62500be..8b98a1b4f5 100644 --- a/test/axis-align-ticks.html +++ b/test/axis-align-ticks.html @@ -40,10 +40,12 @@
-
-
+
+
+
+
@@ -226,6 +228,7 @@ var option = { legend: {}, + tooltip: {}, xAxis: { type: 'category', name: 'x', @@ -256,7 +259,7 @@ ] } - var chart = testHelper.create(echarts, 'main3', { + var chart = testHelper.create(echarts, 'main_Log_axis_can_alignTicks_to_value_axis', { title: [ 'Log axis can alignTicks to value axis' ], @@ -302,7 +305,7 @@ ] } - var chart = testHelper.create(echarts, 'main4', { + var chart = testHelper.create(echarts, 'main_Value_axis_can_alignTicks_to_log_axis', { title: [ 'Value axis can alignTicks to log axis' ], @@ -406,6 +409,355 @@ }); }); + + + + + + diff --git a/test/axis-data-min-max.html b/test/axis-data-min-max.html index e0d510aa11..4e32a454a9 100644 --- a/test/axis-data-min-max.html +++ b/test/axis-data-min-max.html @@ -51,6 +51,8 @@
+
+ + + + + + + + + + diff --git a/test/axis.html b/test/axis.html index 72a233812e..2b9c21f024 100644 --- a/test/axis.html +++ b/test/axis.html @@ -21,26 +21,24 @@ + - + + + + -
- + + + + + \ No newline at end of file diff --git a/test/bar-overflow-plot2.html b/test/bar-overflow-plot2.html new file mode 100644 index 0000000000..03647bf628 --- /dev/null +++ b/test/bar-overflow-plot2.html @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/test/bar-overflow-time-plot.html b/test/bar-overflow-time-plot.html index 057d6c8579..6ecafd5e64 100644 --- a/test/bar-overflow-time-plot.html +++ b/test/bar-overflow-time-plot.html @@ -31,93 +31,491 @@ -
-
- - +
+
@@ -220,8 +721,5 @@ - - - \ No newline at end of file diff --git a/test/bar-polar-multi-series-radial.html b/test/bar-polar-multi-series-radial.html index 9399273a25..73374c2a5d 100644 --- a/test/bar-polar-multi-series-radial.html +++ b/test/bar-polar-multi-series-radial.html @@ -21,29 +21,26 @@ + - + + + + -
- + + + + + + diff --git a/test/bar-polar-multi-series.html b/test/bar-polar-multi-series.html index 685f075651..6a8e607980 100644 --- a/test/bar-polar-multi-series.html +++ b/test/bar-polar-multi-series.html @@ -21,32 +21,31 @@ + - + + + + -
+ +
+
+ + + + + + + + + diff --git a/test/bar-stack.html b/test/bar-stack.html index 51ce177f2f..09d2780985 100644 --- a/test/bar-stack.html +++ b/test/bar-stack.html @@ -39,6 +39,7 @@
+
@@ -725,6 +726,103 @@ + + + + + \ No newline at end of file diff --git a/test/boxplot-multi.html b/test/boxplot-multi.html index db27f88a0b..31144d854f 100644 --- a/test/boxplot-multi.html +++ b/test/boxplot-multi.html @@ -24,18 +24,19 @@ + - + + -
-
+ +
+
+ + + + + + + + + \ No newline at end of file diff --git a/test/build/mktest-tpl.html b/test/build/mktest-tpl.html index 629e3353f6..eea2982bcf 100644 --- a/test/build/mktest-tpl.html +++ b/test/build/mktest-tpl.html @@ -75,7 +75,7 @@ var chart = testHelper.create(echarts, '{{TPL_DOM_ID}}', { title: [ 'Test Case Description of {{TPL_DOM_ID}}', - '(Muliple lines and **emphasis** are supported in description)' + '(Multiple lines and **emphasis** are supported in description)' ], option: option, diff --git a/test/candlestick-case.html b/test/candlestick-case.html index 6a408a644a..47481605cd 100644 --- a/test/candlestick-case.html +++ b/test/candlestick-case.html @@ -37,10 +37,190 @@ + +
+
+ + + + @@ -56,98 +236,7 @@ var downColor = '#00da3c'; var downBorderColor = '#008F28'; - // 数据意义:开盘(open),收盘(close),最低(lowest),最高(highest) - var data0 = splitData([ - ['2013/1/24', 2320.26,2320.26,2287.3,2362.94], - ['2013/1/25', 2300,2291.3,2288.26,2308.38], - ['2013/1/28', 2295.35,2346.5,2295.35,2346.92], - ['2013/1/29', 2347.22,2358.98,2337.35,2363.8], - ['2013/1/30', 2360.75,2382.48,2347.89,2383.76], - ['2013/1/31', 2383.43,2385.42,2371.23,2391.82], - ['2013/2/1', 2377.41,2419.02,2369.57,2421.15], - ['2013/2/4', 2425.92,2428.15,2417.58,2440.38], - ['2013/2/5', 2411,2433.13,2403.3,2437.42], - ['2013/2/6', 2432.68,2434.48,2427.7,2441.73], - ['2013/2/7', 2430.69,2418.53,2394.22,2433.89], - ['2013/2/8', 2416.62,2432.4,2414.4,2443.03], - ['2013/2/18', 2441.91,2421.56,2415.43,2444.8], - ['2013/2/19', 2420.26,2382.91,2373.53,2427.07], - ['2013/2/20', 2383.49,2397.18,2370.61,2397.94], - ['2013/2/21', 2378.82,2325.95,2309.17,2378.82], - ['2013/2/22', 2322.94,2314.16,2308.76,2330.88], - ['2013/2/25', 2320.62,2325.82,2315.01,2338.78], - ['2013/2/26', 2313.74,2293.34,2289.89,2340.71], - ['2013/2/27', 2297.77,2313.22,2292.03,2324.63], - ['2013/2/28', 2322.32,2365.59,2308.92,2366.16], - ['2013/3/1', 2364.54,2359.51,2330.86,2369.65], - ['2013/3/4', 2332.08,2273.4,2259.25,2333.54], - ['2013/3/5', 2274.81,2326.31,2270.1,2328.14], - ['2013/3/6', 2333.61,2347.18,2321.6,2351.44], - ['2013/3/7', 2340.44,2324.29,2304.27,2352.02], - ['2013/3/8', 2326.42,2318.61,2314.59,2333.67], - ['2013/3/11', 2314.68,2310.59,2296.58,2320.96], - ['2013/3/12', 2309.16,2286.6,2264.83,2333.29], - ['2013/3/13', 2282.17,2263.97,2253.25,2286.33], - ['2013/3/14', 2255.77,2270.28,2253.31,2276.22], - ['2013/3/15', 2269.31,2278.4,2250,2312.08], - ['2013/3/18', 2267.29,2240.02,2239.21,2276.05], - ['2013/3/19', 2244.26,2257.43,2232.02,2261.31], - ['2013/3/20', 2257.74,2317.37,2257.42,2317.86], - ['2013/3/21', 2318.21,2324.24,2311.6,2330.81], - ['2013/3/22', 2321.4,2328.28,2314.97,2332], - ['2013/3/25', 2334.74,2326.72,2319.91,2344.89], - ['2013/3/26', 2318.58,2297.67,2281.12,2319.99], - ['2013/3/27', 2299.38,2301.26,2289,2323.48], - ['2013/3/28', 2273.55,2236.3,2232.91,2273.55], - ['2013/3/29', 2238.49,2236.62,2228.81,2246.87], - ['2013/4/1', 2229.46,2234.4,2227.31,2243.95], - ['2013/4/2', 2234.9,2227.74,2220.44,2253.42], - ['2013/4/3', 2232.69,2225.29,2217.25,2241.34], - ['2013/4/8', 2196.24,2211.59,2180.67,2212.59], - ['2013/4/9', 2215.47,2225.77,2215.47,2234.73], - ['2013/4/10', 2224.93,2226.13,2212.56,2233.04], - ['2013/4/11', 2236.98,2219.55,2217.26,2242.48], - ['2013/4/12', 2218.09,2206.78,2204.44,2226.26], - ['2013/4/15', 2199.91,2181.94,2177.39,2204.99], - ['2013/4/16', 2169.63,2194.85,2165.78,2196.43], - ['2013/4/17', 2195.03,2193.8,2178.47,2197.51], - ['2013/4/18', 2181.82,2197.6,2175.44,2206.03], - ['2013/4/19', 2201.12,2244.64,2200.58,2250.11], - ['2013/4/22', 2236.4,2242.17,2232.26,2245.12], - ['2013/4/23', 2242.62,2184.54,2182.81,2242.62], - ['2013/4/24', 2187.35,2218.32,2184.11,2226.12], - ['2013/4/25', 2213.19,2199.31,2191.85,2224.63], - ['2013/4/26', 2203.89,2177.91,2173.86,2210.58], - ['2013/5/2', 2170.78,2174.12,2161.14,2179.65], - ['2013/5/3', 2179.05,2205.5,2179.05,2222.81], - ['2013/5/6', 2212.5,2231.17,2212.5,2236.07], - ['2013/5/7', 2227.86,2235.57,2219.44,2240.26], - ['2013/5/8', 2242.39,2246.3,2235.42,2255.21], - ['2013/5/9', 2246.96,2232.97,2221.38,2247.86], - ['2013/5/10', 2228.82,2246.83,2225.81,2247.67], - ['2013/5/13', 2247.68,2241.92,2231.36,2250.85], - ['2013/5/14', 2238.9,2217.01,2205.87,2239.93], - ['2013/5/15', 2217.09,2224.8,2213.58,2225.19], - ['2013/5/16', 2221.34,2251.81,2210.77,2252.87], - ['2013/5/17', 2249.81,2282.87,2248.41,2288.09], - ['2013/5/20', 2286.33,2299.99,2281.9,2309.39], - ['2013/5/21', 2297.11,2305.11,2290.12,2305.3], - ['2013/5/22', 2303.75,2302.4,2292.43,2314.18], - ['2013/5/23', 2293.81,2275.67,2274.1,2304.95], - ['2013/5/24', 2281.45,2288.53,2270.25,2292.59], - ['2013/5/27', 2286.66,2293.08,2283.94,2301.7], - ['2013/5/28', 2293.4,2321.32,2281.47,2322.1], - ['2013/5/29', 2323.54,2324.02,2321.17,2334.33], - ['2013/5/30', 2316.25,2317.75,2310.49,2325.72], - ['2013/5/31', 2320.74,2300.59,2299.37,2325.53], - ['2013/6/3', 2300.21,2299.25,2294.11,2313.43], - ['2013/6/4', 2297.1,2272.42,2264.76,2297.1], - ['2013/6/5', 2270.71,2270.93,2260.87,2276.86], - ['2013/6/6', 2264.43,2242.11,2240.07,2266.69], - ['2013/6/7', 2242.26,2210.9,2205.07,2250.63], - ['2013/6/13', 2190.1,2148.35,2126.22,2190.1] - ]); - + var data0 = splitData(createRawData()); function splitData(rawData) { var categoryData = []; @@ -192,8 +281,6 @@ return result; } - - option = { title: { text: '上证指数', @@ -363,6 +450,8 @@ }); + + + + + + + + + + + + + + + + + + + + + diff --git a/test/candlestick-large3.html b/test/candlestick-large3.html index 5f58a89b98..6c64b7afd0 100644 --- a/test/candlestick-large3.html +++ b/test/candlestick-large3.html @@ -63,10 +63,11 @@ var xValueMax = rawDataChunkSize * chunkCount; var yValueMin = Infinity; var yValueMax = -Infinity; - + var xData = []; var rawData = []; + for (var i = 0; i < chunkCount; i++) { - rawData.push(generateOHLC(rawDataChunkSize)); + generateOHLC(rawDataChunkSize, rawData); } yValueMax = Math.ceil(yValueMax); yValueMin = Math.floor(yValueMin); @@ -75,7 +76,6 @@ frameInsight.init(echarts, 'duration'); - // var data = generateOHLC(rawDataChunkSize); var chart = window.chart = init(); var loadedChunkIndex = 0; @@ -99,7 +99,7 @@ } function generateOHLC(count) { - var data = []; + var seriesData = []; var tmpVals = new Array(4); var dayRange = 12; @@ -125,10 +125,12 @@ closeIdx++; } + var xValIdx = xData.length; + xData.push(echarts.format.formatTime('yyyy-MM-dd hh:mm:ss', xValue += minute)); // ['open', 'close', 'lowest', 'highest'] // [1, 4, 3, 2] - data.push([ - echarts.format.formatTime('yyyy-MM-dd hh:mm:ss', xValue += minute), + seriesData.push([ + xValIdx, +tmpVals[openIdx].toFixed(2), // open +tmpVals[3].toFixed(2), // highest +tmpVals[0].toFixed(2), // lowest @@ -136,7 +138,7 @@ ]); } - return data; + rawData.push(seriesData); } function calculateMA(dayCount, data) { @@ -208,8 +210,9 @@ axisLine: {onZero: false}, splitLine: {show: false}, splitNumber: 20, - min: xValueMin, - max: xValueMax + // min: xValueMin, + // max: xValueMax, + data: xData, }, // { // type: 'category', diff --git a/test/dataZoom-action.html b/test/dataZoom-action.html index fb34b42616..5b3651d9f0 100644 --- a/test/dataZoom-action.html +++ b/test/dataZoom-action.html @@ -49,105 +49,204 @@
-
-
-
+
+
+
+
+
@@ -328,7 +427,7 @@ var now = new Date(base += oneDay); var cat = [now.getFullYear(), now.getMonth() + 1, now.getDate()].join('-'); category.push(cat); - value = Math.round((Math.random() - 0.5) * 20 + (!i ? startValue : data1[i - 1])); + var value = Math.round((Math.random() - 0.5) * 20 + (!i ? startValue : data1[i - 1])); data1.push(value); if (i === 40) { specialNormal[0] = cat; @@ -346,6 +445,9 @@ } } + var minSpan = 5; + var maxSpan = 30; + var option = { tooltip: { trigger: 'axis' @@ -373,15 +475,15 @@ id: 'dz-in', start: 0, end: 10, - minSpan: 5, - maxSpan: 30, + minSpan: minSpan, + maxSpan: maxSpan, xAxisIndex: 0 }, { id: 'dz-s', start: 0, end: 10, - minSpan: 5, - maxSpan: 30, + minSpan: minSpan, + maxSpan: maxSpan, xAxisIndex: 0 }], series: [{ @@ -398,7 +500,7 @@ var ctx = { hint: 'category axis value should be integer', - percentButttons: getDefaultPercentButtons(), + percentButttons: getDefaultPercentButtons({minSpan, maxSpan}), valueButtons: [{ text: getBtnLabel(specialNormal), startValue: specialNormal[0], @@ -414,7 +516,7 @@ }, { }] }; - ctx.chart = testHelper.create(echarts, 'main2', { + ctx.chart = testHelper.create(echarts, 'main_categoryX_hasMinSpan_hasMaxSpan', { option: option, title: [ '(category axis) dispatchAction: {type: "dataZoom"}', @@ -448,7 +550,7 @@ for (var i = 0; i < 100; i++) { var now = new Date(base += oneDay); - value = Math.round((Math.random() - 0.5) * 20 + (!i ? startValue : data2[i - 1][1])); + var value = Math.round((Math.random() - 0.5) * 20 + (!i ? startValue : data2[i - 1][1])); data2.push([now, value]); if (i === 30) { specialNormal[0] = +now; @@ -466,6 +568,9 @@ } } + var minSpan = 5; + var maxSpan = 30; + var option = { tooltip: { trigger: 'axis', @@ -490,14 +595,14 @@ dataZoom: [{ type: 'inside', id: 'dz-in', - maxSpan: 30, - minSpan: 5, + minSpan: minSpan, + maxSpan: maxSpan, start: 0, end: 10 }, { id: 'dz-s', - maxSpan: 30, - minSpan: 5, + minSpan: minSpan, + maxSpan: maxSpan, start: 0, end: 10 }], @@ -520,7 +625,7 @@ var ctx = { hint: 'time axis value should be integer', - percentButttons: getDefaultPercentButtons(), + percentButttons: getDefaultPercentButtons({minSpan, maxSpan}), valueButtons: [{ text: getBtnLabel(specialNormal), startValue: fmt2Str(specialNormal[0]), @@ -535,7 +640,7 @@ endValue: fmt2Str(specialLong[1]) }] }; - ctx.chart = testHelper.create(echarts, 'main3', { + ctx.chart = testHelper.create(echarts, 'main_timeX_hasMinSpan_hasMaxSpan', { option: option, title: [ '(time axis) dispatchAction: {type: "dataZoom"}', @@ -552,17 +657,6 @@ - - - - - - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/test/lib/testHelper.js b/test/lib/testHelper.js index fee04790dd..67b95a7bce 100644 --- a/test/lib/testHelper.js +++ b/test/lib/testHelper.js @@ -1819,7 +1819,7 @@ if (theme == null && window.__ECHARTS__DEFAULT__THEME__) { theme = window.__ECHARTS__DEFAULT__THEME__; } - if (theme) { + if (typeof theme === 'string') { require(['theme/' + theme]); } diff --git a/test/logScale.html b/test/logScale.html index 028256139a..113337e8d5 100644 --- a/test/logScale.html +++ b/test/logScale.html @@ -39,6 +39,7 @@
+
+ + + + \ No newline at end of file diff --git a/test/pie-coordinate-system.html b/test/pie-coordinate-system.html index c44c74bc52..ca6e1a0f20 100644 --- a/test/pie-coordinate-system.html +++ b/test/pie-coordinate-system.html @@ -36,7 +36,7 @@
-
+
+ + + + + + diff --git a/test/ut/spec/util/model.test.ts b/test/ut/spec/util/model.test.ts index 40f7890324..e00cf22bda 100755 --- a/test/ut/spec/util/model.test.ts +++ b/test/ut/spec/util/model.test.ts @@ -18,7 +18,7 @@ * under the License. */ -import { compressBatches } from '@/src/util/model'; +import { compressBatches, removeDuplicates } from '@/src/util/model'; describe('util/model', function () { @@ -93,6 +93,167 @@ describe('util/model', function () { ]); }); + + describe('removeDuplicates', function () { + + type Item1 = { + name: string; + name2?: string; + extraNum?: number; + }; + type Item2 = { + value: number; + }; + + it('removeDuplicates_resolve1', function () { + const countRecord: number[] = []; + function resolve1(item: Item1, count: number): void { + countRecord.push(count); + item.name2 = item.name + ( + count > 0 ? (count - 1) : '' + ); + } + const arr: Item1[] = [ + {name: 'y'}, + {name: 'b'}, + {name: 'y'}, + {name: 't'}, + {name: 'y'}, + {name: 'z'}, + {name: 't'}, + ]; + const arrLengthOriginal = arr.length; + const arrNamesOriginal = arr.map(item => item.name); + removeDuplicates(arr, item => item.name, resolve1); + + expect(countRecord).toEqual([0, 0, 1, 0, 2, 0, 1]); + expect(arr.length).toEqual(arrLengthOriginal); + expect(arr.map(item => item.name)).toEqual(arrNamesOriginal); + expect(arr.map(item => item.name2)).toEqual(['y', 'b', 'y0', 't', 'y1', 'z', 't0']); + }); + + it('removeDuplicates_no_resolve_has_value', function () { + const arr: string[] = [ + 'y', + 'b', + 'y', + undefined, + 'y', + null, + 'y', + 't', + 'b', + ]; + removeDuplicates(arr, item => item + '', null); + expect(arr.length).toEqual(5); + expect(arr).toEqual(['y', 'b', undefined, null, 't']); + }); + + it('removeDuplicates_priority', function () { + const arr: Item1[] = [ + {name: 'y', extraNum: 100}, + {name: 'b', extraNum: 101}, + {name: 'y', extraNum: 102}, + {name: 't', extraNum: 103}, + {name: 'y', extraNum: 104}, + {name: 'z', extraNum: 105}, + {name: 't', extraNum: 106}, + ]; + removeDuplicates(arr, item => item.name, null); + expect(arr.length).toEqual(4); + expect(arr.map(item => item.name)).toEqual(['y', 'b', 't', 'z']); + expect(arr.map(item => item.extraNum)).toEqual([100, 101, 103, 105]); + }); + + it('removeDuplicates_edges_cases', function () { + function run(inputArr: Item2[], expectArr: Item2[]): void { + removeDuplicates(inputArr, (item: Item2) => item.value + '', null); + expect(inputArr).toEqual(expectArr); + } + + run( + [], + [] + ); + run( + [ + {value: 1}, + ], + [ + {value: 1} + ] + ); + run( + [ + { value: 1 }, + { value: 2 }, + { value: 3 } + ], + [ + { value: 1 }, + { value: 2 }, + { value: 3 } + ], + ); + run( + [ + { value: 1 }, + { value: 2 }, + { value: 2 }, + { value: 3 } + ], + [ + { value: 1 }, + { value: 2 }, + { value: 3 } + ], + ); + run( + [ + { value: 1 }, + { value: 1 }, + { value: 2 } + ], + [ + { value: 1 }, + { value: 2 } + ], + ); + run( + [ + { value: 1 }, + { value: 2 }, + { value: 2 } + ], + [ + { value: 1 }, + { value: 2 } + ], + ); + run( + [ + { value: 2 }, + { value: 2 }, + { value: 2 } + ], + [ + { value: 2 } + ], + ); + run( + [ + { value: 5 }, + { value: 5 } + ], + [ + { value: 5 }, + ], + ); + + }); + + }); + }); }); \ No newline at end of file diff --git a/test/ut/spec/util/number.test.ts b/test/ut/spec/util/number.test.ts index 52d872130c..25bb4502a0 100755 --- a/test/ut/spec/util/number.test.ts +++ b/test/ut/spec/util/number.test.ts @@ -21,7 +21,9 @@ import { linearMap, parseDate, reformIntervals, getPrecisionSafe, getPrecision, getPercentWithPrecision, quantityExponent, quantity, nice, - isNumeric, numericToNumber, addSafe + isNumeric, numericToNumber, addSafe, + getPixelPrecision, + getAcceptablePrecision } from '@/src/util/number'; @@ -1015,4 +1017,74 @@ describe('util/number', function () { testNumeric(function () {}, NaN, false); }); + describe('getAcceptablePrecision', function () { + // NOTICE: These cases fail in `getPixelPrecision` (pxDiff1 > 1) + const CASES = [ + // dataExtent pixelExtent precision1 precision2 diff1 diff2 + [ [ 0, 1e-3 ], [ 0, 100 ] ], // 1 0 + [ [ 0, 1e5 ], [ 0, 100 ] ], // 1 0 + [ [ 0, 816.2050883836147 ], [ 0, 914.7923109827166 ] ], // 1 0 + [ [ 0, 132.4279201671552 ], [ 0, 267.9399859644955 ] ], // 0 1 1.011644620055545 0.1011644620055545 + [ [ 0, 100.34020279327427 ], [ 0, 287.77043437322726 ] ], // 0 1 1.433973753103259 0.14339737531032593 + [ [ 0, 131.76288568225613 ], [ 0, 268.9583525845105 ] ], // 0 1 1.020614990298174 0.10206149902981741 + [ [ 0, 100.28571954202148 ], [ 0, 256.9972613326965 ] ], // 0 1 1.2813253098563555 0.12813253098563557 + [ [ 0, 104.20905450687412 ], [ 0, 301.06863468545566 ] ], // 0 1 1.4445416288926973 0.14445416288926974 + [ [ 0, 0.0000012212958760328775 ], [ 0, 30.161832948821356 ] ], // 7 8 1.234829067252553 0.12348290672525532 fail1 + [ [ 0, 1.0169256034269881e-7 ], [ 0, 293.5116394339741 ] ], // 9 10 1.4431323119648805 0.14431323119648806 fail1 + [ [ 0, 0.0011105264071798859 ], [ 0, 222.30675252167865 ] ], // 5 6 1.0009070972306418 0.10009070972306418 fail1 + [ [ 0, 0.00010498610084514804 ], [ 0, 264.6383246939843 ] ], // 6 7 1.260349334643447 0.1260349334643447 fail1 + ]; + const NAN_CASES = [ + [ [ 0, 0 ], [ 0, 100 ] ], + ]; + + // We require `diff * pxSpan / dataSpan <= pxDiffAcceptable`. + // The max `diff` is: `pow(10, -precision) / 2`. + function calcMaxPxDiff(dataExtent: number[], pxExtent: number[], precision: number): number { + return Math.pow(10, -precision) / 2 + * Math.abs(pxExtent[1] - pxExtent[0]) + / Math.abs(dataExtent[1] - dataExtent[0]); + } + + for (let idx = 0; idx < CASES.length; idx++) { + const caseItem = CASES[idx]; + const dataExtent = caseItem[0]; + const pxExtent = caseItem[1]; + const precision1 = getPixelPrecision( + dataExtent.slice() as [number, number], + pxExtent.slice() as [number, number] + ); + const precision2 = getAcceptablePrecision( + dataExtent[1] - dataExtent[0], + pxExtent[1] - pxExtent[0], + null + ); + const pxDiff1 = calcMaxPxDiff(dataExtent, pxExtent, precision1); + const pxDiff2 = calcMaxPxDiff(dataExtent, pxExtent, precision2); + expect(pxDiff1).toBeFinite(); // May > 1 (bad case). + expect(pxDiff2).toBeLessThanOrEqual(1); + + // if (precision1 > 1) { + // console.log( + // dataExtent, pxExtent, '---', + // precision1, precision2, pxDiff1, pxDiff2 + // ); + // } + } + + for (let idx = 0; idx < NAN_CASES.length; idx++) { + const caseItem = NAN_CASES[idx]; + const dataExtent = caseItem[0]; + const pxExtent = caseItem[1]; + const precision2 = getAcceptablePrecision( + dataExtent[1] - dataExtent[0], + pxExtent[1] - pxExtent[0], + null + ); + const pxDiff2 = calcMaxPxDiff(dataExtent, pxExtent, precision2); + expect(pxDiff2).toBeNaN(); + } + + }); + }); \ No newline at end of file