This is an automated email from the ASF dual-hosted git repository. sushuang pushed a commit to branch PR/plainheart_fix/alignTicks-precision in repository https://gitbox.apache.org/repos/asf/echarts.git
commit 8de2b64faa9d6f829ac2293d27920da76f47d5f5 Author: 100pah <[email protected]> AuthorDate: Fri Feb 27 15:19:46 2026 +0800 feature&fix(axis): (1) feature: Enable uniform bandWidth calculation in numeric axis (e.g., for tooltip shadow); it previously only applicable to category axis, but buggy in numeric axis with bar series. And enable the clip of tooltip shadow. (2) refactor: Introduce a dedicated workflow phase for series aggregation and data statistics computation on a single axis, allowting the results to be reused across multiple features. (3) fix: Fix duplicate ticks in TimeScale and customValues, which cause jitter of splitArea. (4) fix: Fix category showMin/MaxLabel handling when step > 1 and showMin/MaxLabel: false (5) chore: Tweak bad effects introduced by the previous implementation of SCALE_EXTENT_KIND_MAPPING. (6) chore: Clean some code. --- src/chart/bar/install.ts | 4 +- src/chart/bar/installPictorialBar.ts | 4 +- src/chart/line/LineView.ts | 2 +- src/chart/sankey/sankeyLayout.ts | 5 +- src/component/axis/AngleAxisView.ts | 6 +- src/component/axis/AxisBuilder.ts | 72 +++-- src/component/axis/axisSplitHelper.ts | 7 +- src/component/axisPointer/CartesianAxisPointer.ts | 18 +- src/component/axisPointer/axisTrigger.ts | 2 +- src/component/brush/preprocessor.ts | 15 +- src/component/helper/RoamController.ts | 2 +- src/component/matrix/MatrixView.ts | 2 +- src/component/timeline/SliderTimelineView.ts | 5 +- src/coord/Axis.ts | 24 +- src/coord/axisBand.ts | 159 +++++++++++ src/coord/axisCommonTypes.ts | 15 +- src/coord/axisDefault.ts | 6 +- src/coord/axisHelper.ts | 1 + src/coord/axisNiceTicks.ts | 5 +- src/coord/axisStatistics.ts | 262 +++++++++++++++++ src/coord/axisTickLabelBuilder.ts | 104 +++---- src/coord/cartesian/Grid.ts | 2 +- src/coord/matrix/Matrix.ts | 2 +- src/coord/scaleRawExtentInfo.ts | 2 +- src/core/CoordinateSystem.ts | 8 + src/core/echarts.ts | 6 +- src/data/DataStore.ts | 69 ++--- src/data/SeriesData.ts | 5 +- src/data/helper/createDimensions.ts | 30 +- src/data/helper/dataValueHelper.ts | 73 ++++- src/layout/barGrid.ts | 332 ++++++---------------- src/scale/Log.ts | 6 +- src/scale/Ordinal.ts | 4 + src/scale/Time.ts | 59 ++-- src/scale/breakImpl.ts | 4 + src/scale/scaleMapper.ts | 26 +- src/util/model.ts | 69 ++++- src/util/number.ts | 2 +- src/util/types.ts | 10 +- test/bar-overflow-time-plot.html | 3 + test/ut/spec/util/model.test.ts | 163 ++++++++++- 41 files changed, 1073 insertions(+), 522 deletions(-) diff --git a/src/chart/bar/install.ts b/src/chart/bar/install.ts index eefe3bca8..65ab60963 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, registerBarGridAxisContainShapeHandler} from '../../layout/barGrid'; +import {layout, createProgressiveLayout, registerBarGridAxisHandlers} from '../../layout/barGrid'; import dataSample from '../../processor/dataSample'; import BarSeries from './BarSeries'; @@ -67,5 +67,5 @@ export function install(registers: EChartsExtensionInstallRegisters) { ); }); - registerBarGridAxisContainShapeHandler(registers); + registerBarGridAxisHandlers(registers); } diff --git a/src/chart/bar/installPictorialBar.ts b/src/chart/bar/installPictorialBar.ts index 5c529444c..dc5ba41b6 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, registerBarGridAxisContainShapeHandler } from '../../layout/barGrid'; +import { createProgressiveLayout, layout, registerBarGridAxisHandlers } from '../../layout/barGrid'; import { curry } from 'zrender/src/core/util'; export function install(registers: EChartsExtensionInstallRegisters) { @@ -31,5 +31,5 @@ export function install(registers: EChartsExtensionInstallRegisters) { // Do layout after other overall layout, which can prepare some information. registers.registerLayout(registers.PRIORITY.VISUAL.PROGRESSIVE_LAYOUT, createProgressiveLayout('pictorialBar')); - registerBarGridAxisContainShapeHandler(registers); + registerBarGridAxisHandlers(registers); } diff --git a/src/chart/line/LineView.ts b/src/chart/line/LineView.ts index 3d59a7752..fd7934e52 100644 --- a/src/chart/line/LineView.ts +++ b/src/chart/line/LineView.ts @@ -399,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; }); diff --git a/src/chart/sankey/sankeyLayout.ts b/src/chart/sankey/sankeyLayout.ts index 8e9c8d22b..4b405925b 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/component/axis/AngleAxisView.ts b/src/component/axis/AngleAxisView.ts index a694c4e49..95dbb5900 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<typeof elementList[number], AngleAxisEle // Use length of ticksAngles because it may remove the last tick to avoid overlapping zrUtil.each(labels, function (labelItem, idx) { let labelModel = commonLabelModel; - const tickValue = labelItem.tickValue; + const tickValue = labelItem.tick.value; const r = radiusExtent[getRadiusIdx(polar)]; const p = polar.coordToPoint([r + labelMargin, labelItem.coord]); diff --git a/src/component/axis/AxisBuilder.ts b/src/component/axis/AxisBuilder.ts index d9a763a87..78fc96ea6 100644 --- a/src/component/axis/AxisBuilder.ts +++ b/src/component/axis/AxisBuilder.ts @@ -44,7 +44,8 @@ import { DimensionName, } from '../../util/types'; import { - AxisBaseOption, AxisBaseOptionCommon, AxisLabelBaseOptionNuance + AxisBaseOption, AxisBaseOptionCommon, AxisLabelBaseOptionNuance, + AxisShowMinMaxLabelOption, } from '../../coord/axisCommonTypes'; import type Element from 'zrender/src/Element'; import { PathProps, PathStyleProps } from 'zrender/src/graphic/Path'; @@ -70,9 +71,11 @@ import BoundingRect from 'zrender/src/core/BoundingRect'; import Point from 'zrender/src/core/Point'; import { copyTransform } from 'zrender/src/core/Transformable'; import { + AxisLabelInfoDetermined, AxisLabelsComputingContext, AxisTickLabelComputingKind, createAxisLabelsComputingContext } from '../../coord/axisTickLabelBuilder'; import { AxisTickCoord } from '../../coord/Axis'; +import { isTimeScale } from '../../scale/helper'; const PI = Math.PI; @@ -112,14 +115,13 @@ type AxisLabelText = graphic.Text & { } & ECElement; export const getLabelInner = makeInner<{ - break: VisualAxisBreak; - tickValue: number; + labelInfo: AxisLabelInfoDetermined; // Never be null/undefined. layoutRotation: number; }, graphic.Text>(); const getTickInner = makeInner<{ - onBand: AxisTickCoord['onBand'] - tickValue: AxisTickCoord['tickValue'] + onBand: AxisTickCoord['onBand']; + tickValue: AxisTickCoord['tickValue']; }, graphic.Line>(); @@ -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? @@ -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; + const labelItemTickBreakParsedBreak = labelItemTickBreak.parsedBreak; + if (labelItemTickBreak) { 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 7679bbd1e..9e4e610da 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/CartesianAxisPointer.ts b/src/component/axisPointer/CartesianAxisPointer.ts index 6b8b34423..325768563 100644 --- a/src/component/axisPointer/CartesianAxisPointer.ts +++ b/src/component/axisPointer/CartesianAxisPointer.ts @@ -27,6 +27,7 @@ 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 { isNullableNumberFinite, mathMax, mathMin } from '../../util/number'; // Not use top level axisPointer model type AxisPointerModel = Model<CommonAxisPointerOption>; @@ -105,8 +106,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]; @@ -156,13 +157,18 @@ 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]; + let bandWidth = axis.getBandWidth(); + const thisExtent = axis.getGlobalExtent(); + bandWidth = isNullableNumberFinite(bandWidth) + ? mathMax(1, bandWidth) : 1; + const otherSpan = otherExtent[1] - otherExtent[0]; + const thisX = mathMax(thisExtent[0], pixelValue - bandWidth / 2); + const thisW = mathMin(thisX + bandWidth, thisExtent[1]) - thisX; return { type: 'Rect', shape: viewHelper.makeRectShape( - [pixelValue - bandWidth / 2, otherExtent[0]], - [bandWidth, span], + [thisX, otherExtent[0]], + [thisW, otherSpan], getAxisDimIndex(axis) ) }; diff --git a/src/component/axisPointer/axisTrigger.ts b/src/component/axisPointer/axisTrigger.ts index 326786d1a..8cc2457a1 100644 --- a/src/component/axisPointer/axisTrigger.ts +++ b/src/component/axisPointer/axisTrigger.ts @@ -368,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. diff --git a/src/component/brush/preprocessor.ts b/src/component/brush/preprocessor.ts index 5bbbfc8cc..4c55cf319 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<number>; - 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/helper/RoamController.ts b/src/component/helper/RoamController.ts index 4c057412e..2a87b993c 100644 --- a/src/component/helper/RoamController.ts +++ b/src/component/helper/RoamController.ts @@ -180,7 +180,7 @@ class RoamController extends Eventful<RoamEventDefinition> { 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/matrix/MatrixView.ts b/src/component/matrix/MatrixView.ts index ae5ff13ce..ce2e45ade 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/timeline/SliderTimelineView.ts b/src/component/timeline/SliderTimelineView.ts index 8bd27e613..371e5456e 100644 --- a/src/component/timeline/SliderTimelineView.ts +++ b/src/component/timeline/SliderTimelineView.ts @@ -43,7 +43,6 @@ import { enableHoverEmphasis } from '../../util/states'; import { createTooltipMarkup } from '../tooltip/tooltipMarkup'; import Displayable from 'zrender/src/graphic/Displayable'; import { createScaleByModel } from '../../coord/axisHelper'; -import { OptionAxisType } from '../../coord/axisCommonTypes'; import { scaleCalcNiceDirectly } from '../../coord/axisNiceTicks'; const PI = Math.PI; @@ -473,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<TimelineDataItemOption>(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, diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index d36018834..b0370028f 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -28,12 +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 { AxisBandWidthResult, calcBandWidth } from './axisBand'; const NORMALIZED_EXTENT = [0, 1] as [number, number]; @@ -83,7 +84,7 @@ class Axis { inverse: AxisBaseOption['inverse'] = false; // To be injected outside. May change - do not use it outside of echarts. - __alignTo: Axis; + __alignTo: Axis | NullUndefined; constructor(dim: DimensionName, scale: Scale, extent: [number, number]) { @@ -241,19 +242,12 @@ class Axis { } /** - * Get width of band + * NOTICE: Can only be called after `adoptBandWidth` being called in `CoordinateSystem#update` stage. */ 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; + calcBandWidth(tmpOutBandWidth, this); + // NOTICE: Do not add logic here. Implement everthing in `calcBandWidth`. + return tmpOutBandWidth.bandWidth; } /** @@ -264,7 +258,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); @@ -273,6 +267,8 @@ class Axis { } +const tmpOutBandWidth: AxisBandWidthResult = {}; + function makeExtentWithBands(axis: Axis): number[] { const extent = axis.getExtent(); if (axis.onBand) { diff --git a/src/coord/axisBand.ts b/src/coord/axisBand.ts new file mode 100644 index 000000000..95edb208f --- /dev/null +++ b/src/coord/axisBand.ts @@ -0,0 +1,159 @@ +/* +* 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 { 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 } from '../util/number'; +import { getAxisStatistics, getAxisStatisticsKeys } from './axisStatistics'; +import { getScaleLinearSpanForMapping } from '../scale/scaleMapper'; + + +// Arbitrary, leave some space to avoid overflowing when dataZoom moving. +const SINGULAR_BAND_WIDTH_RATIO = 0.7; + +export type AxisBandWidthResult = { + // In px. May be NaN/null/undefined if no meaningfull bandWidth. + bandWidth?: number | NullUndefined; + kind?: AxisBandWidthKind; + // If `AXIS_BAND_WIDTH_KIND_NORMAL`, this is a ratio from px span to data span, exists only if not singular. + // If `AXIS_BAND_WIDTH_KIND_SINGULAR`, no need any ratio. + ratio?: number | NullUndefined; +}; + +export type AxisBandWidthKind = + // NullUndefined means no bandWidth, typically due to no series data. + NullUndefined + | typeof AXIS_BAND_WIDTH_KIND_SINGULAR + | typeof AXIS_BAND_WIDTH_KIND_NORMAL; +export const AXIS_BAND_WIDTH_KIND_SINGULAR = 1; +export const AXIS_BAND_WIDTH_KIND_NORMAL = 2; + +/** + * NOTICE: + * Require the axis pixel extent and the scale extent as inputs. But they + * can be not precise for approximation. + * + * 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: + * - bar series group band width; + * - tooltip axisPointer type "shadow"; + * - etc. + */ +export function calcBandWidth( + out: AxisBandWidthResult, + axis: Axis +): void { + // Clear out. + out.bandWidth = out.ratio = out.kind = undefined; + + const scale = axis.scale; + + if (isOrdinalScale(scale) + || !calcBandWidthForNumericAxisIfPossible(out, axis, scale) + ) { + calcBandWidthForCategoryAxisOrFallback(out, axis, scale); + } +} + +/** + * Only reasonable on 'category'. + * + * It can be used as a fallback, as it does not produce a significant negative impact + * on non-category axes. + */ +function calcBandWidthForCategoryAxisOrFallback( + 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); + + const size = Math.abs(axisExtent[1] - axisExtent[0]); + + out.bandWidth = Math.abs(size) / len; +} + +function calcBandWidthForNumericAxisIfPossible( + out: AxisBandWidthResult, + axis: Axis, + scale: Scale, + // A falsy return indicates this method is not applicable - a fallback is needed. +): boolean { + // PENDING: Theoretically, for 'value'/'time'/'log' axis, `bandWidth` should be derived from + // series data and may vary per data items. However, we currently only derive `bandWidth` + // per serise, regardless of individual data items, until concrete requirements arise. + // Therefore, we arbitrarily choose a minimal `bandWidth` to avoid overlap if multiple + // irrelevant series reside on one axis. + let hasStat: boolean; + let linearPositiveMinGap = Infinity; + each(getAxisStatisticsKeys(axis), function (axisStatKey) { + const liMinGap = getAxisStatistics(axis, axisStatKey).linearPositiveMinGap; + if (liMinGap != null) { + hasStat = true; + if (isNullableNumberFinite(liMinGap) && liMinGap < linearPositiveMinGap) { + linearPositiveMinGap = liMinGap; + } + } + }); + if (!hasStat) { + return false; + } + + let bandWidth: number | NullUndefined; + let kind: AxisBandWidthKind | NullUndefined; + let ratio: number | NullUndefined; + + 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(linearPositiveMinGap) + ) { + bandWidth = pxSpan / linearScaleSpan * linearPositiveMinGap; + ratio = linearScaleSpan / pxSpan; + kind = AXIS_BAND_WIDTH_KIND_NORMAL; + } + else { + bandWidth = pxSpan * SINGULAR_BAND_WIDTH_RATIO; + kind = AXIS_BAND_WIDTH_KIND_SINGULAR; + } + + out.bandWidth = bandWidth; + out.kind = kind; + out.ratio = ratio; + + return true; +} diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index 229290dac..32b196d1b 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -260,7 +260,7 @@ interface AxisTickOption { // The length of axisTick. length?: number, lineStyle?: LineStyleOption, - customValues?: (number | string | Date)[] + customValues?: AxisTickLabelCustomValuesOption } export type AxisLabelValueFormatter = ( @@ -326,10 +326,8 @@ interface AxisLabelBaseOption extends LabelCommonOption<AxisLabelBaseOptionNuanc // Whether axisLabel is inside the grid or outside the grid. inside?: boolean, rotate?: number, - // true | false | null/undefined (auto) - showMinLabel?: boolean, - // true | false | null/undefined (auto) - showMaxLabel?: boolean, + showMinLabel?: AxisShowMinMaxLabelOption, + showMaxLabel?: AxisShowMinMaxLabelOption, // 'left' | 'center' | 'right' | null/undefined (auto) alignMinLabel?: TextAlign, // 'left' | 'center' | 'right' | null/undefined (auto) @@ -345,9 +343,14 @@ interface AxisLabelBaseOption extends LabelCommonOption<AxisLabelBaseOptionNuanc * If hide overlapping labels. */ hideOverlap?: boolean, - customValues?: (number | string | Date)[], + customValues?: AxisTickLabelCustomValuesOption, } +// true | false | null/undefined (auto) +export type AxisShowMinMaxLabelOption = boolean | NullUndefined; + +export type AxisTickLabelCustomValuesOption = (number | string | Date)[]; + interface AxisLabelOption<TType extends OptionAxisType> extends AxisLabelBaseOption { formatter?: LabelFormatters[TType] interval?: TType extends 'category' diff --git a/src/coord/axisDefault.ts b/src/coord/axisDefault.ts index c30d59f90..ddb33ee33 100644 --- a/src/coord/axisDefault.ts +++ b/src/coord/axisDefault.ts @@ -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 36fbb39ab..8739c5ebf 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -38,6 +38,7 @@ import { AxisLabelFormatterExtraParams, OptionAxisType, AXIS_TYPES, + AxisShowMinMaxLabelOption, } from './axisCommonTypes'; import SeriesData from '../data/SeriesData'; import { getStackedDimension } from '../data/helper/dataStackHelper'; diff --git a/src/coord/axisNiceTicks.ts b/src/coord/axisNiceTicks.ts index 856a7ff2d..186459e8a 100644 --- a/src/coord/axisNiceTicks.ts +++ b/src/coord/axisNiceTicks.ts @@ -46,7 +46,7 @@ import type Axis from './Axis'; // ------ START: LinearIntervalScaleStub Nice ------ function calcNiceForIntervalOrLogScale( - scale: IntervalScale | LogScale, + scale: (IntervalScale | LogScale) & Scale, opt: ScaleCalcNiceMethodOpt, ): void { // [CAVEAT]: If updating this impl, need to sync it to `axisAlignTicks.ts`. @@ -187,6 +187,9 @@ type ScaleCalcNiceMethodOpt = { /** * 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: { diff --git a/src/coord/axisStatistics.ts b/src/coord/axisStatistics.ts new file mode 100644 index 000000000..c677a4337 --- /dev/null +++ b/src/coord/axisStatistics.ts @@ -0,0 +1,262 @@ +/* +* 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, clone, createHashMap, each, HashMap } from 'zrender/src/core/util'; +import type GlobalModel from '../model/Global'; +import type SeriesModel from '../model/Series'; +import { + extentHasValue, getCachePerECFullUpdate, GlobalModelCachePerECFullUpdate, + initExtentForUnion, makeInner, +} from '../util/model'; +import { NullUndefined } from '../util/types'; +import type Axis from './Axis'; +import { asc, isNullableNumberFinite, mathMin } from '../util/number'; +import { registerPerformAxisStatistics } from '../core/CoordinateSystem'; +import { parseSanitizationFilter, passesSanitizationFilter } from '../data/helper/dataValueHelper'; + + +const ecModelCacheInner = makeInner<{ + axes: Axis[]; +}, GlobalModelCachePerECFullUpdate>(); +type AxisStatisticsStore = { + stat: AxisStatisticsPerAxis | NullUndefined; + // For duplication checking. + added: boolean +}; +const axisInner = makeInner<AxisStatisticsStore, Axis>(); + +export type AxisStatisticsClient = { + collectAxisSeries: ( + ecModel: GlobalModel, + saveAxisSeries: (axis: Axis, series: SeriesModel) => void + ) => void; + getMetrics: ( + axis: Axis, + ) => AxisStatisticsMetrics; +}; + +/** + * Nominal to avoid misusing. + * Sample usage: + * function axisStatKey(seriesType: ComponentSubType): AxisStatisticsKey { + * return `xxx-${seriesType}` as AxisStatisticsKey; + * } + */ +export type AxisStatisticsKey = string & {_: 'AxisStatisticsKey'}; + +type AxisStatisticsMetrics = { + // Currently only one metric is required. + // NOTICE: + // May be time-consuming due to some metrics requiring travel and sort of series data, + // especially when axis break is used, so it is performed only if required. + minGap?: boolean +}; + +type AxisStatisticsPerAxis = HashMap<AxisStatisticsPerAxisPerKey, AxisStatisticsKey>; + +type AxisStatisticsPerAxisPerKey = { + // Mark that any statistics has been performed in this record. Also for duplication checking. + added?: boolean + // This is series use this axis as base axis and need to be laid out. + sers: SeriesModel[]; + // Minimal positive gap of values of all relevant series (e.g. per `BaseBarSeriesSubType`) on this axis. + // Be `NaN` if no valid data item or only one valid data item. + // Be `null`/`undefined` if this statistics is not performed. + linearPositiveMinGap?: number | NullUndefined; + // min/max of values of all relevant series (e.g. per `BaseBarSeriesSubType`) on this axis. + // Be `null`/`undefined` if this statistics is not performed, + // otherwise it is an array, but may contain `NaN` if no valid data. + linearValueExtent?: number[] | NullUndefined; +}; + +export type AxisStatisticsResult = Pick< + AxisStatisticsPerAxisPerKey, + 'linearPositiveMinGap' | 'linearValueExtent' +>; + +function ensureAxisStatisticsPerAxisPerKey( + axisStore: AxisStatisticsStore, axisStatKey: AxisStatisticsKey +): AxisStatisticsPerAxisPerKey { + if (__DEV__) { + assert(axisStatKey != null); + } + const stat = axisStore.stat || (axisStore.stat = createHashMap()); + return stat.get(axisStatKey) + || stat.set(axisStatKey, {sers: []}); +} + +export function getAxisStatistics( + axis: Axis, + axisStatKey: AxisStatisticsKey + // Never return null/undefined. +): AxisStatisticsResult { + const record = ensureAxisStatisticsPerAxisPerKey(axisInner(axis), axisStatKey); + return { + linearPositiveMinGap: record.linearPositiveMinGap, + linearValueExtent: clone(record.linearValueExtent), + }; +} + +export function getAxisStatisticsKeys( + axis: Axis +): AxisStatisticsKey[] { + const stat = axisInner(axis).stat; + return stat ? stat.keys() : []; +} + +export function eachCollectedSeries( + axis: Axis, + axisStatKey: AxisStatisticsKey, + cb: (series: SeriesModel) => void +): void { + each(ensureAxisStatisticsPerAxisPerKey(axisInner(axis), axisStatKey).sers, cb); +} + +export function getCollectedSeriesLength( + axis: Axis, + axisStatKey: AxisStatisticsKey, +): number { + return ensureAxisStatisticsPerAxisPerKey(axisInner(axis), axisStatKey).sers.length; +} + +export function eachCollectedAxis( + ecModel: GlobalModel, + cb: (axis: Axis) => void +): void { + each(ecModelCacheInner(getCachePerECFullUpdate(ecModel)).axes, cb); +} + +/** + * Perform statistics if required. + */ +function performAxisStatisticsImpl(ecModel: GlobalModel): void { + const ecCache = ecModelCacheInner(getCachePerECFullUpdate(ecModel)); + const distinctAxes: Axis[] = ecCache.axes = []; + + const records: AxisStatisticsPerAxisPerKey[] = []; + const recordAxes: Axis[] = []; + const recordMetricsList: AxisStatisticsMetrics[] = []; + + axisStatisticsClients.each(function (client, axisStatKey) { + client.collectAxisSeries( + ecModel, + function saveAxisSeries(axis, series): void { + const axisStore = axisInner(axis); + if (!axisStore.added) { + axisStore.added = true; + distinctAxes.push(axis); + } + const record = ensureAxisStatisticsPerAxisPerKey(axisStore, axisStatKey); + if (!record.added) { + record.added = true; + records.push(record); + recordAxes.push(axis); + recordMetricsList.push(client.getMetrics(axis) || {}); + } + // NOTICE: series order should respect to the input order, + // since it matters in some cases (see `barGrid`). + record.sers.push(series); + } + ); + }); + + each(records, function (record, idx) { + performStatisticsForRecord(record, recordMetricsList[idx], recordAxes[idx]); + }); +} + +function performStatisticsForRecord( + record: AxisStatisticsPerAxisPerKey, + metrics: AxisStatisticsMetrics, + axis: Axis, +): void { + if (!metrics.minGap) { + return; + } + + const linearValueExtent = initExtentForUnion(); + const scale = axis.scale; + const needTransform = scale.needTransform(); + const filter = scale.getFilter ? scale.getFilter() : null; + const filterParsed = parseSanitizationFilter(filter); + let valIdx = 0; + + each(record.sers, function (seriesModel) { + const data = seriesModel.getData(); + const dimIdx = data.getDimensionIndex(data.mapDimension(axis.dim)); + const store = data.getStore(); + + 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(dimIdx, 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); + } + tmpStaticPSFRValues[valIdx++] = val; + val < linearValueExtent[0] && (linearValueExtent[0] = val); + val > linearValueExtent[1] && (linearValueExtent[1] = val); + } + } + }); + tmpStaticPSFRValues.length = valIdx; + + // Sort axis values into ascending order to calculate gaps + asc(tmpStaticPSFRValues); + + let min = Infinity; + for (let j = 1; j < valIdx; ++j) { + const delta = tmpStaticPSFRValues[j] - tmpStaticPSFRValues[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 + ) { + min = mathMin(min, delta); + } + } + + record.linearPositiveMinGap = isNullableNumberFinite(min) + ? min + : NaN; // No valid data item or single valid data item. + if (!extentHasValue(linearValueExtent)) { + linearValueExtent[0] = linearValueExtent[1] = NaN; // No valid data. + } + record.linearValueExtent = linearValueExtent; +} +const tmpStaticPSFRValues: number[] = []; // A quick performance optimization. + +export function requireAxisStatistics( + axisStatKey: AxisStatisticsKey, + client: AxisStatisticsClient +): void { + if (__DEV__) { + assert(!axisStatisticsClients.get(axisStatKey)); + } + + registerPerformAxisStatistics(performAxisStatisticsImpl); + axisStatisticsClients.set(axisStatKey, client); +} + +const axisStatisticsClients: HashMap<AxisStatisticsClient, AxisStatisticsKey> = createHashMap(); diff --git a/src/coord/axisTickLabelBuilder.ts b/src/coord/axisTickLabelBuilder.ts index ec26ce6cf..5fdd20941 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<TKey, TVal> = { @@ -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<typeof createAxisLabels> { @@ -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<typeof createAxisLabels> { return { formattedLabel: labelFormatter(tick, idx), rawLabel: axis.scale.getLabel(tick), - tickValue: tick.value, - time: tick.time, - break: tick.break, + tick: tick, }; }) }; @@ -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/Grid.ts b/src/coord/cartesian/Grid.ts index 1c18aae1a..698b309d8 100644 --- a/src/coord/cartesian/Grid.ts +++ b/src/coord/cartesian/Grid.ts @@ -851,7 +851,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); diff --git a/src/coord/matrix/Matrix.ts b/src/coord/matrix/Matrix.ts index 59cd7ecc9..3e9bfacf3 100644 --- a/src/coord/matrix/Matrix.ts +++ b/src/coord/matrix/Matrix.ts @@ -489,7 +489,7 @@ type CtxPointToData = { y: CtxPointToDataAreaType | NullUndefined; point: number[]; // If clamp required, this point is clamped after prepared. }; -// For handy performance optimization in pointToData. +// For quick performance optimization in pointToData. const _tmpCtxPointToData: CtxPointToData = {x: null, y: null, point: []}; function pointToDataOneDimPrepareCtx( diff --git a/src/coord/scaleRawExtentInfo.ts b/src/coord/scaleRawExtentInfo.ts index 628ea1b1f..1579d2452 100644 --- a/src/coord/scaleRawExtentInfo.ts +++ b/src/coord/scaleRawExtentInfo.ts @@ -642,7 +642,7 @@ function scaleRawExtentInfoReallyCreateDeal( // NOTE: This data may have been filtered by dataZoom on orthogonal axes. const data = seriesModel.getData(); if (data) { - const filter = scale.getSeriesExtentFilter ? scale.getSeriesExtentFilter() : null; + const filter = scale.getFilter ? scale.getFilter() : null; each(getDataDimensionsOnAxis(data, axisDim), function (dim) { unionExtentFromExtent(extent, data.getApproximateExtent(dim, filter)); }); diff --git a/src/core/CoordinateSystem.ts b/src/core/CoordinateSystem.ts index 5979adc85..1f247fe6f 100644 --- a/src/core/CoordinateSystem.ts +++ b/src/core/CoordinateSystem.ts @@ -28,6 +28,7 @@ import SeriesModel from '../model/Series'; import { error } from '../util/log'; import { CoordinateSystemDataCoord, NullUndefined } from '../util/types'; + type CoordinateSystemCreatorMap = {[type: string]: CoordinateSystemCreator}; /** @@ -59,6 +60,8 @@ class CoordinateSystemManager { this._nonSeriesBoxMasterList = dealCreate(nonSeriesBoxCoordSysCreators, true); this._normalMasterList = dealCreate(normalCoordSysCreators, false); + performAxisStatistics && performAxisStatistics(ecModel); + function dealCreate(creatorMap: CoordinateSystemCreatorMap, canBeNonSeriesBox: boolean) { let coordinateSystems: CoordinateSystemMaster[] = []; zrUtil.each(creatorMap, function (creator, type) { @@ -356,5 +359,10 @@ export const simpleCoordSysInjectionProvider: CoordSysInjectionProvider = functi return coordSysModel && coordSysModel.coordinateSystem; }; +let performAxisStatistics: ((ecModel: GlobalModel) => void) | NullUndefined; +// To reduce code size, the implementation of `performAxisStatistics` is registered only when needed. +export function registerPerformAxisStatistics(impl: typeof performAxisStatistics): void { + performAxisStatistics = impl; +} export default CoordinateSystemManager; diff --git a/src/core/echarts.ts b/src/core/echarts.ts index 18efa5c25..e67e49dd6 100644 --- a/src/core/echarts.ts +++ b/src/core/echarts.ts @@ -160,10 +160,9 @@ const PRIORITY_PROCESSOR_DATASTACK = 900; // `PRIORITY_PROCESSOR_FILTER` is typically used by `dataZoom` (see `AxisProxy`), which relies // on the initialized "axis extent". const PRIORITY_PROCESSOR_FILTER = 1000; -// NOTICE: These "data processors" (especially, data filters) above may block the stream, so they -// should be put at the beginning of data processing. const PRIORITY_PROCESSOR_DEFAULT = 2000; const PRIORITY_PROCESSOR_STATISTIC = 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; @@ -2928,6 +2927,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/data/DataStore.ts b/src/data/DataStore.ts index f0280d06a..c9b7f932c 100644 --- a/src/data/DataStore.ts +++ b/src/data/DataStore.ts @@ -27,10 +27,13 @@ import { ParsedValueNumeric } 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 */ @@ -73,9 +76,6 @@ type FilterCb = (...args: any) => boolean; // type MapArrayCb = (...args: any) => any; type MapCb = (...args: any) => ParsedValue | ParsedValue[]; -// g: greater than, ge: greater equal, l: less than, le: less equal -export type DataStoreExtentFilter = {g?: number; ge?: number; l?: number; le?: number;}; - export type DimValueGetter = ( this: DataStore, dataItem: any, @@ -509,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)) { @@ -519,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; } /** @@ -1134,7 +1132,7 @@ class DataStore { getDataExtent( dim: DimensionIndex, - filter: DataStoreExtentFilter | NullUndefined + filter: DataSanitizationFilter | NullUndefined ): [number, number] { // Make sure use concrete dim as cache name. const dimData = this._chunks[dim]; @@ -1165,29 +1163,10 @@ class DataStore { const thisExtent = this._extent; const dimExtentRecord = thisExtent[dim] || (thisExtent[dim] = {}); - 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; - } - } + + const filterParsed = parseSanitizationFilter(filter); + const filterKey = filterParsed.key; + const dimExtent = dimExtentRecord[filterKey]; if (dimExtent) { return dimExtent.slice() as [number, number]; @@ -1196,24 +1175,18 @@ class DataStore { let min = initialExtent[0]; let max = initialExtent[1]; - // NOTICE: Performance sensitive on large data. 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; - if (filter) { - if (value <= filterG - || value < filterGE - || value >= filterL - || value > filterLE - ) { - continue; + // NOTE: in most cases, filter does not exist. + if (!filter || passesSanitizationFilter(filterParsed, value)) { + if (value < min) { + min = value; + } + if (value > max) { + max = value; } - } - if (value < min) { - min = value; - } - if (value > max) { - max = value; } } diff --git a/src/data/SeriesData.ts b/src/data/SeriesData.ts index ec8bc0b15..1cfde0407 100644 --- a/src/data/SeriesData.ts +++ b/src/data/SeriesData.ts @@ -44,8 +44,9 @@ import type Tree from './Tree'; import type { VisualMeta } from '../component/visualMap/VisualMapModel'; import {isSourceInstance, Source} from './Source'; import { LineStyleProps } from '../model/mixin/lineStyle'; -import DataStore, { DataStoreDimensionDefine, DataStoreExtentFilter, DimValueGetter } from './DataStore'; +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; @@ -679,7 +680,7 @@ class SeriesData< */ getApproximateExtent( dim: SeriesDimensionLoose, - filter: DataStoreExtentFilter | NullUndefined + filter: DataSanitizationFilter | NullUndefined ): [number, number] { return this._approximateExtent[dim] || this._store.getDataExtent(this._getStoreDimIndex(dim), filter); } diff --git a/src/data/helper/createDimensions.ts b/src/data/helper/createDimensions.ts index b8f8e8a9e..ba88b6aa4 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<number>(); - 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/dataValueHelper.ts b/src/data/helper/dataValueHelper.ts index 18764920f..a45b90445 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/layout/barGrid.ts b/src/layout/barGrid.ts index 88c033600..6d1616fc1 100644 --- a/src/layout/barGrid.ts +++ b/src/layout/barGrid.ts @@ -17,7 +17,7 @@ * under the License. */ -import { each, defaults, hasOwn, assert } from 'zrender/src/core/util'; +import { each, defaults, hasOwn } 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'; @@ -28,13 +28,13 @@ import { StageHandler, NullUndefined } from '../util/types'; import { createFloat32Array } from '../util/vendor'; import { extentHasValue, - getCachePerECFullUpdate, GlobalModelCachePerECFullUpdate, initExtentForUnion, - isValidNumberForExtent, makeCallOnlyOnce, makeInner, + initExtentForUnion, + makeCallOnlyOnce, unionExtentFromNumber, } from '../util/model'; import { isOrdinalScale } from '../scale/helper'; import { - CartesianAxisHashKey, getCartesianAxisHashKey, isCartesian2DInjectedAsDataCoordSys + isCartesian2DInjectedAsDataCoordSys } from '../coord/cartesian/cartesianAxisHelper'; import type BaseBarSeriesModel from '../chart/bar/BaseBarSeries'; import type BarSeriesModel from '../chart/bar/BarSeries'; @@ -42,47 +42,19 @@ import { AxisContainShapeHandler, registerAxisContainShapeHandler, } from '../coord/scaleRawExtentInfo'; import { EChartsExtensionInstallRegisters } from '../extension'; -import { getScaleLinearSpanForMapping } from '../scale/scaleMapper'; -import type Scale from '../scale/Scale'; - - -const ecModelCacheInner = makeInner<{ - layoutPre: BarGridLayoutPre; -}, GlobalModelCachePerECFullUpdate>(); +import { + AxisStatisticsClient, AxisStatisticsKey, eachCollectedAxis, + eachCollectedSeries, getCollectedSeriesLength, requireAxisStatistics +} from '../coord/axisStatistics'; +import { + AXIS_BAND_WIDTH_KIND_NORMAL, AxisBandWidthResult, calcBandWidth +} from '../coord/axisBand'; -// Record of layout preparation by series sub type. -type BarGridLayoutPre = Partial<Record<BaseBarSeriesSubType, BarGridLayoutPreOnSeriesType>>; -type BarGridLayoutPreOnSeriesType = { - seriesReady: boolean; - // NOTICE: `axes` and `axisMap` do not necessarily contain all Cartesian axes - a record - // is created iff `ensureLayoutAxisPre` is called. - axes: CartesianAxisHashKey[]; - axisMap: Record<CartesianAxisHashKey, BarGridLayoutAxisPre>; -}; - -// Record of layout preparation by series sub type by axis. -type BarGridLayoutAxisPre = { - axis: Axis2D; - // This is series use this axis as base axis and need to be laid out. - seriesList: BaseBarSeriesModel[]; - // Statistics on values for `minGap` and `linearValueExtent` has been ready. - valStatReady?: boolean; - linearMinGap?: number | NullUndefined; - // min/max of values of all bar series (per `BaseBarSeriesSubType`) on this axis, - // but other series types are not included. - // Only available for non-'category' axis. - // If no valid data, remains `undefined`. - linearValueExtent?: number[] | NullUndefined; -}; +const callOnlyOnce = makeCallOnlyOnce(); const STACK_PREFIX = '__ec_stack_'; -// Arbitrary, leave some space to avoid overflowing when dataZoom moving. -const SINGULAR_BAND_WIDTH_RATIO = 0.8; -// Corresponding to `SINGULAR_BAND_WIDTH_RATIO`, but they are not necessarily equal on other value choices. -const SINGULAR_SUPPLEMENT_RATIO = 0.8; - function getSeriesStackId(seriesModel: BaseBarSeriesModel): string { return (seriesModel as BarSeriesModel).get('stack') || STACK_PREFIX + seriesModel.seriesIndex; } @@ -90,10 +62,7 @@ function getSeriesStackId(seriesModel: BaseBarSeriesModel): string { interface BarGridLayoutAxisInfo { seriesInfo: BarGridLayoutAxisSeriesInfo[]; // Calculated layout width for a single bars group. - bandWidth: number; - singular?: boolean; - linearValueExtent?: BarGridLayoutAxisPre['linearValueExtent']; - pxToDataRatio?: number | NullUndefined; + bandWidthResult: AxisBandWidthResult; } interface BarGridLayoutAxisSeriesInfo { @@ -132,7 +101,7 @@ export type BarGridColumnLayoutOnAxis = BarGridLayoutAxisInfo & { }; type BarGridLayoutResultItemInternal = { - bandWidth: BarGridLayoutAxisInfo['bandWidth'] + bandWidth: BarGridLayoutAxisInfo['bandWidthResult']['bandWidth'] offset: number // An offset with respect to `dataToPoint` width: number }; @@ -146,12 +115,8 @@ export type BarGridLayoutResultForCustomSeries = BarGridLayoutResultItem[] | Nul */ export function getLayoutOnAxis(opt: BarGridLayoutOption): BarGridLayoutResultForCustomSeries { const params: BarGridLayoutAxisSeriesInfo[] = []; - const baseAxis = opt.axis; - - if (baseAxis.type !== 'category') { - return; - } - const bandWidth = baseAxis.getBandWidth(); + const bandWidthResult: AxisBandWidthResult = {}; + calcBandWidth(bandWidthResult, opt.axis); for (let i = 0; i < opt.count || 0; i++) { params.push(defaults({ @@ -159,7 +124,7 @@ export function getLayoutOnAxis(opt: BarGridLayoutOption): BarGridLayoutResultFo }, opt) as BarGridLayoutAxisSeriesInfo); } const widthAndOffsets = calcBarWidthAndOffset({ - bandWidth, + bandWidthResult, seriesInfo: params, }); @@ -173,130 +138,6 @@ export function getLayoutOnAxis(opt: BarGridLayoutOption): BarGridLayoutResultFo return result; } -function ensureLayoutPre( - ecModel: GlobalModel, seriesType: BaseBarSeriesSubType -): BarGridLayoutPreOnSeriesType { - const ecCache = ecModelCacheInner(getCachePerECFullUpdate(ecModel)); - const layoutPre = ecCache.layoutPre || (ecCache.layoutPre = {}); - return layoutPre[seriesType] || (layoutPre[seriesType] = { - axes: [], axisMap: {}, seriesReady: false - }); -} - -function ensureLayoutAxisPre( - layoutPre: BarGridLayoutPreOnSeriesType, axis: Axis2D -): BarGridLayoutAxisPre { - const axisKey = getCartesianAxisHashKey(axis); - const axisMap = layoutPre.axisMap || (layoutPre.axisMap = {}); - let axisPre = axisMap[axisKey]; - if (!axisPre) { - layoutPre.axes.push(axisKey); - axisPre = axisMap[axisKey] = { - axis, - seriesList: [], - }; - } - return axisPre; -} - -function eachAxisPre( - layoutPre: BarGridLayoutPreOnSeriesType, cb: (axisPre: BarGridLayoutAxisPre) => void -): void { - each(layoutPre.axes, function (axisKey) { - cb(layoutPre.axisMap[axisKey]); - }); -} - -/** - * NOTICE: - * - Ensure the idempotent on this function - it may be called multiple times in a run - * of ec workflow. - * - Not a pure function - `seriesListByType` will be cached on base axis instance - * to avoid duplicated travel of series for each axis. - * - The order of series matters - must be respected to the declaration on ec option, - * because for historical reason, the last series holds the effective ec option. - * See `calcBarWidthAndOffset`. - */ -function ensureBarGridSeriesList( - ecModel: GlobalModel, seriesType: BaseBarSeriesSubType -): BarGridLayoutPreOnSeriesType { - const layoutPre = ensureLayoutPre(ecModel, seriesType); - if (layoutPre.seriesReady) { - return layoutPre; - } - ecModel.eachSeriesByType(seriesType, function (seriesModel: BaseBarSeriesModel) { - if (isCartesian2DInjectedAsDataCoordSys(seriesModel)) { - const baseAxis = (seriesModel.coordinateSystem as Cartesian2D).getBaseAxis(); - ensureLayoutAxisPre(layoutPre, baseAxis).seriesList.push(seriesModel); - } - }); - layoutPre.seriesReady = true; - return layoutPre; -} - -/** - * CAVEAT: Time-consuming due to the travel and sort of series data. - * - * 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. - */ -function ensureValuesStatisticsOnAxis( - axis: Axis2D, layoutPre: BarGridLayoutPreOnSeriesType -): BarGridLayoutAxisPre { - if (__DEV__) { - assert(!isOrdinalScale(axis.scale)); - } - - const axisPre = ensureLayoutAxisPre(layoutPre, axis); - // `minGap` is cached for performance, otherwise data will be traveled more than once - // in each run of ec workflow. The first creation is during coord sys update stage to - // expand the scale extent of the base axis to avoid edge bars overflowing the axis. - // And then in render stage. - if (axisPre.valStatReady) { - return axisPre; - } - - const scale = axis.scale; - const values: number[] = []; - const linearValueExtent = initExtentForUnion(); - each(axisPre.seriesList, function (seriesModel) { - const data = seriesModel.getData(); - const dimIdx = data.getDimensionIndex(data.mapDimension(axis.dim)); - const store = data.getStore(); - for (let i = 0, cnt = store.count(); i < cnt; ++i) { - const val = scale.transformIn(store.get(dimIdx, i) as number, null); - if (isValidNumberForExtent(val)) { // This also filters out `log(non-positive)` for LogScale. - values.push(val); - unionExtentFromNumber(linearValueExtent, val); - } - } - }); - - // Sort axis values into ascending order to calculate gaps - values.sort(function (a, b) { - return a - b; - }); - let min = null; - for (let j = 1; j < values.length; ++j) { - const delta = values[j] - values[j - 1]; - if (delta > 0) { - // Ignore 0 delta because they are of the same axis value - min = min === null ? delta : mathMin(min, delta); - } - } - axisPre.linearMinGap = min; // Set to null if only have one data - if (extentHasValue(linearValueExtent)) { - axisPre.linearValueExtent = linearValueExtent; // Remain `undefined` if no valid data - } - axisPre.valStatReady = true; - - return axisPre; -} - /** * 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 @@ -304,12 +145,11 @@ function ensureValuesStatisticsOnAxis( * axis pixel extent and scale extent may be changed finally. */ function makeColumnLayoutOnAxisReal( - layoutPre: BarGridLayoutPreOnSeriesType, baseAxis: Axis2D, + seriesType: BaseBarSeriesSubType ): BarGridColumnLayoutOnAxis { - const axisPre = ensureLayoutAxisPre(layoutPre, baseAxis); const seriesInfoListOnAxis = createLayoutInfoListOnAxis( - axisPre.axis, layoutPre, axisPre + baseAxis, seriesType ) as BarGridColumnLayoutOnAxis; seriesInfoListOnAxis.columnMap = calcBarWidthAndOffset(seriesInfoListOnAxis); return seriesInfoListOnAxis; @@ -317,41 +157,15 @@ function makeColumnLayoutOnAxisReal( function createLayoutInfoListOnAxis( baseAxis: Axis2D, - layoutPre: BarGridLayoutPreOnSeriesType, - axisPre: BarGridLayoutAxisPre + seriesType: BaseBarSeriesSubType ): BarGridLayoutAxisInfo { const seriesInfoOnAxis: BarGridLayoutAxisSeriesInfo[] = []; - const axisScale = baseAxis.scale; - let linearValueExtent: BarGridLayoutAxisInfo['linearValueExtent']; - let pxToDataRatio: BarGridLayoutAxisInfo['pxToDataRatio']; - let singular: BarGridLayoutAxisInfo['singular']; - - let bandWidth: number; - if (isOrdinalScale(axisScale)) { - bandWidth = baseAxis.getBandWidth(); - } - else { - const axisPre = ensureValuesStatisticsOnAxis(baseAxis, layoutPre); - linearValueExtent = axisPre.linearValueExtent; - const axisExtent = baseAxis.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(axisScale); - // `linearScaleSpan` may be `0` or `Infinity` or `NaN`, since normalizers like - // `intervalScaleEnsureValidExtent` may not have been called yet. - if (axisPre.linearMinGap && linearScaleSpan && isNullableNumberFinite(linearScaleSpan)) { - singular = false; - bandWidth = pxSpan / linearScaleSpan * axisPre.linearMinGap; - pxToDataRatio = linearScaleSpan / pxSpan; - } - else { - singular = true; - bandWidth = pxSpan * SINGULAR_BAND_WIDTH_RATIO; - } - } + const bandWidthResult: AxisBandWidthResult = {}; + calcBandWidth(bandWidthResult, baseAxis); + const bandWidth = bandWidthResult.bandWidth; - each(axisPre.seriesList, function (seriesModel) { + eachCollectedSeries(baseAxis, axisStatKey(seriesType), function (seriesModel: BaseBarSeriesModel) { seriesInfoOnAxis.push({ barWidth: parsePercent(seriesModel.get('barWidth'), bandWidth), barMaxWidth: parsePercent(seriesModel.get('barMaxWidth'), bandWidth), @@ -368,11 +182,8 @@ function createLayoutInfoListOnAxis( }); return { - bandWidth: bandWidth, - linearValueExtent: linearValueExtent, + bandWidthResult, seriesInfo: seriesInfoOnAxis, - singular: singular, - pxToDataRatio: pxToDataRatio, }; } @@ -392,7 +203,7 @@ function calcBarWidthAndOffset( minWidth?: number } - const bandWidth = seriesInfoOnAxis.bandWidth; + const bandWidth = seriesInfoOnAxis.bandWidthResult.bandWidth; let remainedWidth = bandWidth; let autoWidthCount: number = 0; let barCategoryGapOption: number | string; @@ -530,10 +341,9 @@ function calcBarWidthAndOffset( export function layout(seriesType: BaseBarSeriesSubType, ecModel: GlobalModel): void { - const layoutPre = ensureBarGridSeriesList(ecModel, seriesType); - eachAxisPre(layoutPre, function (axisPre) { - const columnLayout = makeColumnLayoutOnAxisReal(layoutPre, axisPre.axis); - each(axisPre.seriesList, function (seriesModel) { + eachCollectedAxis(ecModel, function (axis) { + const columnLayout = makeColumnLayoutOnAxisReal(axis as Axis2D, seriesType); + eachCollectedSeries(axis, axisStatKey(seriesType), function (seriesModel) { const columnLayoutInfo = columnLayout.columnMap[getSeriesStackId(seriesModel)]; seriesModel.getData().setLayout({ bandWidth: columnLayoutInfo.bandWidth, @@ -707,26 +517,26 @@ function barGridCreateAxisContainShapeHandler(seriesType: BaseBarSeriesSubType): // 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)) { - const layoutPre = ensureBarGridSeriesList(ecModel, seriesType); - const axisPre = ensureLayoutAxisPre(layoutPre, axis); - if (!axisPre.seriesList.length) { - return; // Quick return for robustness - in most cases there is no bar series based on this axis. + if (!getCollectedSeriesLength(axis, axisStatKey(seriesType))) { + return; // Quick path - in most cases there is no bar on non-ordinal axis. } - const columnLayout = makeColumnLayoutOnAxisReal(layoutPre, axis); - return calcShapeOverflowSupplement(scale, columnLayout); + const columnLayout = makeColumnLayoutOnAxisReal(axis, seriesType); + return calcShapeOverflowSupplement(columnLayout); } }; } function calcShapeOverflowSupplement( - scale: Scale, columnLayout: BarGridColumnLayoutOnAxis | NullUndefined ): number[] | NullUndefined { - const linearValueExtent = columnLayout && columnLayout.linearValueExtent; - - if (columnLayout == null || !linearValueExtent) { + if (columnLayout == null) { return; } + const bandWidthResult = columnLayout.bandWidthResult; + const bandWidthResultKind = bandWidthResult.kind; + if (bandWidthResultKind == null) { + return; // No series data. + } // The calculation below is based on a proportion mapping from // `[barsBoundVal[0], barsBoundVal[1]]` to `[minValNew, maxValNew]`: @@ -737,7 +547,7 @@ function calcShapeOverflowSupplement( // (Note: `|---|` above represents "pixels" rather than "data".) const barsBoundPx = initExtentForUnion(); - const bandWidth = columnLayout.bandWidth; + const bandWidth = bandWidthResult.bandWidth; // 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]`. @@ -750,30 +560,58 @@ function calcShapeOverflowSupplement( unionExtentFromNumber(barsBoundPx, item.offset + item.width); }); - const pxToDataRatio = columnLayout.pxToDataRatio; + const ratio = bandWidthResult.ratio; + if (extentHasValue(barsBoundPx) && isNullableNumberFinite(ratio) + && bandWidthResultKind === AXIS_BAND_WIDTH_KIND_NORMAL + ) { + // Convert from pixel domain to data domain, since the `barsBoundPx` is calculated based on + // `minGap` and extent on data domain. + return [barsBoundPx[0] * ratio, barsBoundPx[1] * ratio]; + // If AXIS_BAND_WIDTH_KIND_SINGULAR, extent expansion is not needed. + } +} - if (extentHasValue(barsBoundPx)) { - let linearSupplement: number[]; +function createAxisStatisticsClient(seriesType: BaseBarSeriesSubType): AxisStatisticsClient { + return { + /** + * NOTICE: + * The order of series matters - must be respected to the declaration on ec option, + * because for historical reason, the last series holds the effective ec option. + * See `calcBarWidthAndOffset`. + */ + collectAxisSeries(ecModel, saveAxisSeries) { + ecModel.eachSeriesByType(seriesType, function (seriesModel: BaseBarSeriesModel) { + if (isCartesian2DInjectedAsDataCoordSys(seriesModel)) { + saveAxisSeries( + (seriesModel.coordinateSystem as Cartesian2D).getBaseAxis(), + seriesModel + ); + } + }); + }, - if (columnLayout.singular) { - const linearSpan = getScaleLinearSpanForMapping(scale); - linearSupplement = [-linearSpan * SINGULAR_SUPPLEMENT_RATIO, linearSpan * SINGULAR_SUPPLEMENT_RATIO]; - } - else if (isNullableNumberFinite(pxToDataRatio)) { - // Convert from pixel domain to data domain, since the `barsBoundPx` is calculated based on - // `minGap` and extent on data domain. - linearSupplement = [barsBoundPx[0] * pxToDataRatio, barsBoundPx[1] * pxToDataRatio]; + getMetrics(axis) { + return { + minGap: !isOrdinalScale(axis.scale) + }; } - return linearSupplement; - } + }; } -const callOnlyOnce = makeCallOnlyOnce(); +function axisStatKey(seriesType: BaseBarSeriesSubType): AxisStatisticsKey { + return `barGrid-${seriesType}` as AxisStatisticsKey; +} -export function registerBarGridAxisContainShapeHandler(registers: EChartsExtensionInstallRegisters) { +export function registerBarGridAxisHandlers(registers: EChartsExtensionInstallRegisters) { callOnlyOnce(registers, function () { - registerAxisContainShapeHandler('bar', barGridCreateAxisContainShapeHandler('bar')); - registerAxisContainShapeHandler('pictorialBar', barGridCreateAxisContainShapeHandler('pictorialBar')); + + function register(seriesType: BaseBarSeriesSubType): void { + requireAxisStatistics(axisStatKey(seriesType), createAxisStatisticsClient(seriesType)); + registerAxisContainShapeHandler(seriesType, barGridCreateAxisContainShapeHandler(seriesType)); + } + + register('bar'); + register('pictorialBar'); }); } diff --git a/src/scale/Log.ts b/src/scale/Log.ts index da746696d..8adff48ef 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -161,6 +161,10 @@ class LogScale extends Scale<LogScale> { static mapperMethods: DecoratedScaleMapperMethods<LogScale> = { + needTransform() { + return true; + }, + normalize(val) { return this.intervalStub.normalize(logScaleLogTick(val, this.base)); }, @@ -228,7 +232,7 @@ class LogScale extends Scale<LogScale> { ); }, - getSeriesExtentFilter() { + getFilter() { return {g: 0}; }, diff --git a/src/scale/Ordinal.ts b/src/scale/Ordinal.ts index e6d2f13d5..d4e2fc55b 100644 --- a/src/scale/Ordinal.ts +++ b/src/scale/Ordinal.ts @@ -177,6 +177,10 @@ class OrdinalScale extends Scale<OrdinalScale> { static decoratedMethods: DecoratedScaleMapperMethods<OrdinalScale> = { + needTransform() { + return this._mapper.needTransform(); + }, + contain(this: OrdinalScale, val: OrdinalNumber): boolean { return this._mapper.contain(this._getTickNumber(val)) && val >= 0 && val < this._ordinalMeta.categories.length; diff --git a/src/scale/Time.ts b/src/scale/Time.ts index de1ef7572..d7f199218 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -90,6 +90,7 @@ import { getScaleExtentForTickUnsafe, initBreakOrLinearMapper, ScaleMapperGeneric } from './scaleMapper'; +import { removeDuplicates, removeDuplicatesGetKeyFromValueProp } from '../util/model'; // FIXME 公用? const bisect = function ( @@ -191,17 +192,7 @@ class TimeScale extends Scale<TimeScale> { 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, @@ -210,18 +201,6 @@ class TimeScale extends Scale<TimeScale> { brk ); - ticks = ticks.concat(innerTicks); - - const extent1Unit = getUnitFromValue(extent[1], useUTC); - ticks.push({ - value: extent[1], - time: { - level: 0, - upperTimeUnit: extent1Unit, - lowerTimeUnit: extent1Unit, - } - }); - let upperUnitIndex = primaryTimeUnits.length - 1; let maxLevel = 0; each(ticks, tick => { @@ -489,7 +468,7 @@ function createEstimateNiceMultiple( }; } -function getIntervalTicks( +function createIntervalTicks( bottomUnitName: TimeUnit, approxInterval: number, isUTC: boolean, @@ -709,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) { @@ -726,16 +706,31 @@ 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) { diff --git a/src/scale/breakImpl.ts b/src/scale/breakImpl.ts index f0f2e5ba2..394092c82 100644 --- a/src/scale/breakImpl.ts +++ b/src/scale/breakImpl.ts @@ -105,6 +105,10 @@ class BreakScaleMapperImpl { static decoratedMethods: DecoratedScaleMapperMethods<BreakScaleMapperImpl> = { + needTransform() { + return !this.breaks.length; + }, + getExtent() { return this._outOfBrk.getExtent(); }, diff --git a/src/scale/scaleMapper.ts b/src/scale/scaleMapper.ts index 57b06e161..08da294e9 100644 --- a/src/scale/scaleMapper.ts +++ b/src/scale/scaleMapper.ts @@ -23,7 +23,7 @@ import { NullUndefined } from '../util/types'; import { AxisBreakParsingResult, BreakScaleMapper, getScaleBreakHelper } from './break'; import { error } from '../util/log'; import { ValueTransformLookupOpt } from './helper'; -import { DataStoreExtentFilter } from '../data/DataStore'; +import { DataSanitizationFilter } from '../data/helper/dataValueHelper'; // ------ START: Scale Mapper Core ------ @@ -34,7 +34,6 @@ import { DataStoreExtentFilter } from '../data/DataStore'; * - All tick/label-related calculation. * - `dataZoom` controlled ends. * - Cartesian2D `clampData`. - * - `axisPointer` triggering. * - line series start. * - heatmap series range. * - markerArea range. @@ -58,6 +57,7 @@ import { DataStoreExtentFilter } from '../data/DataStore'; * - `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: @@ -72,6 +72,7 @@ export const SCALE_EXTENT_KIND_MAPPING = 1; const SCALE_MAPPER_METHOD_NAMES_MAP: Record<keyof ScaleMapper, 1> = { + needTransform: 1, normalize: 1, scale: 1, transformIn: 1, @@ -81,7 +82,7 @@ const SCALE_MAPPER_METHOD_NAMES_MAP: Record<keyof ScaleMapper, 1> = { getExtentUnsafe: 1, setExtent: 1, setExtent2: 1, - getSeriesExtentFilter: 1, + getFilter: 1, sanitizeExtent: 1, freeze: 1, }; @@ -148,6 +149,12 @@ export type ScaleMapperTransformInOpt = export interface ScaleMapper extends ScaleMapperGeneric<ScaleMapper> {} export interface ScaleMapperGeneric<This> { + /** + * 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: @@ -240,7 +247,10 @@ export interface ScaleMapperGeneric<This> { setExtent(this: This, start: number, end: number): void; setExtent2(this: This, kind: ScaleExtentKind, start: number, end: number): void; - getSeriesExtentFilter?: () => DataStoreExtentFilter; + /** + * Filter for sanitization. + */ + getFilter?: () => DataSanitizationFilter; /** * Sanitize the input extent if possible. For example, for LogScale, the negative part will be clampped. @@ -383,6 +393,10 @@ export function initLinearScaleMapper( const linearScaleMapperMethods: ScaleMapperGeneric<LinearScaleMapper> = { + needTransform() { + return false; + }, + /** * NOTICE: Don't use optional arguments for performance consideration here. */ @@ -408,7 +422,9 @@ const linearScaleMapperMethods: ScaleMapperGeneric<LinearScaleMapper> = { }, contain(val) { - const extent = this._extents[SCALE_EXTENT_KIND_EFFECTIVE]; + // 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]; }, diff --git a/src/util/model.ts b/src/util/model.ts index 67ef0282d..54b42de24 100644 --- a/src/util/model.ts +++ b/src/util/model.ts @@ -1239,6 +1239,7 @@ export function isValidBoundsForExtent(start: number, end: number): boolean { /** * `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`. @@ -1294,10 +1295,11 @@ export function resetCachePerECFullUpdate(ecModel: GlobalModel): void { * The cache is auto cleared at the begining of a run of "ec prepare". * * NOTICE: - * - It can be only called at "ec prepare" stage, such as, - * - Do not call it in processor `getTargetSeries` methods. - * - Do not call it in component/series model `init`/`mergeOption`/`optionUpdated`/`getData` methods. - * - "ec prepare" is not necessarily called before each "ec full update". + * - The cache can only be written at the "ec prepare" stage, such as + * - It can be written in `getTargetSeries` methods of data processors. + * - It can be written in `init`/`mergeOption`/`optionUpdated`/`getData` methods of component/series models. + * - The cache can be read in any stages. + * - "ec prepare" is not necessarily performed before each "ec full update" performing. */ export function getCachePerECPrepare(ecModel: GlobalModel): GlobalModelCachePerECPrepare { return ecModelCacheInner(ecModel).prepare; @@ -1305,11 +1307,66 @@ export function getCachePerECPrepare(ecModel: GlobalModel): GlobalModelCachePerE /** * The cache is auto cleared at the begining of a run of "ec full update". + * However, all shortcuts (such as `updateView`/`updateLayout`/etc.) do not clear it. * * NOTICE: - * - Do not call it at "ec prepare" stage. See `getCachePerECPrepare` for details. - * - All shortcuts (such as `updateView`/`updateLayout`/etc.) do not clear it. + * - The cache can only be written AFTER "ec prepare" stage (not included). + * See `getCachePerECPrepare` for details. */ export function getCachePerECFullUpdate(ecModel: GlobalModel): GlobalModelCachePerECFullUpdate { return ecModelCacheInner(ecModel).fullUpdate; } + +/** + * @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<TItem>( + 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<number, string>(); + 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<TValue extends (string | number)>( + item: {value: TValue} +): string { + if (__DEV__) { + assert(item.value != null); + } + return item.value + ''; +} + +export function removeDuplicatesGetKeyFromItemItself<TValue extends (string | number)>( + item: TValue +): string { + if (__DEV__) { + assert(item != null); + } + return item + ''; +} diff --git a/src/util/number.ts b/src/util/number.ts index accab98a0..13df66639 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -804,7 +804,7 @@ export function getLeastCommonMultiple(a: number, b: number) { } /** - * NOTICE: Assume the input `val` is number or null/undefined, no type check. + * 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. diff --git a/src/util/types.ts b/src/util/types.ts index a04c6d3d2..5c87cce9b 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -370,7 +370,7 @@ export interface StageHandler { */ overallReset?: StageHandlerOverallReset; /** - * Called only when this task in a pipeline, and "dirty". + * Called only when this task in a single pipeline, and "dirty". */ reset?: StageHandlerReset; } @@ -550,9 +550,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: { diff --git a/test/bar-overflow-time-plot.html b/test/bar-overflow-time-plot.html index 4ea53f178..d5ebc4853 100644 --- a/test/bar-overflow-time-plot.html +++ b/test/bar-overflow-time-plot.html @@ -201,6 +201,9 @@ under the License. }, inverse: _ctx.xAxisInverse, boundaryGap: _ctx.xAxisBoundaryGap, + splitArea: { + show: true, + }, }, yAxis: { axisTick: { diff --git a/test/ut/spec/util/model.test.ts b/test/ut/spec/util/model.test.ts index 40f789032..e00cf22bd 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 --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
