diff --git a/src/component/axis/AxisBuilder.ts b/src/component/axis/AxisBuilder.ts index c7b48dcafd8..b9faff59d89 100644 --- a/src/component/axis/AxisBuilder.ts +++ b/src/component/axis/AxisBuilder.ts @@ -26,7 +26,7 @@ import {isRadianAroundZero, remRadian} from '../../util/number'; import {createSymbol, normalizeSymbolOffset} from '../../util/symbol'; import * as matrixUtil from 'zrender/src/core/matrix'; import {applyTransform as v2ApplyTransform} from 'zrender/src/core/vector'; -import {shouldShowAllLabels} from '../../coord/axisHelper'; +import {shouldShowAllLabels, isNameLocationCenter} from '../../coord/axisHelper'; import { AxisBaseModel } from '../../coord/AxisBaseModel'; import { ZRTextVerticalAlign, ZRTextAlign, ECElement, ColorString } from '../../util/types'; import { AxisBaseOption } from '../../coord/axisCommonTypes'; @@ -374,8 +374,8 @@ const builders: Record<'axisLine' | 'axisTickLabel' | 'axisName', AxisElementsBu const nameLocation = axisModel.get('nameLocation'); const nameDirection = opt.nameDirection; const textStyleModel = axisModel.getModel('nameTextStyle'); - const gap = axisModel.get('nameGap') || 0; + const gap = axisModel.axis.getNameGap(); const extent = axisModel.axis.getExtent(); const gapSignal = extent[0] > extent[1] ? -1 : 1; const pos = [ @@ -599,11 +599,6 @@ function isTwoLabelOverlapped( return firstRect.intersect(nextRect); } -function isNameLocationCenter(nameLocation: string) { - return nameLocation === 'middle' || nameLocation === 'center'; -} - - function createTicks( ticksCoords: TickCoord[], tickTransform: matrixUtil.MatrixArray, @@ -740,13 +735,10 @@ function buildAxisLabel( const labelModel = axisModel.getModel('axisLabel'); const labelMargin = labelModel.get('margin'); - const labels = axis.getViewLabels(); + const { labels, rotation } = axis.getViewLabelsAndRotation(); // Special label rotate. - const labelRotation = ( - retrieve(opt.labelRotate, labelModel.get('rotate')) || 0 - ) * PI / 180; - + const labelRotation = (opt.labelRotate || labelModel.get('rotate') || rotation || 0) * PI / 180; const labelLayout = AxisBuilder.innerTextLayout(opt.rotation, labelRotation, opt.labelDirection); const rawCategoryData = axisModel.getCategories && axisModel.getCategories(true); diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index 5a33a33973a..c26530bb27c 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -22,7 +22,9 @@ import {linearMap, getPixelPrecision, round} from '../util/number'; import { createAxisTicks, createAxisLabels, - calculateCategoryInterval + calculateCategoryInterval, + calculateCategoryAutoLayout, + getAxisNameGap } from './axisTickLabelBuilder'; import Scale from '../scale/Scale'; import { DimensionName, ScaleDataValue, ScaleTick } from '../util/types'; @@ -224,6 +226,14 @@ class Axis { return createAxisLabels(this).labels; } + getViewLabelsAndRotation(): ReturnType { + return createAxisLabels(this); + } + + getNameGap(): ReturnType { + return getAxisNameGap(this); + } + getLabelModel(): Model { return this.model.getModel('axisLabel'); } @@ -269,6 +279,14 @@ class Axis { return calculateCategoryInterval(this); } + /** + * Only be called in category axis. + * Can be overridden, consider other axes like in 3D. + * @return Auto layout properties (interval, rotation) for cateogry axis tick and label + */ + calculateCategoryAutoLayout(interval?: number): ReturnType { + return calculateCategoryAutoLayout(this, interval); + } } function fixExtentWithBands(extent: [number, number], nTick: number): void { diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index bb4d1b7bf14..1fdc502bd8d 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -45,8 +45,9 @@ export interface AxisBaseOptionCommon extends ComponentOption, placeholder?: string; }; nameTextStyle?: AxisNameTextStyleOption; - // The gap between axisName and axisLine. + // The gap between axisName and axisLine/labels nameGap?: number; + nameLayout?: 'auto'; silent?: boolean; triggerEvent?: boolean; @@ -219,6 +220,8 @@ interface AxisLabelBaseOption extends Omit { // Whether axisLabel is inside the grid or outside the grid. inside?: boolean, rotate?: number, + autoRotate?: boolean | [number, ...number[]], + minDistance?: number, // true | false | null/undefined (auto) showMinLabel?: boolean, // true | false | null/undefined (auto) @@ -232,7 +235,11 @@ interface AxisLabelBaseOption extends Omit { // Color can be callback color?: ColorString | ((value?: string | number, index?: number) => ColorString) overflow?: TextStyleProps['overflow'] + // approximate auto-layout computations (autoRotate, hideOverlap) if the total number of axis labels is over + // the trheshold; defaults to 40 + layoutApproximationThreshold?: number } + interface AxisLabelOption extends AxisLabelBaseOption { formatter?: LabelFormatters[TType] } diff --git a/src/coord/axisDefault.ts b/src/coord/axisDefault.ts index 4d2c6674b32..b97c58b4475 100644 --- a/src/coord/axisDefault.ts +++ b/src/coord/axisDefault.ts @@ -83,6 +83,7 @@ const defaultOption: AxisBaseOption = { // Whether axisLabel is inside the grid or outside the grid. inside: false, rotate: 0, + minDistance: 10, // true | false | null/undefined (auto) showMinLabel: null, // true | false | null/undefined (auto) diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 53485fb31e1..1e4beb63e6f 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -26,7 +26,6 @@ import { makeColumnLayout, retrieveColumnLayout } from '../layout/barGrid'; -import BoundingRect, { RectLike } from 'zrender/src/core/BoundingRect'; import TimeScale from '../scale/Time'; import Model from '../model/Model'; @@ -290,69 +289,6 @@ export function getAxisRawValue(axis: Axis, tick: ScaleTick): number | string { return axis.type === 'category' ? axis.scale.getLabel(tick) : tick.value; } -/** - * @param axis - * @return Be null/undefined if no labels. - */ -export function estimateLabelUnionRect(axis: Axis) { - const axisModel = axis.model; - const scale = axis.scale; - - if (!axisModel.get(['axisLabel', 'show']) || scale.isBlank()) { - return; - } - - let realNumberScaleTicks: ScaleTick[]; - let tickCount; - const categoryScaleExtent = scale.getExtent(); - - // Optimize for large category data, avoid call `getTicks()`. - if (scale instanceof OrdinalScale) { - tickCount = scale.count(); - } - else { - realNumberScaleTicks = scale.getTicks(); - tickCount = realNumberScaleTicks.length; - } - - const axisLabelModel = axis.getLabelModel(); - const labelFormatter = makeLabelFormatter(axis); - - let rect; - let step = 1; - // Simple optimization for large amount of labels - if (tickCount > 40) { - step = Math.ceil(tickCount / 40); - } - for (let i = 0; i < tickCount; i += step) { - const tick = realNumberScaleTicks - ? realNumberScaleTicks[i] - : { - value: categoryScaleExtent[0] + i - }; - const label = labelFormatter(tick, i); - const unrotatedSingleRect = axisLabelModel.getTextRect(label); - const singleRect = rotateTextRect(unrotatedSingleRect, axisLabelModel.get('rotate') || 0); - - rect ? rect.union(singleRect) : (rect = singleRect); - } - - return rect; -} - -function rotateTextRect(textRect: RectLike, rotate: number) { - const rotateRadians = rotate * Math.PI / 180; - const beforeWidth = textRect.width; - const beforeHeight = textRect.height; - const afterWidth = beforeWidth * Math.abs(Math.cos(rotateRadians)) - + Math.abs(beforeHeight * Math.sin(rotateRadians)); - const afterHeight = beforeWidth * Math.abs(Math.sin(rotateRadians)) - + Math.abs(beforeHeight * Math.cos(rotateRadians)); - const rotatedRect = new BoundingRect(textRect.x, textRect.y, afterWidth, afterHeight); - - return rotatedRect; -} - /** * @param model axisLabelModel or axisTickModel * @return {number|String} Can be null|'auto'|number|function @@ -399,3 +335,7 @@ export function unionAxisExtentFromData(dataExtent: number[], data: SeriesData, }); } } + +export function isNameLocationCenter(nameLocation: string) { + return nameLocation === 'middle' || nameLocation === 'center'; +} diff --git a/src/coord/axisTickLabelBuilder.ts b/src/coord/axisTickLabelBuilder.ts index 6ac305f9bd0..d8b8335d0df 100644 --- a/src/coord/axisTickLabelBuilder.ts +++ b/src/coord/axisTickLabelBuilder.ts @@ -18,12 +18,15 @@ */ import * as zrUtil from 'zrender/src/core/util'; -import * as textContain from 'zrender/src/contain/text'; +import BoundingRect, { RectLike } from 'zrender/src/core/BoundingRect'; + + import {makeInner} from '../util/model'; import { makeLabelFormatter, getOptionCategoryInterval, - shouldShowAllLabels + shouldShowAllLabels, + isNameLocationCenter } from './axisHelper'; import Axis from './Axis'; import Model from '../model/Model'; @@ -32,9 +35,16 @@ import OrdinalScale from '../scale/Ordinal'; import { AxisBaseModel } from './AxisBaseModel'; import type Axis2D from './cartesian/Axis2D'; +const RADIAN = Math.PI / 180; + +interface Size { + width: number; + height: number; +} + type CacheKey = string | number; -type InnerTickLabelCache = { +type InnerListCache = { key: CacheKey value: T }[]; @@ -42,17 +52,25 @@ type InnerTickLabelCache = { interface InnerLabelCachedVal { labels: MakeLabelsResultObj[] labelCategoryInterval?: number + rotation?: number } interface InnerTickCachedVal { ticks: number[] tickCategoryInterval?: number } +interface InnerAutoLayoutCachedVal { + maxLabelSize?: Size + interval: number + rotation: number +} + type InnerStore = { - labels: InnerTickLabelCache - ticks: InnerTickLabelCache - autoInterval: number - lastAutoInterval: number + labels: InnerListCache + ticks: InnerListCache + labelUnionRect: InnerListCache + autoLayout: InnerAutoLayoutCachedVal + lastAutoLayout: InnerAutoLayoutCachedVal lastTickCount: number axisExtent0: number axisExtent1: number @@ -67,6 +85,7 @@ export function createAxisLabels(axis: Axis): { rawLabel: string, tickValue: number }[], + rotation?: number, labelCategoryInterval?: number } { // Only ordinal scale support tick interval @@ -93,12 +112,32 @@ export function createAxisTicks(axis: Axis, tickModel: AxisBaseModel): { : {ticks: zrUtil.map(axis.scale.getTicks(), tick => tick.value) }; } +/** + * @param {module:echats/coord/Axis} axis + * + */ +export function getAxisNameGap(axis: Axis): number { + const axesModel = axis.model; + const nameGap = axesModel.get('nameGap') ?? 0; + if (axesModel.get('nameLayout') === 'auto' + && isNameLocationCenter(axis.model.get('nameLocation')) + ) { + const labelUnionRect = estimateLabelUnionRect(axis); + const labelMargin = axesModel.get(['axisLabel', 'margin']); + const dim = isHorizontalAxis(axis) ? 'height' : 'width'; + return labelUnionRect[dim] + labelMargin + nameGap; + } + + return nameGap; +} + + function makeCategoryLabels(axis: Axis) { const labelModel = axis.getLabelModel(); const result = makeCategoryLabelsActually(axis, labelModel); return (!labelModel.get('show') || axis.scale.isBlank()) - ? {labels: [], labelCategoryInterval: result.labelCategoryInterval} + ? {labels: [], labelCategoryInterval: result.labelCategoryInterval, rotation: 0} : result; } @@ -112,20 +151,22 @@ function makeCategoryLabelsActually(axis: Axis, labelModel: Model(cache: InnerTickLabelCache, key: CacheKey): T { +function listCacheGet(cache: InnerListCache, key: CacheKey): T { for (let i = 0; i < cache.length; i++) { if (cache[i].key === key) { return cache[i].value; @@ -204,82 +247,295 @@ function listCacheGet(cache: InnerTickLabelCache, key: CacheKey): T { } } -function listCacheSet(cache: InnerTickLabelCache, key: CacheKey, value: T): T { +function listCacheSet(cache: InnerListCache, key: CacheKey, value: T): T { cache.push({key: key, value: value}); return value; } -function makeAutoCategoryInterval(axis: Axis) { - const result = inner(axis).autoInterval; - return result != null - ? result - : (inner(axis).autoInterval = axis.calculateCategoryInterval()); +/** + * @param {module:echats/coord/Axis} axis + * @return null/undefined if no labels. + */ +export function estimateLabelUnionRect(axis: Axis) { + const axisModel = axis.model; + const scale = axis.scale; + + if (!axisModel.get(['axisLabel', 'show']) || scale.isBlank()) { + return; + } + + const axisLabelModel = axis.getLabelModel(); + if (scale instanceof OrdinalScale) { + // reuse category axis's cached labels info + const { labels, labelCategoryInterval, rotation } = makeCategoryLabelsActually(axis, axisLabelModel); + const step = layoutScaleStep(labels.length, axisLabelModel, labelCategoryInterval); + return getLabelUnionRect( + axis, + (i) => labels[i].formattedLabel, + labels.length, + step, + rotation ?? 0 + ); + } + + const labelFormatter = makeLabelFormatter(axis); + const realNumberScaleTicks = scale.getTicks(); + const step = layoutScaleStep(realNumberScaleTicks.length, axisLabelModel); + return getLabelUnionRect( + axis, + (i) => labelFormatter(realNumberScaleTicks[i], i), + realNumberScaleTicks.length, + step, + axisLabelModel.get('rotate') ?? 0 + ); } /** - * Calculate interval for category axis ticks and labels. - * To get precise result, at least one of `getRotate` and `isHorizontal` - * should be implemented in axis. + * @param {module:echats/coord/Axis} axis + * @return Axis name dimensions. */ -export function calculateCategoryInterval(axis: Axis) { - const params = fetchAutoCategoryIntervalCalculationParams(axis); - const labelFormatter = makeLabelFormatter(axis); - const rotation = (params.axisRotate - params.labelRotate) / 180 * Math.PI; +export function estimateAxisNameSize(axis: Axis): Size { + const axisModel = axis.model; + const name = axisModel.get('name'); + if (!name) { + return { width: 0, height: 0 }; + } - const ordinalScale = axis.scale as OrdinalScale; - const ordinalExtent = ordinalScale.getExtent(); - // Providing this method is for optimization: - // avoid generating a long array by `getTicks` - // in large category data case. - const tickCount = ordinalScale.count(); + const textStyleModel = axisModel.getModel('nameTextStyle'); + const padding = normalizePadding(textStyleModel.get('padding') ?? 0); + const nameRotate = axisModel.get('nameRotate') ?? 0; + const { bounds } = rotateLabel(textStyleModel.getTextRect(name), nameRotate); + return applyPadding(bounds, padding); +} - if (ordinalExtent[1] - ordinalExtent[0] < 1) { - return 0; +function getLabelUnionRect( + axis: Axis, + getLabel: (i: number) => string, + tickCount: number, + step: number, + rotation: number +) { + const cache = getListCache(axis, 'labelUnionRect'); + const key = tickCount + '_' + step + '_' + rotation + '_' + (tickCount > 0 ? getLabel(0) : ''); + const result = listCacheGet(cache, key); + if (result) { + return result; } - let step = 1; - // Simple optimization. Empirical value: tick count should less than 40. - if (tickCount > 40) { - step = Math.max(1, Math.floor(tickCount / 40)); + return listCacheSet(cache, key, calculateLabelUnionRect(axis, getLabel, tickCount, step, rotation)); +} + +function calculateLabelUnionRect( + axis: Axis, + getLabel: (i: number) => string, + tickCount: number, + step: number, + rotation: number +) { + const labelModel = axis.getLabelModel(); + const padding = getOptionLabelPadding(labelModel); + const isHorizontal = isHorizontalAxis(axis); + let rect; + for (let i = 0; i < tickCount; i += step) { + const labelRect = rotateLabelRect(labelModel.getTextRect(getLabel(i)), rotation, padding, isHorizontal); + rect ? rect.union(labelRect) : (rect = labelRect); } - let tickValue = ordinalExtent[0]; + + return rect; +} + +function rotateLabelRect( + originalRect: RectLike, + rotation: number, + padding: NormedPadding, + isHorizontal: boolean +) { + const { bounds, offset } = rotateLabel(originalRect, rotation); + const { width, height } = applyPadding(bounds, padding); + return new BoundingRect( + 0, 0, + width - (isHorizontal ? 0 : offset.x), + height - (isHorizontal ? offset.y : 0) + ); +} + +function makeAutoCategoryLayout(axis: Axis, interval?: number) { + const result = inner(axis).autoLayout; + return result != null && (interval === undefined || result.interval === interval) + ? result + : (inner(axis).autoLayout = axis.calculateCategoryAutoLayout(interval)); +} + +function calculateUnitDimensions(axis: Axis): Size { + const rotation = getAxisRotate(axis) * RADIAN; + + const ordinalScale = axis.scale as OrdinalScale; + const ordinalExtent = ordinalScale.getExtent(); + const tickValue = ordinalExtent[0]; + const unitSpan = axis.dataToCoord(tickValue + 1) - axis.dataToCoord(tickValue); - const unitW = Math.abs(unitSpan * Math.cos(rotation)); - const unitH = Math.abs(unitSpan * Math.sin(rotation)); + return { + width: Math.abs(unitSpan * Math.cos(rotation)), + height: Math.abs(unitSpan * Math.sin(rotation)) + }; +} + +function calculateMaxLabelDimensions(axis: Axis): Size { + const labelFormatter = makeLabelFormatter(axis); + const axisLabelModel = axis.getLabelModel(); + + const ordinalScale = axis.scale as OrdinalScale; + const ordinalExtent = ordinalScale.getExtent(); + const step = layoutScaleStep(ordinalScale.count(), axisLabelModel); let maxW = 0; let maxH = 0; // Caution: Performance sensitive for large category data. // Consider dataZoom, we should make appropriate step to avoid O(n) loop. - for (; tickValue <= ordinalExtent[1]; tickValue += step) { - let width = 0; - let height = 0; - - // Not precise, do not consider align and vertical align - // and each distance from axis line yet. - const rect = textContain.getBoundingRect( - labelFormatter({ value: tickValue }), params.font, 'center', 'top' - ); - // Magic number - width = rect.width * 1.3; - height = rect.height * 1.3; - + for (let tickValue = ordinalExtent[0]; tickValue <= ordinalExtent[1]; tickValue += step) { + const label = labelFormatter({ value: tickValue }); + const rect = axisLabelModel.getTextRect(label); // Min size, void long loop. - maxW = Math.max(maxW, width, 7); - maxH = Math.max(maxH, height, 7); + maxW = Math.max(maxW, rect.width, 5); + maxH = Math.max(maxH, rect.height, 5); } - let dw = maxW / unitW; - let dh = maxH / unitH; + const labelPadding = getOptionLabelPadding(axisLabelModel); + return applyPadding({ width: maxW, height: maxH }, labelPadding); +} + + +/** + * Calculate interval for category axis ticks and labels. + * To get precise result, at least one of `getRotate` and `isHorizontal` + * should be implemented in axis. + */ +export function calculateCategoryInterval(axis: Axis) { + return calculateCategoryAutoLayout(axis).interval; +} + + +function layoutScaleStep(tickCount: number, labelModel: Model, interval?: number) { + // Simple optimization for large amount of labels: trigger sparse label iteration + // if tick count is over the threshold + const treshhold = labelModel.get('layoutApproximationThreshold') ?? 40; + const step = Math.max((interval ?? 0) + 1, 1); + return tickCount > treshhold ? Math.max(step, Math.floor(tickCount / treshhold)) : step; +} + +function calculateLabelInterval(unitSize: Size, maxLabelSize: Size, minDistance: number) { + let dw = (maxLabelSize.width + minDistance) / unitSize.width; + let dh = (maxLabelSize.height + minDistance) / unitSize.height; // 0/0 is NaN, 1/0 is Infinity. isNaN(dw) && (dw = Infinity); isNaN(dh) && (dh = Infinity); - let interval = Math.max(0, Math.floor(Math.min(dw, dh))); - const cache = inner(axis.model); + return Math.max(0, Math.floor(Math.min(dw, dh))); +} + +/** + * Rotate label's rectangle, see https://codepen.io/agurtovoy/pen/WNPyqWx for the visualization of the math + * below. + * + * @return {Object} { + * axesIntersection: Size, // intersection of the rotated label's rectangle with the corresponding axes + * bounds: Size // dimensions of the rotated label's bounding rectangle + * offset: PointLike // bounding rectangle's offset from the assumed rotation origin + * } + */ +function rotateLabel({ width, height }: Size, rotation: number) { + const rad = rotation * RADIAN; + const sin = Math.abs(Math.sin(rad)); + const cos = Math.abs(Math.cos(rad)); + + // width and height of the intersection of the rotated label's rectangle with the corresponding axes, see + // https://math.stackexchange.com/questions/1449352/intersection-between-a-side-of-rotated-rectangle-and-axis + const axesIntersection = { + width: Math.min(width / cos, height / sin), + height: Math.min(height / cos, width / sin) + }; + + // width and height of the rotated label's bounding rectangle; note th + const bounds = { + width: width * cos + height * sin, + height: width * sin + height * cos + }; + + // label's bounding box's rotation origin is at the vertical center of the bbox's axis edge + // rather than the corresponding corner point + const asbRotation = Math.abs(rotation); + const bboxOffset = asbRotation === 0 || asbRotation === 180 ? 0 : height / 2; + const offset = { + x: bboxOffset * sin, + y: bboxOffset * cos + }; + + return { axesIntersection, bounds, offset }; +} + +function getCandidateLayouts(axis: Axis) { + const unitSize = calculateUnitDimensions(axis); + const maxLabelSize = calculateMaxLabelDimensions(axis); + const isHorizontal = isHorizontalAxis(axis); + + const labelModel = axis.getLabelModel(); + const labelRotations = normalizeLabelRotations(getLabelRotations(labelModel), isHorizontal); + const labelMinDistance = labelModel.get('minDistance') ?? 0; + + const candidateLayouts = []; + for (const rotation of labelRotations) { + const { axesIntersection, bounds, offset } = rotateLabel(maxLabelSize, rotation); + const labelSize = isHorizontal + ? { width: axesIntersection.width, height: bounds.height - offset.y } + : { width: bounds.width - offset.x, height: axesIntersection.height }; + + const interval = calculateLabelInterval(unitSize, labelSize, labelMinDistance); + candidateLayouts.push({ labelSize, interval, rotation }); + } + + return candidateLayouts; +} + +function chooseAutoLayout(candidateLayouts: InnerAutoLayoutCachedVal[]): InnerAutoLayoutCachedVal { + let autoLayout = { + interval: Infinity, + rotation: 0 + }; + + for (const layout of candidateLayouts) { + if (layout.interval < autoLayout.interval + || layout.interval > 0 && layout.interval === autoLayout.interval) { + autoLayout = layout; + } + } + + return autoLayout; +} + +/** + * Calculate max label dimensions, interval, and rotation for category axis ticks and labels. + * To get precise result, at least one of `getRotate` and `isHorizontal` + * should be implemented in axis. + */ +export function calculateCategoryAutoLayout(axis: Axis, interval?: number): InnerAutoLayoutCachedVal { + const ordinalScale = axis.scale as OrdinalScale; + const ordinalExtent = ordinalScale.getExtent(); + if (ordinalExtent[1] - ordinalExtent[0] < 1) { + return { interval: 0, rotation: 0 }; + } + + const candidateLayouts = getCandidateLayouts(axis); + let autoLayout = { + ...chooseAutoLayout(candidateLayouts), + ...(interval === undefined ? {} : { interval }) + }; + const axisExtent = axis.getExtent(); - const lastAutoInterval = cache.lastAutoInterval; + const tickCount = ordinalScale.count(); + + const cache = inner(axis.model); + const lastAutoLayout = cache.lastAutoLayout; const lastTickCount = cache.lastTickCount; // Use cache to keep interval stable while moving zoom window, @@ -288,42 +544,88 @@ export function calculateCategoryInterval(axis: Axis) { // For example, if all of the axis labels are `a, b, c, d, e, f, g`. // The jitter will cause that sometimes the displayed labels are // `a, d, g` (interval: 2) sometimes `a, c, e`(interval: 1). - if (lastAutoInterval != null + if (lastAutoLayout != null && lastTickCount != null - && Math.abs(lastAutoInterval - interval) <= 1 + && Math.abs(lastAutoLayout.interval - autoLayout.interval) <= 1 && Math.abs(lastTickCount - tickCount) <= 1 // Always choose the bigger one, otherwise the critical // point is not the same when zooming in or zooming out. - && lastAutoInterval > interval + && lastAutoLayout.interval > autoLayout.interval // If the axis change is caused by chart resize, the cache should not // be used. Otherwise some hidden labels might not be shown again. && cache.axisExtent0 === axisExtent[0] && cache.axisExtent1 === axisExtent[1] ) { - interval = lastAutoInterval; + autoLayout = lastAutoLayout; } // Only update cache if cache not used, otherwise the // changing of interval is too insensitive. else { cache.lastTickCount = tickCount; - cache.lastAutoInterval = interval; + cache.lastAutoLayout = autoLayout; cache.axisExtent0 = axisExtent[0]; cache.axisExtent1 = axisExtent[1]; } - return interval; + return autoLayout; } -function fetchAutoCategoryIntervalCalculationParams(axis: Axis) { - const labelModel = axis.getLabelModel(); +function isHorizontalAxis(axis: Axis): boolean { + return zrUtil.isFunction((axis as Axis2D).isHorizontal) + && (axis as Axis2D).isHorizontal(); +} + +function getAxisRotate(axis: Axis) { + return zrUtil.isFunction(axis.getRotate) + ? axis.getRotate() + : isHorizontalAxis(axis) ? 0 : 90; +} + +function getLabelRotations(labelModel: Model): [number, ...number[]] { + const labelRotate = labelModel.get('rotate'); + const autoRotate = labelModel.get('autoRotate'); + return labelRotate + ? [labelRotate] + : zrUtil.isArray(autoRotate) && autoRotate.length > 0 + ? autoRotate + : autoRotate ? [0, 45, 90] : [0]; +} + +function normalizeLabelRotations(rotations: [number, ...number[]], isHorizontal: boolean) { + // for horizontal axes, we want to iterate through the rotation angles in the ascending order + // so that the smaller angles are considered first; conversely, for vertical axes, the larger + // angles need to be considered first, since in that case the 0 degree rotation corresponds + // to the smaller possible vertical label size and the largest horizontal extent. + return [...rotations].sort(isHorizontal + ? (a, b) => Math.abs(a) - Math.abs(b) + : (a, b) => Math.abs(b) - Math.abs(a) + ); +} + +function getOptionLabelAutoRotate(labelModel: Model) { + const labelRotate = labelModel.get('rotate'); + const autoRotate = labelModel.get('autoRotate'); + return !labelRotate && (zrUtil.isArray(autoRotate) ? autoRotate.length > 1 : Boolean(autoRotate)); +} + +function getOptionLabelPadding(labelModel: Model) { + return normalizePadding(labelModel.get('padding') ?? 0); +} + +type NormedPadding = [number, number, number, number]; + +function normalizePadding(padding: number | number[]): NormedPadding { + return zrUtil.isArray(padding) + ? (padding.length === 4 + ? padding as NormedPadding + : [padding[0], padding[1], padding[0], padding[1]]) + : [padding, padding, padding, padding]; +} + +function applyPadding({ width, height }: Size, padding: NormedPadding) { return { - axisRotate: axis.getRotate - ? axis.getRotate() - : ((axis as Axis2D).isHorizontal && !(axis as Axis2D).isHorizontal()) - ? 90 - : 0, - labelRotate: labelModel.get('rotate') || 0, - font: labelModel.getFont() + width: width + padding[1] + padding[3], + height: height + padding[0] + padding[2] }; } diff --git a/src/coord/cartesian/Grid.ts b/src/coord/cartesian/Grid.ts index d22ddef556b..4daafedb19d 100644 --- a/src/coord/cartesian/Grid.ts +++ b/src/coord/cartesian/Grid.ts @@ -29,9 +29,10 @@ import { createScaleByModel, ifAxisCrossZero, niceScaleExtent, - estimateLabelUnionRect, - getDataDimensionsOnAxis + getDataDimensionsOnAxis, + isNameLocationCenter } from '../../coord/axisHelper'; +import { estimateLabelUnionRect, estimateAxisNameSize } from '../../coord/axisTickLabelBuilder'; import Cartesian2D, {cartesian2DDimensions} from './Cartesian2D'; import Axis2D from './Axis2D'; import {ParsedModelFinder, ParsedModelFinderKnown, SINGLE_REFERRING} from '../../util/model'; @@ -168,7 +169,6 @@ class Grid implements CoordinateSystemMaster { * Resize the grid */ resize(gridModel: GridModel, api: ExtensionAPI, ignoreContainLabel?: boolean): void { - const boxLayoutParams = gridModel.getBoxLayoutParams(); const isContainLabel = !ignoreContainLabel && gridModel.get('containLabel'); @@ -180,17 +180,18 @@ class Grid implements CoordinateSystemMaster { this._rect = gridRect; - const axesList = this._axesList; - - adjustAxes(); + // sort axes to make sure we estimate ordinal axes' label dimensions after we adjusted + // the available grid space based on the value axes' label dimensions + const axesList = [...this._axesList].sort((a, b) => axisOrder(a) - axisOrder(b)); // Minus label size if (isContainLabel) { each(axesList, function (axis) { + const dim: 'height' | 'width' = axis.isHorizontal() ? 'height' : 'width'; + if (!axis.model.get(['axisLabel', 'inside'])) { const labelUnionRect = estimateLabelUnionRect(axis); if (labelUnionRect) { - const dim: 'height' | 'width' = axis.isHorizontal() ? 'height' : 'width'; const margin = axis.model.get(['axisLabel', 'margin']); gridRect[dim] -= labelUnionRect[dim] + margin; if (axis.position === 'top') { @@ -201,17 +202,39 @@ class Grid implements CoordinateSystemMaster { } } } - }); - adjustAxes(); + if (axis.model.get('nameLayout') === 'auto' + && isNameLocationCenter(axis.model.get('nameLocation')) + ) { + const nameGap = axis.model.get('nameGap') ?? 0; + const nameSize = estimateAxisNameSize(axis); + gridRect[dim] -= nameSize.height + nameGap; + if (axis.position === 'top') { + gridRect.y += nameSize.height + nameGap; + } + else if (axis.position === 'left') { + gridRect.x += nameSize.height + nameGap; + } + } + + // adjust axes after each potential change to grid dimensions + // so that the next (ordinal) axis' label estimations are more precise + adjustAxes(); + }); } + adjustAxes(); + each(this._coordsList, function (coord) { // Calculate affine matrix to accelerate the data to point transform. // If all the axes scales are time or value. coord.calcAffineTransform(); }); + function axisOrder(a: Axis2D) { + return a.type === 'category' ? 1 : 0; + } + function adjustAxes() { each(axesList, function (axis) { const isHorizontal = axis.isHorizontal(); diff --git a/test/axis-labelAutoRotate.html b/test/axis-labelAutoRotate.html new file mode 100644 index 00000000000..91acb0a71a1 --- /dev/null +++ b/test/axis-labelAutoRotate.html @@ -0,0 +1,304 @@ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + \ No newline at end of file