diff --git a/src/coord/Axis.ts b/src/coord/Axis.ts index a71051507c..f09bfd2908 100644 --- a/src/coord/Axis.ts +++ b/src/coord/Axis.ts @@ -221,7 +221,8 @@ class Axis { } getViewLabels(): ReturnType['labels'] { - return createAxisLabels(this).labels; + const labels = createAxisLabels(this).labels; + return labels; } getLabelModel(): Model { diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index bb4d1b7bf1..7d53c04a25 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -22,6 +22,7 @@ import { AreaStyleOption, ComponentOption, ColorString, AnimationOptionMixin, Dictionary, ScaleDataValue, CommonAxisPointerOption } from '../util/types'; +import { PrimaryTimeUnit } from '../util/time'; import { TextStyleProps } from 'zrender/src/graphic/Text'; @@ -159,9 +160,13 @@ export interface LogAxisBaseOption extends NumericAxisBaseOptionCommon { axisLabel?: AxisLabelOption<'log'>; logBase?: number; } + +export interface TimeAxisLabelOption extends AxisLabelOption<'time'> { + formatterMinUnit?: PrimaryTimeUnit +} export interface TimeAxisBaseOption extends NumericAxisBaseOptionCommon { type?: 'time'; - axisLabel?: AxisLabelOption<'time'>; + axisLabel?: TimeAxisLabelOption; } interface AxisNameTextStyleOption extends TextCommonOption { rich?: Dictionary diff --git a/src/coord/axisDefault.ts b/src/coord/axisDefault.ts index 4d2c6674b3..1d82498cd8 100644 --- a/src/coord/axisDefault.ts +++ b/src/coord/axisDefault.ts @@ -173,9 +173,6 @@ const valueAxis: AxisBaseOption = zrUtil.merge({ const timeAxis: AxisBaseOption = zrUtil.merge({ splitNumber: 6, axisLabel: { - // To eliminate labels that are not nice - showMinLabel: false, - showMaxLabel: false, rich: { primary: { fontWeight: 'bold' diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 53485fb31e..d6758a4272 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -38,6 +38,7 @@ import { CategoryAxisBaseOption, LogAxisBaseOption, TimeAxisLabelFormatterOption, + TimeAxisLabelOption, ValueAxisBaseOption } from './axisCommonTypes'; import type CartesianAxisModel from './cartesian/AxisModel'; @@ -201,9 +202,11 @@ export function createScaleByModel(model: AxisBaseModel, axisType?: string): Sca extent: [Infinity, -Infinity] }); case 'time': + const axisLabel = model.getModel('axisLabel') as Model; return new TimeScale({ locale: model.ecModel.getLocaleModel(), - useUTC: model.ecModel.get('useUTC') + useUTC: model.ecModel.get('useUTC'), + formatterMinUnit: axisLabel.get('formatterMinUnit') }); default: // case 'value'/'interval', 'log', or others. diff --git a/src/coord/axisTickLabelBuilder.ts b/src/coord/axisTickLabelBuilder.ts index 6ac305f9bd..12ad897ec8 100644 --- a/src/coord/axisTickLabelBuilder.ts +++ b/src/coord/axisTickLabelBuilder.ts @@ -31,6 +31,9 @@ import { AxisBaseOption } from './axisCommonTypes'; import OrdinalScale from '../scale/Ordinal'; import { AxisBaseModel } from './AxisBaseModel'; import type Axis2D from './cartesian/Axis2D'; +import { TimeScaleTick } from '../util/types'; +import * as formatUtil from '../util/format'; +import { BoundingRect } from 'zrender'; type CacheKey = string | number; @@ -72,6 +75,8 @@ export function createAxisLabels(axis: Axis): { // Only ordinal scale support tick interval return axis.type === 'category' ? makeCategoryLabels(axis) + : axis.type === 'time' + ? makeTimeLabels(axis) : makeRealNumberLabels(axis); } @@ -90,6 +95,8 @@ export function createAxisTicks(axis: Axis, tickModel: AxisBaseModel): { // Only ordinal scale support tick interval return axis.type === 'category' ? makeCategoryTicks(axis, tickModel) + : axis.type === 'time' + ? makeTimeTicks(axis, tickModel) : {ticks: zrUtil.map(axis.scale.getTicks(), tick => tick.value) }; } @@ -102,6 +109,15 @@ function makeCategoryLabels(axis: Axis) { : result; } +function makeTimeLabels(axis: Axis) { + const labelModel = axis.getLabelModel(); + const result = makeTimeLabelsActually(axis, labelModel); + + return (!labelModel.get('show') || axis.scale.isBlank()) + ? {labels: []} + : result; +} + function makeCategoryLabelsActually(axis: Axis, labelModel: Model) { const labelsCache = getListCache(axis, 'labels'); const optionLabelInterval = getOptionCategoryInterval(labelModel); @@ -129,6 +145,144 @@ function makeCategoryLabelsActually(axis: Axis, labelModel: Model) { + const labelsCache = getListCache(axis, 'labels'); + const timeKey = 'time'; // TODO: change key name + const result = listCacheGet(labelsCache, timeKey); + + if (result) { + return result; + } + + const labels = makeNonOverlappedTimeLabels(axis); + + // Cache to avoid calling interval function repeatly. + return listCacheSet(labelsCache, timeKey, { + labels: labels + }); +} + +function makeNonOverlappedTimeLabels(axis: Axis): MakeLabelsResultObj[]; +function makeNonOverlappedTimeLabels(axis: Axis, onlyTick: false): MakeLabelsResultObj[]; +function makeNonOverlappedTimeLabels(axis: Axis, onlyTick: true): number[]; +function makeNonOverlappedTimeLabels(axis: Axis, onlyTick?: boolean) { + const ticks = axis.scale.getTicks() as TimeScaleTick[]; + const ordinalScale = axis.scale as OrdinalScale; + const labelFormatter = makeLabelFormatter(axis); + const labelModel = axis.getLabelModel(); + const font = labelModel.getFont(); + const padding = formatUtil.normalizeCssArray(labelModel.get('padding') || 0); + const paddingH = padding[1] + padding[3]; + + const result: (MakeLabelsResultObj | number)[] = []; + const boundingRects: BoundingRect[] = []; + + function isOverlap(rect: BoundingRect) { + /** + * `rotate` is not considered because for time axis, + * the interval is a suggestion value, not a precise value. + * So if there is no overlap without rotate, there should be + * no overlap with rotate and we don't have to make tick labels + * as condense as possible as in the case of category axes. + */ + for (let i = 0; i < boundingRects.length; i++) { + if (rect.intersect(boundingRects[i])) { + return true; + } + } + return false; + } + + function addItem(tickValue: number, tickLevel: number) { + const tickObj = { value: tickValue, level: tickLevel }; + result.push(onlyTick + ? tickValue + : { + formattedLabel: labelFormatter(tickObj), + rawLabel: ordinalScale.getLabel(tickObj), // TODO: ? + tickValue: tickValue, + level: tickLevel + } + ); + } + + let lastMaxLevel = Number.MAX_VALUE; + let maxLevel; + /** + * Loop through the ticks with larger levels to smaller levels so that if + * the ticks are overlapped, we can use the level of the higher level. + */ + while (true) { + maxLevel = -1; + + for (let i = 0; i < ticks.length; i++) { + if (ticks[i].level > maxLevel && ticks[i].level < lastMaxLevel) { + maxLevel = ticks[i].level; + } + } + + if (maxLevel < 0) { + break; + } + + for (let i = 0; i < ticks.length; i++) { + const tick = ticks[i]; + if (tick.level === maxLevel) { + // Check if this tick is overlapped with added ticks + const rect = textContain.getBoundingRect( + labelFormatter({ + value: tick.value, + level: tick.level + }), + font, + 'center', + 'top' + ); + rect.x += axis.dataToCoord(tick.value) - padding[3]; + rect.width += paddingH; + if (!isOverlap(rect)) { + // Add the tick only if it has no overlap with current ones + addItem(tick.value, tick.level); + boundingRects.push(rect); + } + } + } + + if (maxLevel <= 0) { + break; + } + lastMaxLevel = maxLevel; + } + return result; +} + +function makeTimeTicks(axis: Axis, tickModel: AxisBaseModel) { + const ticksCache = getListCache(axis, 'ticks'); + const result = listCacheGet(ticksCache, 'time'); + + if (result) { + return result; + } + + let ticks: number[]; + + // Optimize for the case that large category data and no label displayed, + // we should not return all ticks. + if (!tickModel.get('show') || axis.scale.isBlank()) { + ticks = []; + } + + const labelsResult = makeTimeLabelsActually(axis, axis.getLabelModel()); + ticks = zrUtil.map(labelsResult.labels, function (labelItem) { + return labelItem.tickValue; + }); + + // Cache to avoid calling interval function repeatly. + return listCacheSet(ticksCache, 'time', { + ticks: ticks + }); +} + function makeCategoryTicks(axis: Axis, tickModel: AxisBaseModel) { const ticksCache = getListCache(axis, 'ticks'); const optionTickInterval = getOptionCategoryInterval(tickModel); @@ -331,6 +485,7 @@ interface MakeLabelsResultObj { formattedLabel: string rawLabel: string tickValue: number + level?: number } function makeLabelsByNumericCategoryInterval(axis: Axis, categoryInterval: number): MakeLabelsResultObj[]; diff --git a/src/scale/Time.ts b/src/scale/Time.ts index 2458490b2e..ea3c5cd20f 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -78,7 +78,7 @@ import {TimeAxisLabelFormatterOption} from '../coord/axisCommonTypes'; import { warn } from '../util/log'; import { LocaleOption } from '../core/locale'; import Model from '../model/Model'; -import { filter, isNumber, map } from 'zrender/src/core/util'; +import { filter, isNumber, map, indexOf } from 'zrender/src/core/util'; // FIXME 公用? const bisect = function ( @@ -102,6 +102,7 @@ const bisect = function ( type TimeScaleSetting = { locale: Model; useUTC: boolean; + formatterMinUnit?: PrimaryTimeUnit; }; class TimeScale extends IntervalScale { @@ -139,7 +140,8 @@ class TimeScale extends IntervalScale { ): string { const isUTC = this.getSetting('useUTC'); const lang = this.getSetting('locale'); - return leveledFormat(tick, idx, labelFormatter, lang, isUTC); + const formatterMinUnit = this.getSetting('formatterMinUnit'); + return leveledFormat(tick, idx, labelFormatter, lang, isUTC, formatterMinUnit); } /** @@ -161,12 +163,14 @@ class TimeScale extends IntervalScale { }); const useUTC = this.getSetting('useUTC'); + const formatterMinUnit = this.getSetting('formatterMinUnit'); const innerTicks = getIntervalTicks( this._minLevelUnit, this._approxInterval, useUTC, - extent + extent, + formatterMinUnit ); ticks = ticks.concat(innerTicks); @@ -316,65 +320,12 @@ function isUnitValueSame( } } -// const primaryUnitGetters = { -// year: fullYearGetterName(), -// month: monthGetterName(), -// day: dateGetterName(), -// hour: hoursGetterName(), -// minute: minutesGetterName(), -// second: secondsGetterName(), -// millisecond: millisecondsGetterName() -// }; - -// const primaryUnitUTCGetters = { -// year: fullYearGetterName(true), -// month: monthGetterName(true), -// day: dateGetterName(true), -// hour: hoursGetterName(true), -// minute: minutesGetterName(true), -// second: secondsGetterName(true), -// millisecond: millisecondsGetterName(true) -// }; - -// function moveTick(date: Date, unitName: TimeUnit, step: number, isUTC: boolean) { -// step = step || 1; -// switch (getPrimaryTimeUnit(unitName)) { -// case 'year': -// date[fullYearSetterName(isUTC)](date[fullYearGetterName(isUTC)]() + step); -// break; -// case 'month': -// date[monthSetterName(isUTC)](date[monthGetterName(isUTC)]() + step); -// break; -// case 'day': -// date[dateSetterName(isUTC)](date[dateGetterName(isUTC)]() + step); -// break; -// case 'hour': -// date[hoursSetterName(isUTC)](date[hoursGetterName(isUTC)]() + step); -// break; -// case 'minute': -// date[minutesSetterName(isUTC)](date[minutesGetterName(isUTC)]() + step); -// break; -// case 'second': -// date[secondsSetterName(isUTC)](date[secondsGetterName(isUTC)]() + step); -// break; -// case 'millisecond': -// date[millisecondsSetterName(isUTC)](date[millisecondsGetterName(isUTC)]() + step); -// break; -// } -// return date.getTime(); -// } - -// const DATE_INTERVALS = [[8, 7.5], [4, 3.5], [2, 1.5]]; -// const MONTH_INTERVALS = [[6, 5.5], [3, 2.5], [2, 1.5]]; -// const MINUTES_SECONDS_INTERVALS = [[30, 30], [20, 20], [15, 15], [10, 10], [5, 5], [2, 2]]; - function getDateInterval(approxInterval: number, daysInMonth: number) { approxInterval /= ONE_DAY; + // Don't return an interval too large, that would cause too many ticks. + // Even if they don't overlap, it's not good for readability. return approxInterval > 16 ? 16 - // Math.floor(daysInMonth / 2) + 1 // In this case we only want one tick between two months. - : approxInterval > 7.5 ? 7 // TODO week 7 or day 8? - : approxInterval > 3.5 ? 4 - : approxInterval > 1.5 ? 2 : 1; + : approxInterval > 1.5 ? 7 : 1; } function getMonthInterval(approxInterval: number) { @@ -430,7 +381,8 @@ function getIntervalTicks( bottomUnitName: TimeUnit, approxInterval: number, isUTC: boolean, - extent: number[] + extent: number[], + formatterMinUnit?: PrimaryTimeUnit ): TimeScaleTick[] { const safeLimit = 10000; const unitNames = timeUnits; @@ -575,9 +527,17 @@ function getIntervalTicks( const levelsTicks: InnerTimeTick[][] = []; let currentLevelTicks: InnerTimeTick[] = []; + const minUnitId = formatterMinUnit + ? indexOf(unitNames, formatterMinUnit) + : -1; + let tickCount = 0; let lastLevelTickCount = 0; for (let i = 0; i < unitNames.length && iter++ < safeLimit; ++i) { + if (minUnitId >= 0 && i > minUnitId) { + break; + } + const primaryTimeUnit = getPrimaryTimeUnit(unitNames[i]); if (!isPrimaryTimeUnit(unitNames[i])) { // TODO continue; diff --git a/src/util/time.ts b/src/util/time.ts index 9a71f44cfc..2fb95c17e4 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -161,9 +161,11 @@ export function leveledFormat( idx: number, formatter: TimeAxisLabelFormatterOption, lang: string | Model, - isUTC: boolean + isUTC: boolean, + formatterMinUnit: PrimaryTimeUnit ) { let template = null; + const level = tick.level; if (zrUtil.isString(formatter)) { // Single formatter for all units at all levels template = formatter; @@ -171,12 +173,14 @@ export function leveledFormat( else if (zrUtil.isFunction(formatter)) { // Callback formatter template = formatter(tick.value, idx, { - level: tick.level + level }); } else { const defaults = zrUtil.extend({}, defaultLeveledFormatter); - if (tick.level > 0) { + if (level > 0) { + // When there are multiple levels and this is the more significant + // one, emphasis this level for (let i = 0; i < primaryTimeUnits.length; ++i) { defaults[primaryTimeUnits[i]] = `{primary|${defaults[primaryTimeUnits[i]]}}`; } @@ -189,13 +193,29 @@ export function leveledFormat( ) : defaults) as any; - const unit = getUnitFromValue(tick.value, isUTC); + let unit = getUnitFromValue(tick.value, isUTC); + if (formatterMinUnit) { + // When formatterMinUnit is defined and larger than unit, + // use formatterMinUnit instead + // For example, when formatterMinUnit is 'day', and unit is 'hour', + // use 'day' instead of 'hour' + const formatterMinUnitIdx = zrUtil.indexOf(primaryTimeUnits, formatterMinUnit); + if (formatterMinUnitIdx >= 0) { + unit = primaryTimeUnits[ + Math.min( + zrUtil.indexOf(primaryTimeUnits, unit), + formatterMinUnitIdx + ) + ]; + } + } + if (mergedFormatter[unit]) { template = mergedFormatter[unit]; } else if (mergedFormatter.inherit) { // Unit formatter is not defined and should inherit from bigger units - const targetId = timeUnits.indexOf(unit); + const targetId = zrUtil.indexOf(timeUnits, unit); for (let i = targetId - 1; i >= 0; --i) { if (mergedFormatter[unit]) { template = mergedFormatter[unit]; @@ -206,9 +226,9 @@ export function leveledFormat( } if (zrUtil.isArray(template)) { - let levelId = tick.level == null + let levelId = level == null ? 0 - : (tick.level >= 0 ? tick.level : template.length + tick.level); + : (level >= 0 ? level : template.length + level); levelId = Math.min(levelId, template.length - 1); template = template[levelId]; } diff --git a/src/util/types.ts b/src/util/types.ts index 56c7b6983b..9c387f348b 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -395,7 +395,7 @@ export interface TimeScaleTick extends ScaleTick { * For example, a time axis may contain labels like: Jan, 8th, 16th, 23th, * Feb, and etc. In this case, month labels like Jan and Feb should be * displayed in a more significant way than days. - * `level` is set to be 0 when it's the most significant level, like month + * `level` is set to be larger when it's more significant, like month * labels in the above case. */ level?: number diff --git a/test/timeScale-formatter.html b/test/timeScale-formatter.html index 128548bf13..3d9d67f4ef 100644 --- a/test/timeScale-formatter.html +++ b/test/timeScale-formatter.html @@ -38,21 +38,15 @@
- -
- -
- - +
- -
- -
+
+
+
+ + + + + + + + + + + + + + + - diff --git a/test/timeScale2.html b/test/timeScale2.html index 77edaf5d29..5ee9fe2d57 100644 --- a/test/timeScale2.html +++ b/test/timeScale2.html @@ -200,7 +200,8 @@ axisLabel: { textStyle: { color: '#ddd' - } + }, + formatterMinUnit: 'year' } }], yAxis: [{