/
githubmirror
/
echarts
Обзор
Документация
Войти
/
githubmirror
/
echarts
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/coord/scaleRawExtentInfo.ts
857 строк
33 KB
100pah
test: fix UT.
11 апр 2026, 16:50
11 апр 2026, 16:50
e548306
Код
Авторство
О чём код?
/* * 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, isArray, eqNaN, isFunction, each, HashMap, createHashMap } from 'zrender/src/core/util'; import Scale from '../scale/Scale'; import { AxisBaseModel } from './AxisBaseModel'; import { parsePercent } from 'zrender/src/contain/text'; import { NumericAxisBaseOptionCommon, NumericAxisBoundaryGapOptionItemValue, } from './axisCommonTypes'; import { DimensionIndex, DimensionName, NullUndefined, ScaleDataValue } from '../util/types'; import { isIntervalScale, isLogScale, isOrdinalScale, isTimeScale } from '../scale/helper'; import { makeInner, initExtentForUnion, unionExtentFromNumber, isValidNumberForExtent, extentHasValue, unionExtentFromExtent, unionExtentStartFromNumber, unionExtentEndFromNumber, ensureExtentAscSimply, } from '../util/model'; import { discourageOnAxisZero, getDataDimensionsOnAxis, isAxisOnBand } from './axisHelper'; import { getCoordForCoordSysUsageKindBox } from '../core/CoordinateSystem'; import type GlobalModel from '../model/Global'; import { error } from '../util/log'; import type Axis from './Axis'; import { isNullableNumberFinite, mathMax, mathMin } from '../util/number'; import { SCALE_EXTENT_KIND_MAPPING } from '../scale/scaleMapper'; import { AxisStatKey, eachKeyOnAxis, eachSeriesOnAxis } from './axisStatistics'; /** * NOTICE: Can be only used in `ensureScaleStore(axisLike)`. * * In most cases the instances of `Axis` and `Scale` are one-to-one mapping and share the same lifecycle. * But in some external usage (such as echarts-gl), axis instance does not necessarily exist, and only * scale instance and axisModel are used. Therefore we store the internal info on scale instance directly. */ const scaleInner = makeInner<{ extent: number[]; dimIdxInCoord: number; }, Scale>(); export const AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE = 1; export const AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM = 2; const AXIS_EXTENT_INFO_BUILD_FROM_EMPTY = 3; export type AxisExtentInfoBuildFrom = typeof AXIS_EXTENT_INFO_BUILD_FROM_COORD_SYS_UPDATE | typeof AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM | typeof AXIS_EXTENT_INFO_BUILD_FROM_EMPTY; /** * It is originally created as `ScaleRawExtentResultFinal['fixMinMax']` and may be * modified in the subsequent process. * It suggests axes to use `scaleRawExtentResult.min/max` directly as their bounds, * instead of expanding the extent by some "nice strategy". But axes may refuse to * comply with it in some special cases, for example, when their scale extents are * invalid, or when they need to be expanded to visually contain series bars. * * In `ScaleRawExtentResultFinal['fixMinMax']`, it is `true` iff: * - ec option `xxxAxis.min/max` are specified, or * - `scaleRawExtentResult.zoomFixMinMax[]` are `true`. * - `min` `max` are expanded by `AxisContainShapeHandler`. * * In the subsequent process, it may be modified to `true` for customization. */ export type ScaleExtentFixMinMax = boolean[]; /** * Return the min max before `dataZoom` applied. * This is `noZoomEffMM`, corresponding to `SCALE_EXTENT_KIND_EFFECTIVE`. */ export type ScaleRawExtentResultForZoom = number[]; export type ScaleRawExtentResultFinal = Pick< ScaleRawExtentInternal, 'fixMM' | 'zoomFixMM' | 'isBlank' | 'incl0' | 'tggAxInv' | 'ctnShp' > & { // This is the effective min max. "effective" indicates `SCALE_EXTENT_KIND_EFFECTIVE`. // It is determined by series data extent and ec options such as `xxxAxis.min/max`, // `xxxAxis.boundaryGap`, etc. And this is the input of "nice" strategy. // Ensure `effMM` has only finite numbers or `NaN`, but never has `null`/`undefined`. // `NaN` means min/max axis is blank. effMM: number[]; }; type ScaleRawExtentResultOthers = Pick< ScaleRawExtentInternal, 'startValue' >; /** * CAVEAT: MUST NOT be modified outside! */ interface ScaleRawExtentInternal { scale: Scale; // The effective min max before `dataZoom` being applied. // "effective" means `SCALE_EXTENT_KIND_EFFECTIVE`. noZoomEffMM: number[]; // data min max, an union from series data on this axis and `model.dataMin/dataMax`. // May be at the initial state `[Infinity, -Infinity]`. dataMM: number[]; // See `ScaleRawExtentInfo['setZoomMM']` zoomMM: (number | NullUndefined)[]; // See comments of `ScaleExtentFixMinMax` fixMM: ScaleExtentFixMinMax; // It indicates that the min max have been fixed by `dataZoom` when its start/end is not 0%/100%. zoomFixMM: ScaleExtentFixMinMax; // Parsed from `AxisBaseOptionCommon['startValue'`]`. // If it is not explicitly specified in ec option, and no series has a true return // of `__requireStartValue()`, it will be a null/undefined. startValue: number | NullUndefined; // Mark that the axis should be blank. isBlank: boolean; // Need to include zero incl0: boolean; // Need to toggle axis inverse. tggAxInv: boolean; // Whether "containShape" is required on this axis. // NOTICE: This is just a requirement; "containShape" is performed only if possible. ctnShp: boolean; } export type AxisContainShapeHandler = ( axis: Axis, ecModel: GlobalModel, // @return: supplement in linear space. // Must ensure: `supplement[0] <= 0 && supplement[1] >= 0` // - e.g., [-50, 70] indicates the final extent should calculated by adding this supplement: // [ // scale.transformOut(scale.transformIn(extent[0]) - 50), // scale.transformOut(scale.transformIn(extent[1]) + 70) // ] // - `null/undefined` means no supplement. ) => number[] | NullUndefined; export class ScaleRawExtentInfo { private _i: ScaleRawExtentInternal; // Injected outside readonly from: AxisExtentInfoBuildFrom; constructor( scale: Scale, model: AxisBaseModel, // Typically: data extent from all series on this axis. dataExtent: number[], requireStartValue: boolean, requireContainShape: boolean ) { const isOrdinal = isOrdinalScale(scale); const axisDataLen = isOrdinal // FIXME: there is a flaw here: if there is no "block" data processor like `dataZoom`, // and progressive rendering is using, here the category result might just only contain // the processed chunk rather than the entire result. ? model.getCategories().length : null; // [CATEGORY_AXIS_MODEL_DATA_IS_EMPTY_ARRAY]: // This is only for backward compatibility - `xxxAxis: {data: []}` can declare this axis as // a "category" axis and use `series.data` to determine its extent but the axis is blank - only // axis line is displayed. This is a conincidence, but it is used in some cases. let categoryAxisModelDataIsEmptyArray: boolean; if (isOrdinal) { const axisModelDataArray = model.getCategories(true); categoryAxisModelDataIsEmptyArray = axisModelDataArray && !axisModelDataArray.length; } // NOTE: also considered the input dataExtent may be still in the initialized state `[Infinity, -Infinity]`. const dataMM = dataExtent.slice(); // custom dataMin/dataMax. // Also considered `modelDataMinMax[0] > modelDataMinMax[1]` may occur. if (isIntervalScale(scale) || isLogScale(scale) || isTimeScale(scale)) { unionExtentStartFromNumber( dataMM, parseAxisModelMinMax(scale, (model as AxisBaseModel<NumericAxisBaseOptionCommon>).get('dataMin', true)) ); unionExtentEndFromNumber( dataMM, parseAxisModelMinMax(scale, (model as AxisBaseModel<NumericAxisBaseOptionCommon>).get('dataMax', true)) ); } if (!extentHasValue(dataMM)) { // dataMM may be still `[Infinity, -Infinity]`, we use `NaN` on the subsequent calculations // to force the `noZoomEffMM` to be `[NaN, NaN]` if needed. dataMM[0] = dataMM[1] = NaN; } const noZoomEffMM: number[] = []; const fixMM: boolean[] = [false, false]; // Notice: When min/max is not set (that is, when there are null/undefined, // which is the most common case), these cases should be ensured: // (1) For 'ordinal', show all axis.data. // (2) For others: // + `boundaryGap` is applied (if min/max set, boundaryGap is // disabled). // + If `needIncludeZero`, min/max should be zero, otherwise, min/max should // be the result that originalExtent enlarged by boundaryGap. // (3) If no data, it should be ensured that `scale.setBlank` is set. const modelMinRaw = model.get('min', true); if (modelMinRaw === 'dataMin') { noZoomEffMM[0] = dataMM[0]; fixMM[0] = true; } else { noZoomEffMM[0] = parseAxisModelMinMax( scale, isFunction(modelMinRaw) // This callback always provides users the full data extent (before data is filtered). ? modelMinRaw({min: dataMM[0], max: dataMM[1]}) : modelMinRaw ); // If `xxxAxis.min: null/undefined`, min should not be fixed. fixMM[0] = noZoomEffMM[0] != null; } const modelMaxRaw = model.get('max', true); if (modelMaxRaw === 'dataMax') { noZoomEffMM[1] = dataMM[1]; fixMM[1] = true; } else { noZoomEffMM[1] = parseAxisModelMinMax( scale, isFunction(modelMaxRaw) // This callback always provides users the full data extent (before data is filtered). ? modelMaxRaw({min: dataMM[0], max: dataMM[1]}) : modelMaxRaw ); // If `xxxAxis.max: null/undefined`, max should not be fixed. fixMM[1] = noZoomEffMM[1] != null; } const boundaryGap = parseBoundaryGapOption(scale, model); const span = !isOrdinal // PENDING: Historicall behavior but may not reasonable enough. ? ((dataMM[1] - dataMM[0]) || Math.abs(dataMM[0])) : null; // NOTE: If a numeric axis min/max is specified as 'dataMin'/'dataMax', // `boundaryGap` will not be used. if (noZoomEffMM[0] == null) { noZoomEffMM[0] = isOrdinal ? (categoryAxisModelDataIsEmptyArray ? dataMM[0] : (axisDataLen ? 0 : NaN)) : dataMM[0] - boundaryGap[0] * span; } if (noZoomEffMM[1] == null) { noZoomEffMM[1] = isOrdinal ? (categoryAxisModelDataIsEmptyArray ? dataMM[1] : (axisDataLen ? axisDataLen - 1 : NaN)) : dataMM[1] + boundaryGap[1] * span; } // Normalize to `NaN` if invalid; e.g., this may occur when `dataMM` has Infinity. !isValidNumberForExtent(noZoomEffMM[0]) && (noZoomEffMM[0] = NaN); !isValidNumberForExtent(noZoomEffMM[1]) && (noZoomEffMM[1] = NaN); const isBlank = categoryAxisModelDataIsEmptyArray || eqNaN(noZoomEffMM[0]) || eqNaN(noZoomEffMM[1]) || (isOrdinal && !axisDataLen); // NOTE: `needIncludeZero` is not applicable to LogScale, TimeScale, OrdinalScale. const needIncludeZeroApplicable = isIntervalScale(scale); const needIncludeZero = needIncludeZeroApplicable && model.needIncludeZero && model.needIncludeZero(); if (needIncludeZero) { if (noZoomEffMM[0] > 0 && noZoomEffMM[1] > 0 && !fixMM[0]) { noZoomEffMM[0] = 0; // fixMM[0] = true; } if (noZoomEffMM[0] < 0 && noZoomEffMM[1] < 0 && !fixMM[1]) { noZoomEffMM[1] = 0; // fixMM[1] = true; } } let needToggleAxisInverse: boolean = false; if (noZoomEffMM[0] > noZoomEffMM[1]) { // Historically, if users set `xxxAxis.min > xxxAxis.max`, or `xxxAxis.max < dataExtent[0]`, // or `xxxAxis.min > dataExtent[1]` the behavior is sometimes like `xxxAxis.inverse = true`, // sometimes abnormal. We remain backward compatible with the former one, though this feature // may not be reasonable. // And handle it after "needIncludeZero" is also for backward compatibility. noZoomEffMM.reverse(); needToggleAxisInverse = true; } let startValue = parseAxisModelMinMax(scale, model.get('startValue', true)); const startValueSpecified = startValue != null; if (!isNullableNumberFinite(startValue) && requireStartValue) { startValue = scale.getDefaultStartValue ? scale.getDefaultStartValue() : 0; } if (isNullableNumberFinite(startValue) // Keep backward compatibility and enable `xxxAxis.scale: true` enabled on bar series: // if `xxxAxis.scale: true` and `startValue` is not specified, do not union the default `startValue`, && (startValueSpecified || !needIncludeZeroApplicable || needIncludeZero) ) { if (startValue < noZoomEffMM[0] && !fixMM[0]) { noZoomEffMM[0] = startValue; fixMM[0] = true; } else if (startValue > noZoomEffMM[1] && !fixMM[1]) { noZoomEffMM[1] = startValue; fixMM[1] = true; } } const internal: ScaleRawExtentInternal = this._i = { scale, dataMM, noZoomEffMM, zoomMM: [], fixMM, zoomFixMM: [false, false], startValue, isBlank, incl0: needIncludeZero, tggAxInv: needToggleAxisInverse, ctnShp: requireContainShape, }; sanitizeExtent(internal, noZoomEffMM); } makeNoZoom(): ScaleRawExtentResultForZoom { return this._i.noZoomEffMM.slice(); } makeFinal(): ScaleRawExtentResultFinal { const internal = this._i; const zoomMM = internal.zoomMM; const noZoomEffMM = internal.noZoomEffMM; const zoomFixMM = internal.zoomFixMM; const fixMM = internal.fixMM; const result = { fixMM, zoomFixMM, isBlank: internal.isBlank, incl0: internal.incl0, tggAxInv: internal.tggAxInv, ctnShp: internal.ctnShp, effMM: noZoomEffMM.slice(), }; const effMM = result.effMM; // NOTE: Switching `fixMM` probably leads to abrupt extent changes when draging a `dataZoom` // handle, since `fixMM` impact the "nice extent" and "nice ticks" calculation. // Consider a case: // dataZoom `start` is 2% but its `end` is 100%, (or vice versa), we currently only set `fixMM[0]` // as `true` but remain `fixMM[1]` as `false` for this case to avoid unnecessary abrupt change. // Incidentally, the effect is not unacceptable if we set both `fixMM[0]/[1]` as `true`. if (zoomMM[0] != null) { effMM[0] = zoomMM[0]; fixMM[0] = zoomFixMM[0] = true; } if (zoomMM[1] != null) { effMM[1] = zoomMM[1]; fixMM[1] = zoomFixMM[1] = true; } sanitizeExtent(internal, effMM); return result; } makeRenderInfo(): ScaleRawExtentResultOthers { return { startValue: this._i.startValue, }; } /** * NOTICE: * - Do not set them if the percent are 0% or 100%. (See `AxisProxy['reset']`.) * - The caller must ensure `start <= end` and the range is equal or less then `noZoomEffMM`. * (See `AxisProxy['calculateDataWindow']`.) * - The outcome `_zoomMM` may have both `NullUndefined` and a finite value, like `[undefined, 123]`. */ setZoomMM(idxMinMax: 0 | 1, val: number | NullUndefined): void { this._i.zoomMM[idxMinMax] = val; } } /** * Should be called when a new extent is created or modified. */ function sanitizeExtent( internal: ScaleRawExtentInternal, mm: number[] ): void { const scale = internal.scale; const dataMM = internal.dataMM; if (scale.sanitize) { mm[0] = scale.sanitize(mm[0], dataMM); mm[1] = scale.sanitize(mm[1], dataMM); ensureExtentAscSimply(mm); } } function parseAxisModelMinMax(scale: Scale, minMax: ScaleDataValue): number { return minMax == null ? null // null/undefined means not specified and other default values can be applied. : eqNaN(minMax) ? NaN // NaN means a deliberate invalid number. : scale.parse(minMax); } function parseBoundaryGapOption( scale: Scale, model: AxisBaseModel ): number[] { let boundaryGapOptionArr; if (isOrdinalScale(scale)) { boundaryGapOptionArr = [0, 0]; } else { let boundaryGap = (model as AxisBaseModel<NumericAxisBaseOptionCommon>).get('boundaryGap'); if (typeof boundaryGap === 'boolean') { if (__DEV__) { if (boundaryGap === true) { console.warn('Boolean type for boundaryGap is only ' + 'allowed for ordinal axis. Please use string in ' + 'percentage instead, e.g., "20%". Currently, ' + 'boundaryGap is set to 0.'); } } boundaryGap = null; } boundaryGapOptionArr = isArray(boundaryGap) ? boundaryGap : [boundaryGap, boundaryGap]; } return [ parseBoundaryGapOptionItem(boundaryGapOptionArr[0]), parseBoundaryGapOptionItem(boundaryGapOptionArr[1]), ]; } function parseBoundaryGapOptionItem( opt: NumericAxisBoundaryGapOptionItemValue | boolean ): number { return parsePercent( typeof opt === 'boolean' ? 0 : opt, 1 ) || 0; } /** * NOTE: `associateSeriesWithAxis` is not necessarily called, e.g., when * an axis is not used by any series. */ function ensureScaleStore(axisLike: {scale: Scale}) { const store = scaleInner(axisLike.scale); if (!store.extent) { store.extent = initExtentForUnion(); } return store; } /** * This supports union extent on case like: pie (or other similar series) * lays out on cartesian2d. * @see scaleRawExtentInfoCreate */ export function scaleRawExtentInfoEnableBoxCoordSysUsage( axisLike: { scale: Scale; dim: DimensionName; }, coordSysDimIdxMap: HashMap<DimensionIndex, DimensionName> | NullUndefined ): void { ensureScaleStore(axisLike).dimIdxInCoord = coordSysDimIdxMap.get(axisLike.dim); } /** * @usage * class SomeCoordSys { * static create() { * ecModel.eachSeries(function (seriesModel) { * associateSeriesWithAxis(axis1, seriesModel, ...); * associateSeriesWithAxis(axis2, seriesModel, ...); * // ... * }); * } * update() { * scaleRawExtentInfoCreate(axis1); * scaleRawExtentInfoCreate(axis2); * } * } * class AxisProxy { * reset() { * scaleRawExtentInfoCreate(axis1); * } * } * * NOTICE: * - `associateSeriesWithAxis`(in `axisStatistics.ts`) should be called in: * - Coord sys create method. * - `scaleRawExtentInfoCreate` should be typically called in: * - `dataZoom` processor. It requires processing like: * 1. Filter series data by dataZoom1; * 2. Union the filtered data and init the extent of the orthogonal axes, which is the 100% of dataZoom2; * 3. Filter series data by dataZoom2; * 4. ... * - Coord sys update method, for other axes that not covered by `dataZoom`. * NOTE: If a `dataZoom` covers this series, this data and its extent has been dataZoom-filtered. * Therefore this handling should not before `dataZoom`. * - The callback of `min`/`max` in ec option should NOT be called multiple times, * therefore, we initialize `ScaleRawExtentInfo` uniformly in `scaleRawExtentInfoCreate`. * * @see SCALE_EXTENT_CONSTRUCTION for the full processing flow. */ export function scaleRawExtentInfoCreate( axis: Axis, from: AxisExtentInfoBuildFrom ): void { const scale = axis.scale; const model = axis.model; const axisDim = axis.dim; if (__DEV__) { assert(scale && model && axisDim); } if (scale.rawExtentInfo) { if (__DEV__) { // Check for incorrect impl - the duplicated calling of this method is only allowed in // these cases: // - First in `AxisProxy['reset']` (for dataZoom) // - Then in `CoordinateSystem['update']`. // - Then after `chart.appendData()` due to `dirtyOnOverallProgress: true` assert(scale.rawExtentInfo.from !== from || from === AXIS_EXTENT_INFO_BUILD_FROM_DATA_ZOOM); } return; } scaleRawExtentInfoCreateDeal(scale, axis, axisDim, model, from); } function scaleRawExtentInfoCreateDeal( scale: Scale, axis: Axis, axisDim: DimensionName, model: AxisBaseModel, from: AxisExtentInfoBuildFrom ): void { const scaleStore = ensureScaleStore(axis); const extent = scaleStore.extent; let requireStartValue = false; eachSeriesOnAxis(axis, function (seriesModel) { if (seriesModel.boxCoordinateSystem) { // This supports union extent on case like: pie (or other similar series) // lays out on cartesian2d. const {coord} = getCoordForCoordSysUsageKindBox(seriesModel); const dimIdx = scaleStore.dimIdxInCoord; if (!(dimIdx >= 0)) { if (__DEV__) { // Require `scaleRawExtentInfoEnableBoxCoordSysUsage` have been called to support it. // But if users set it, give a error log but no exceptions. error(`Property "series.coord" is not supported on axis ${seriesModel.boxCoordinateSystem.type}.`); } } // Only `[val1, val2]` case needs to be supported currently. else if (isArray(coord)) { const coordItem = coord[dimIdx]; if (coordItem != null && !isArray(coordItem)) { unionExtentFromNumber(extent, scale.parse(coordItem)); } } } else if (seriesModel.coordinateSystem) { // NOTE: This data may have been filtered by dataZoom on orthogonal axes. const data = seriesModel.getData(); if (data) { const filter = scale.getFilter ? scale.getFilter() : null; each(getDataDimensionsOnAxis(data, axisDim), function (dim) { unionExtentFromExtent(extent, data.getApproximateExtent(dim, filter)); }); } if (seriesModel.__requireStartValue && seriesModel.__requireStartValue(axis)) { requireStartValue = true; } } }); const requireContainShape = determineRequireContainShape(scale, axis, model); const rawExtentInfo = new ScaleRawExtentInfo(scale, model, extent, requireStartValue, requireContainShape); injectScaleRawExtentInfo(scale, rawExtentInfo, from); scaleStore.extent = null; // Clean up } /** * `rawExtentInfo` may not be created in some cases, such as no series declared or extra useless * axes declared in ec option. In this case we still create a default one for that empty axis. */ function scaleRawExtentInfoBuildDefault( axisLike: { scale: Scale; model: AxisBaseModel; }, dataExtent: number[] ): void { const scale = axisLike.scale; if (__DEV__) { assert(!scale.rawExtentInfo); } injectScaleRawExtentInfo( scale, new ScaleRawExtentInfo(scale, axisLike.model, dataExtent, false, false), AXIS_EXTENT_INFO_BUILD_FROM_EMPTY ); } function injectScaleRawExtentInfo( scale: Scale, scaleRawExtentInfo: ScaleRawExtentInfo, from: AxisExtentInfoBuildFrom ): void { // @ts-ignore scale.rawExtentInfo = scaleRawExtentInfo; // @ts-ignore scaleRawExtentInfo.from = from; } /** * See `axisSnippets.ts` for some commonly used handlers. * * FIXME: * `boundaryGap: true` (i.e., `onBand: true` in code) has long been supported on category axis. * And it is implemented in different code and not merged to this implementation yet. */ export function registerAxisContainShapeHandler( // `axisStatKey` is used to quickly omit irrelevant handlers, // since handlers need to be iterated per axis. axisStatKey: AxisStatKey, handler: AxisContainShapeHandler, ) { if (__DEV__) { assert(!axisContainShapeHandlerMap.get(axisStatKey)); } axisContainShapeHandlerMap.set(axisStatKey, handler); } const axisContainShapeHandlerMap: HashMap<AxisContainShapeHandler, AxisStatKey> = createHashMap(); /** * Prepare axis scale extent before "nice". * Item of returned array can only be number (including Infinity and NaN). */ export function adoptScaleRawExtentInfoAndPrepare( scale: Scale, model: AxisBaseModel, ecModel: GlobalModel | NullUndefined, axis: Axis | NullUndefined, externalDataExtent: number[] | NullUndefined ): ScaleRawExtentResultFinal { if (__DEV__) { assert(!externalDataExtent || !scale.rawExtentInfo); } if (!scale.rawExtentInfo) { scaleRawExtentInfoBuildDefault( {scale, model}, externalDataExtent || initExtentForUnion() ); } const rawExtentResult = scale.rawExtentInfo.makeFinal(); // NOTE: This `scale.setExtent()` is required by: // - `axisNiceTicks.ts` and `axisAlignTicks.ts`, where the internal `scaleMapper` may be required. const effectiveMinMax = rawExtentResult.effMM; scale.setExtent(effectiveMinMax[0], effectiveMinMax[1]); scale.setBlank(rawExtentResult.isBlank); if (axis && rawExtentResult.tggAxInv && ecModel && !ecModel.get('legacyMinMaxDontInverseAxis') ) { axis.inverse = !axis.inverse; } return rawExtentResult; } function determineRequireContainShape( scale: Scale, axis: Axis, model: AxisBaseModel, ): boolean { const onBand = isAxisOnBand(scale, model); let modelContainShape = model.get('containShape', true); if (modelContainShape == null && !onBand) { modelContainShape = true; } if (!modelContainShape) { return false; } let requireContainShape = false; eachKeyOnAxis(axis, function (axisStatKey) { requireContainShape = !!axisContainShapeHandlerMap.get(axisStatKey) || requireContainShape; }); return requireContainShape; } /** * This implements ec option `someAxis.containShape`. That is, expand scale extent slightly to * ensure shapes of specific series are fully contained in the axis extent without overflow. * * NOTICE: * Scale extent (data extent) and axis pixel extent (pixel extent) and are required as inputs. * - See BAND_WIDTH_USED_SCALE_LINEAR_SPAN. * - Axis pixel extent has been set outside, though it may be modified later (e.g., via `outerBounds`). * * @tutorial [AXIS_CONTAIN_SHAPE_PROCESSING_ORDER] * This is a trade-off between the following 2 approaches: * - Steps: (the current implementation) * 1. Process `dataZoom` based on a full window `noZoomEffMM`. * 2. Perform "nice" or "align" scale, where `intervalScaleEnsureValidExtent`-ish may be performed to * expand extent to avoid `extent[0] === extent[1]`. * 3. Calculate linear supplement of containShape based on the final result. * Cons: * - Abrupt changes occur when zooming away from 0% or 100%. * - Edge shapes are clipped in "dataZoom shadow". * - Steps: (discarded) * 1. Calculate linear supplement of containShape based on `noZoomEffMM` and * `intervalScaleEnsureValidExtent`-ish. * 2. Process `dataZoom` based on a full window `noZoomEffMM + linearSupplement`. * 3. Perform "nice"/"align" scale. * Cons: * - Input `startValue: 0` (in ec option or action) does not corresponds to `0%`, which is unacceptable. * - Not easy to perform `intervalScaleEnsureValidExtent`-ish before "nice"/"align" processing. * * @see SCALE_EXTENT_CONSTRUCTION for the full processing flow. */ export function adoptScaleExtentKindMapping( axis: Axis, scale: Scale, rawExtentResult: ScaleRawExtentResultFinal, ecModel: GlobalModel ): void { if (!rawExtentResult.ctnShp) { return; } let linearSupplement: number[] | NullUndefined; eachKeyOnAxis(axis, function (axisStatKey) { const handler = axisContainShapeHandlerMap.get(axisStatKey); if (handler) { // This feature can be implemented by either expanding axis extent or scale extent. The choice depends // on whether series shape sizes are defined in pixels or data space. For example, scatter series glyph // sizes is mainly defined in pixel, while bar series `bandWidth` is mainly determined by given percents // of data scale. Since currently scatter does not require this feature, we implement it only on the // data scale. const singleLinearSupplement = handler(axis, ecModel); if (singleLinearSupplement) { linearSupplement = linearSupplement || [0, 0]; unionExtentStartFromNumber(linearSupplement, singleLinearSupplement[0]); unionExtentEndFromNumber(linearSupplement, singleLinearSupplement[1]); // Consider the consistency of `onZero` behavior (not varying by series data; otherwise error-prone // to users), if any `containShape` really performed on this axis, discourage `onZero`. discourageOnAxisZero(axis); } } }); if (!linearSupplement) { return; } const scaleExtent = scale.getExtent(); if (isOrdinalScale(scale)) { if (!axis.onBand) { // - Zooming on `OrdinalScale` auto "snaps" to integer ticks, which causes edge shapes to // always overlap and be clipped at the boundaries. Therefore we always supplement it with // half bandWith to avoid that overlapping. // - `linearSupplement` is typically [-0.5, 0.5] in this case. `linearSupplement` exists only // if any series call `registerAxisContainShapeHandler`. // - PENDING: For historical reason, `onBand: true` has another implementation to handle // this case. Merge them to this? scale.setExtent2( SCALE_EXTENT_KIND_MAPPING, mathMin(scaleExtent[0], scaleExtent[0] + linearSupplement[0]), mathMax(scaleExtent[1], scaleExtent[1] + linearSupplement[1]) ); } } else { // For other cases, `SCALE_EXTENT_KIND_MAPPING` is only used on the full window of `dataZoom`, // where the visual result is more intuitive when zooming: when dataZoom is applied and its ends // (i.e., `zoomMM`) do not reach 0% or 100%, the axis ends should exactly respect to the dataZoom // ends, and shapes are clipped if overflowing. const scaleExtentExpanded = scaleExtent.slice(); if (!rawExtentResult.zoomFixMM[0]) { scaleExtentExpanded[0] = mathMin( scaleExtentExpanded[0], scale.transformOut(scale.transformIn(scaleExtentExpanded[0], null) + linearSupplement[0], null) ); } if (!rawExtentResult.zoomFixMM[1]) { scaleExtentExpanded[1] = mathMax( scaleExtentExpanded[1], scale.transformOut(scale.transformIn(scaleExtentExpanded[1], null) + linearSupplement[1], null) ); } if (scaleExtentExpanded[0] < scaleExtent[0] || scaleExtentExpanded[1] > scaleExtent[1]) { scale.setExtent2(SCALE_EXTENT_KIND_MAPPING, scaleExtentExpanded[0], scaleExtentExpanded[1]); } } // NOTE: since currently `SCALE_EXTENT_KIND_MAPPING` is never required to be displayed, we // do not need to find a proper precision for that. But if it is required in the future, We // can use `getAcceptableTickPrecision` to find a proper precision. }