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]

Reply via email to