diff --git a/src/component/tooltip/TooltipView.ts b/src/component/tooltip/TooltipView.ts index 044a12ffca..9c24cbbbff 100644 --- a/src/component/tooltip/TooltipView.ts +++ b/src/component/tooltip/TooltipView.ts @@ -57,6 +57,7 @@ import { normalizeTooltipFormatResult } from '../../model/mixin/dataFormat'; import { createTooltipMarkup, buildTooltipMarkup, TooltipMarkupStyleCreator } from './tooltipMarkup'; import { findEventDispatcher } from '../../util/event'; import { clear, createOrUpdate } from '../../util/throttle'; +import { isTimeScale } from '../../scale/helper'; const proxyRect = new Rect({ shape: { x: -1, y: -1, width: 2, height: 2 } @@ -132,6 +133,7 @@ type TooltipCallbackDataParams = CallbackDataParams & { // TODO: TYPE Value type axisValue?: string | number axisValueLabel?: string + axisTimeZone?: string marker?: TooltipMarker }; @@ -568,6 +570,9 @@ class TooltipView extends ComponentView { axisItem.seriesDataIndices, axisItem.valueLabelOpt ); + const axisTimeZone = isTimeScale(axis.scale) + ? axis.scale.getTimeZone() + : ecModel.getTimeZone(); const axisSectionMarkup = createTooltipMarkup('section', { header: axisValueLabel, noHeader: !trim(axisValueLabel), @@ -593,6 +598,7 @@ class TooltipView extends ComponentView { axisModel.axis, { value: axisValueParsed } ); cbParams.axisValueLabel = axisValueLabel; + cbParams.axisTimeZone = axisTimeZone; // Pre-create marker style for makers. Users can assemble richText // text in `formatter` callback and use those markers style. cbParams.marker = markupStyleCreator.makeTooltipMarker( @@ -627,7 +633,7 @@ class TooltipView extends ComponentView { const orderMode = singleTooltipModel.get('order'); const builtMarkupText = buildTooltipMarkup( - articleMarkup, markupStyleCreator, renderMode, orderMode, ecModel.get('useUTC'), + articleMarkup, markupStyleCreator, renderMode, orderMode, ecModel.getTimeZone(), singleTooltipModel.get('textStyle') ); builtMarkupText && markupTextArrLegacy.unshift(builtMarkupText); @@ -711,7 +717,7 @@ class TooltipView extends ComponentView { markupStyleCreator, renderMode, orderMode, - ecModel.get('useUTC'), + ecModel.getTimeZone(), tooltipModel.get('textStyle') ) : seriesTooltipResult.text; @@ -846,12 +852,15 @@ class TooltipView extends ComponentView { if (formatter) { if (isString(formatter)) { - const useUTC = tooltipModel.ecModel.get('useUTC'); const params0 = isArray(params) ? params[0] : params; const isTimeAxis = params0 && params0.axisType && params0.axisType.indexOf('time') >= 0; html = formatter; if (isTimeAxis) { - html = timeFormat(params0.axisValue, html, useUTC); + html = timeFormat( + params0.axisValue, + html, + params0.axisTimeZone || tooltipModel.ecModel.getTimeZone() + ); } html = formatTpl(html, params, true); } diff --git a/src/component/tooltip/seriesFormatTooltip.ts b/src/component/tooltip/seriesFormatTooltip.ts index 88182fde06..d3f1e9e692 100644 --- a/src/component/tooltip/seriesFormatTooltip.ts +++ b/src/component/tooltip/seriesFormatTooltip.ts @@ -28,6 +28,7 @@ import { } from './tooltipMarkup'; import { retrieveRawValue } from '../../data/helper/dataProvider'; import { isNameSpecified } from '../../util/model'; +import { isTimeScale } from '../../scale/helper'; export function defaultSeriesFormatTooltip(opt: { @@ -51,12 +52,14 @@ export function defaultSeriesFormatTooltip(opt: { // Complicated rule for pretty tooltip. let inlineValue; let inlineValueType: DimensionType | DimensionType[]; + let inlineTimeZone: string | string[]; let subBlocks: TooltipMarkupBlockFragment[]; let sortParam: unknown; if (tooltipDimLen > 1 || (isValueArr && !tooltipDimLen)) { const formatArrResult = formatTooltipArrayValue(value, series, dataIndex, tooltipDims, markerColor); inlineValue = formatArrResult.inlineValues; inlineValueType = formatArrResult.inlineValueTypes; + inlineTimeZone = formatArrResult.inlineTimeZones; subBlocks = formatArrResult.blocks; // Only support tooltip sort by the first inline value. It's enough in most cases. sortParam = formatArrResult.inlineValues[0]; @@ -65,6 +68,7 @@ export function defaultSeriesFormatTooltip(opt: { const dimInfo = data.getDimensionInfo(tooltipDims[0]); sortParam = inlineValue = retrieveRawValue(data, dataIndex, tooltipDims[0]); inlineValueType = dimInfo.type; + inlineTimeZone = getDimensionTimeZone(series, dimInfo); } else { sortParam = inlineValue = isValueArr ? value[0] : value; @@ -94,6 +98,7 @@ export function defaultSeriesFormatTooltip(opt: { noName: !trim(inlineName), value: inlineValue, valueType: inlineValueType, + timeZone: inlineTimeZone, rawDataIndex: data.getRawIndex(dataIndex), }) ].concat(subBlocks || [] as any) @@ -109,6 +114,7 @@ function formatTooltipArrayValue( ): { inlineValues: unknown[]; inlineValueTypes: DimensionType[]; + inlineTimeZones: string[]; blocks: TooltipMarkupBlockFragment[]; } { // check: category-no-encode-has-axis-data in dataset.html @@ -121,6 +127,7 @@ function formatTooltipArrayValue( const inlineValues: unknown[] = []; const inlineValueTypes: DimensionType[] = []; + const inlineTimeZones: string[] = []; const blocks: TooltipMarkupBlockFragment[] = []; tooltipDims.length @@ -142,14 +149,25 @@ function formatTooltipArrayValue( markerColor: colorStr, name: dimInfo.displayName, value: val, - valueType: dimInfo.type + valueType: dimInfo.type, + timeZone: getDimensionTimeZone(series, dimInfo) })); } else { inlineValues.push(val); inlineValueTypes.push(dimInfo.type); + inlineTimeZones.push(getDimensionTimeZone(series, dimInfo)); } } - return { inlineValues, inlineValueTypes, blocks }; + return { inlineValues, inlineValueTypes, inlineTimeZones, blocks }; +} + +function getDimensionTimeZone(series: SeriesModel, dimInfo: {coordDim?: string}): string { + const axis = dimInfo.coordDim != null + ? series.coordinateSystem?.getAxis?.(dimInfo.coordDim) + : null; + return axis && isTimeScale(axis.scale) + ? axis.scale.getTimeZone() + : series.ecModel.getTimeZone(); } diff --git a/src/component/tooltip/tooltipMarkup.ts b/src/component/tooltip/tooltipMarkup.ts index 2524f753de..f5ac68c5a5 100644 --- a/src/component/tooltip/tooltipMarkup.ts +++ b/src/component/tooltip/tooltipMarkup.ts @@ -169,6 +169,9 @@ export interface TooltipMarkupNameValueBlock extends TooltipMarkupBlock { // If needs to display original string with numeric guessing, set as 'ordinal'. // If both `value` and `valueType` are array, each valueType[i] cooresponds to value[i]. valueType?: DimensionType | DimensionType[]; + // Time zone for a temporal value. If both are arrays, each timeZone[i] + // corresponds to value[i]. + timeZone?: string | string[]; // If `noName` or `noValue` is `true`, do not display name or value. // Otherwise, always display them even if they are // null/undefined/NaN/''... (displayed as '-'). @@ -293,7 +296,7 @@ function buildSection( return subMarkupText; } - const displayableHeader = makeValueReadable(fragment.header, 'ordinal', ctx.useUTC); + const displayableHeader = makeValueReadable(fragment.header, 'ordinal', ctx.timeZone); const {nameStyle} = getTooltipTextStyle(toolTipTextStyle, ctx.renderMode); const tooltipLineHeight = getTooltipLineHeight(toolTipTextStyle); if (ctx.renderMode === 'richText') { @@ -323,11 +326,14 @@ function buildNameValue( const noValue = fragment.noValue; const noMarker = !fragment.markerType; const name = fragment.name; - const useUTC = ctx.useUTC; + const timeZone = ctx.timeZone; + const valueTimeZone = fragment.timeZone; const valueFormatter = fragment.valueFormatter || ctx.valueFormatter || ((value) => { value = isArray(value) ? value : [value]; return map(value as unknown[], (val, idx) => makeValueReadable( - val, isArray(valueTypeOption) ? valueTypeOption[idx] : valueTypeOption, useUTC + val, + isArray(valueTypeOption) ? valueTypeOption[idx] : valueTypeOption, + (isArray(valueTimeZone) ? valueTimeZone[idx] : valueTimeZone) || timeZone )); }); @@ -344,7 +350,7 @@ function buildNameValue( ); const readableName = noName ? '' - : makeValueReadable(name, 'ordinal', useUTC); + : makeValueReadable(name, 'ordinal', timeZone); const valueTypeOption = fragment.valueType; const readableValueList = noValue ? [] @@ -376,7 +382,7 @@ function buildNameValue( } interface TooltipMarkupBuildContext { - useUTC: boolean; + timeZone: string; renderMode: TooltipRenderMode; orderMode: TooltipOrderMode; markupStyleCreator: TooltipMarkupStyleCreator; @@ -392,7 +398,7 @@ export function buildTooltipMarkup( markupStyleCreator: TooltipMarkupStyleCreator, renderMode: TooltipRenderMode, orderMode: TooltipOrderMode, - useUTC: boolean, + timeZone: string, toolTipTextStyle: TooltipOption['textStyle'] ): MarkupText { if (!fragment) { @@ -401,7 +407,7 @@ export function buildTooltipMarkup( const builder = getBuilder(fragment); const ctx: TooltipMarkupBuildContext = { - useUTC: useUTC, + timeZone: timeZone, renderMode: renderMode, orderMode: orderMode, markupStyleCreator: markupStyleCreator, diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index 058dea900e..fec41c640e 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -241,6 +241,11 @@ export interface LogAxisBaseOption extends NumericAxisBaseOptionCommon { } export interface TimeAxisBaseOption extends NumericAxisBaseOptionCommon { type?: 'time'; + /** + * IANA time zone used to align and format ticks on this axis. + * Overrides the global `timeZone` option. + */ + timeZone?: string; axisLabel?: AxisLabelOption<'time'>; } diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index c61b975870..fff3b2671b 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -39,12 +39,13 @@ import { OptionAxisType, AXIS_TYPES, CategoryTickLabelSplitBuildingOption, + TimeAxisBaseOption } from './axisCommonTypes'; import SeriesData from '../data/SeriesData'; import { getStackedDimension } from '../data/helper/dataStackHelper'; import { Dictionary, DimensionName, NullUndefined, ScaleTick } from '../util/types'; import { ScaleExtentFixMinMax } from './scaleRawExtentInfo'; -import { parseTimeAxisLabelFormatter } from '../util/time'; +import { parseTimeAxisLabelFormatter, validateTimeZone } from '../util/time'; import { getScaleBreakHelper } from '../scale/break'; import { error } from '../util/log'; import { @@ -87,6 +88,7 @@ export function createScaleByModel( {type?: string} & Pick & Pick + & Pick > & Partial { private _optionManager: OptionManager; + private _timeZone: string; + private _componentsMap: HashMap; /** @@ -218,7 +221,6 @@ class GlobalModel extends Model { opts: GlobalModelSetOptionOpts, optionPreprocessorFuncs: OptionPreprocessor[] ): void { - if (__DEV__) { assert(option != null, 'option is null/undefined'); assert( @@ -314,6 +316,18 @@ class GlobalModel extends Model { opt: InnerSetOptionOpts ): void { const option = this.option; + const timeZone = newOption.timeZone != null + ? newOption.timeZone + : option.timeZone; + if (timeZone != null) { + this._timeZone = validateTimeZone(timeZone); + } + else if (newOption.useUTC != null) { + this._timeZone = resolveTimeZone(newOption); + } + else if (this._timeZone == null) { + this._timeZone = resolveTimeZone(option); + } const componentsMap = this._componentsMap; const componentsCount = this._componentsCount; const newCmptTypes: ComponentMainType[] = []; @@ -861,6 +875,10 @@ echarts.use([${seriesImportName}]);`); return (this._seriesIndices || []).slice(); } + getTimeZone(): string { + return this._timeZone; + } + filterSeries( cb: (this: T, series: SeriesModel, rawSeriesIndex: number) => boolean, context?: T @@ -933,6 +951,7 @@ echarts.use([${seriesImportName}]);`); // i.e. `chart.setOption(chart.getModel().option);` is forbidden. ecModel.option = {} as ECUnitOption; ecModel.option[OPTION_INNER_KEY] = OPTION_INNER_VALUE; + ecModel._timeZone = null; // Init with series: [], in case of calling findSeries method // before series initialized. @@ -957,6 +976,12 @@ echarts.use([${seriesImportName}]);`); })(); } +function resolveTimeZone(option: ECUnitOption): string { + return option.timeZone != null + ? validateTimeZone(option.timeZone) + : option.useUTC ? 'UTC' : getSystemTimeZone(); +} + /** * Either `mainType` or `query` should be provided. diff --git a/src/scale/Time.ts b/src/scale/Time.ts index b787ce2869..e2a5ca752f 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -28,11 +28,11 @@ */ -// [About UTC and local time zone]: +// [About input values and time zones]: // In most cases, `number.parseDate` will treat input data string as local time -// (except time zone is specified in time string). And `format.formateTime` returns -// local time by default. option.useUTC is false by default. This design has -// considered these common cases: +// (except when a time zone is specified in the string). The resolved time zone +// affects tick calendar arithmetic and formatting, but does not reinterpret input. +// This design preserves these common cases: // (1) Time that is persistent in server is in UTC, but it is needed to be displayed // in local time by default. // (2) By default, the input data string (e.g., '2011-01-02') should be displayed @@ -54,25 +54,10 @@ import { getPrimaryTimeUnit, isPrimaryTimeUnit, getDefaultFormatPrecisionOfInterval, - fullYearGetterName, - monthSetterName, - fullYearSetterName, - dateSetterName, - hoursGetterName, - hoursSetterName, - minutesSetterName, - secondsSetterName, - millisecondsSetterName, - monthGetterName, - dateGetterName, - minutesGetterName, - secondsGetterName, - millisecondsGetterName, - JSDateGetterNames, - JSDateSetterNames, getUnitFromValue, primaryTimeUnits, - roundTime + roundTime, + addTimeInTimeZone } from '../util/time'; import { ensureValidSplitNumber } from './helper'; import Scale, { ScaleGetTicksOpt } from './Scale'; @@ -113,7 +98,7 @@ const bisect = function ( type TimeScaleSetting = { locale: Model; - useUTC: boolean; + timeZone: string; breakOption: AxisBreakOption[] | NullUndefined; }; @@ -127,7 +112,7 @@ class TimeScale extends Scale { readonly type = 'time' as const; private _locale: Model; - private _useUTC: boolean; + private _timeZone: string; private _approxInterval: number; private _interval: number; @@ -138,7 +123,7 @@ class TimeScale extends Scale { this.parse = TimeScale.parse; this._locale = setting.locale; - this._useUTC = setting.useUTC; + this._timeZone = setting.timeZone; this._interval = 0; const breakParsed = simplyParseBreakOption(this, setting); @@ -157,7 +142,7 @@ class TimeScale extends Scale { fullLeveledFormatter[ getDefaultFormatPrecisionOfInterval(getPrimaryTimeUnit(this._minLevelUnit)) ] || fullLeveledFormatter.second, - this._useUTC, + this._timeZone, this._locale ); } @@ -167,7 +152,7 @@ class TimeScale extends Scale { idx: number, labelFormatter: TimeAxisLabelFormatterParsed ): string { - return leveledFormat(tick, idx, labelFormatter, this._locale, this._useUTC); + return leveledFormat(tick, idx, labelFormatter, this._locale, this._timeZone); } getTicks(opt?: ScaleGetTicksOpt): TimeScaleTick[] { @@ -185,7 +170,7 @@ class TimeScale extends Scale { return ticks; } - const useUTC = this._useUTC; + const timeZone = this._timeZone; if (brkAvailable && opt.breakTicks === 'only_break') { getScaleBreakHelper().addBreaksToTicks(ticks, brk.breaks, extent); @@ -195,7 +180,7 @@ class TimeScale extends Scale { ticks = createIntervalTicks( this._minLevelUnit, this._approxInterval, - useUTC, + timeZone, extent, getScaleLinearSpanEffective(this), brk @@ -224,13 +209,13 @@ class TimeScale extends Scale { getScaleBreakHelper().addBreaksToTicks(ticks, brk.breaks, extent, trimmedBrk => { // @see `parseTimeAxisLabelFormatterDictionary`. const lowerBrkUnitIndex = Math.max( - indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmin, useUTC)), - indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmax, useUTC)), + indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmin, timeZone)), + indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmax, timeZone)) ); let upperBrkUnitIndex = 0; for (let unitIdx = 0; unitIdx < primaryTimeUnits.length; unitIdx++) { if (!isPrimaryUnitValueAndGreaterSame( - primaryTimeUnits[unitIdx], trimmedBrk.vmin, trimmedBrk.vmax, useUTC + primaryTimeUnits[unitIdx], trimmedBrk.vmin, trimmedBrk.vmax, timeZone )) { upperBrkUnitIndex = unitIdx; break; @@ -268,6 +253,10 @@ class TimeScale extends Scale { this._minLevelUnit = opt.minLevelUnit; } + getTimeZone(): string { + return this._timeZone; + } + static parse(val: number | string | Date): number { // `val` might be a float (e.g., calculated from percent), so call `round`. return isNumber(val) ? Math.round(val) : +numberUtil.parseDate(val); @@ -302,106 +291,12 @@ function isPrimaryUnitValueAndGreaterSame( unit: PrimaryTimeUnit, valueA: number, valueB: number, - isUTC: boolean + timeZone: string ): boolean { - return roundTime(new Date(valueA), unit, isUTC).getTime() - === roundTime(new Date(valueB), unit, isUTC).getTime(); + return roundTime(new Date(valueA), unit, timeZone).getTime() + === roundTime(new Date(valueB), unit, timeZone).getTime(); } -// function isUnitValueSame( -// unit: PrimaryTimeUnit, -// valueA: number, -// valueB: number, -// isUTC: boolean -// ): boolean { -// const dateA = numberUtil.parseDate(valueA) as any; -// const dateB = numberUtil.parseDate(valueB) as any; - -// const isSame = (unit: PrimaryTimeUnit) => { -// return getUnitValue(dateA, unit, isUTC) -// === getUnitValue(dateB, unit, isUTC); -// }; -// const isSameYear = () => isSame('year'); -// // const isSameHalfYear = () => isSameYear() && isSame('half-year'); -// // const isSameQuater = () => isSameYear() && isSame('quarter'); -// const isSameMonth = () => isSameYear() && isSame('month'); -// const isSameDay = () => isSameMonth() && isSame('day'); -// // const isSameHalfDay = () => isSameDay() && isSame('half-day'); -// const isSameHour = () => isSameDay() && isSame('hour'); -// const isSameMinute = () => isSameHour() && isSame('minute'); -// const isSameSecond = () => isSameMinute() && isSame('second'); -// const isSameMilliSecond = () => isSameSecond() && isSame('millisecond'); - -// switch (unit) { -// case 'year': -// return isSameYear(); -// case 'month': -// return isSameMonth(); -// case 'day': -// return isSameDay(); -// case 'hour': -// return isSameHour(); -// case 'minute': -// return isSameMinute(); -// case 'second': -// return isSameSecond(); -// case 'millisecond': -// return isSameMilliSecond(); -// } -// } - -// 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; return approxInterval > 16 ? 16 @@ -444,20 +339,20 @@ function getMillisecondsInterval(approxInterval: number) { // e.g., if the input unit is 'day', start calculate ticks from the first day of // that month to make ticks "nice". -function getFirstTimestampOfUnit(timestamp: number, unitName: TimeUnit, isUTC: boolean) { +function getFirstTimestampOfUnit(timestamp: number, unitName: TimeUnit, timeZone: string) { const upperUnitIdx = Math.max(0, indexOf(primaryTimeUnits, unitName) - 1); - return roundTime(new Date(timestamp), primaryTimeUnits[upperUnitIdx], isUTC).getTime(); + return roundTime(new Date(timestamp), primaryTimeUnits[upperUnitIdx], timeZone).getTime(); } function createEstimateNiceMultiple( - setMethodName: JSDateSetterNames, + timeUnit: PrimaryTimeUnit, dateMethodInterval: number, + timeZone: string ) { - const tmpDate = new Date(0); - tmpDate[setMethodName](1); - const tmpTime = tmpDate.getTime(); - tmpDate[setMethodName](1 + dateMethodInterval); - const approxTimeInterval = tmpDate.getTime() - tmpTime; + const tmpTime = addTimeInTimeZone(0, timeUnit, 1, timeZone); + const approxTimeInterval = addTimeInTimeZone( + tmpTime, timeUnit, dateMethodInterval, timeZone + ) - tmpTime; return (tickVal: number, targetValue: number) => { // Only in month that accurate result can not get by division of @@ -472,7 +367,7 @@ function createEstimateNiceMultiple( function createIntervalTicks( bottomUnitName: TimeUnit, approxInterval: number, - isUTC: boolean, + timeZone: string, extent: number[], innermostSpan: number, brk: BreakScaleMapper | NullUndefined, @@ -494,19 +389,12 @@ function createIntervalTicks( interval: number, minTimestamp: number, maxTimestamp: number, - getMethodName: JSDateGetterNames, - setMethodName: JSDateSetterNames, - isDate: boolean, + timeUnit: PrimaryTimeUnit, out: InnerTimeTick[] ) { - const estimateNiceMultiple = createEstimateNiceMultiple(setMethodName, interval); + const estimateNiceMultiple = createEstimateNiceMultiple(timeUnit, interval, timeZone); let dateTime = minTimestamp; - const date = new Date(dateTime); - - // if (isDate) { - // d -= 1; // Starts with 0; PENDING - // } while (dateTime < maxTimestamp && dateTime <= extent[1]) { out.push({ @@ -520,14 +408,14 @@ function createIntervalTicks( break; } - date[setMethodName](date[getMethodName]() + interval); - dateTime = date.getTime(); + dateTime = addTimeInTimeZone(dateTime, timeUnit, interval, timeZone); if (brk) { const moreMultiple = brk.calcNiceTickMultiple(dateTime, estimateNiceMultiple); if (moreMultiple > 0) { - date[setMethodName](date[getMethodName]() + moreMultiple * interval); - dateTime = date.getTime(); + dateTime = addTimeInTimeZone( + dateTime, timeUnit, moreMultiple * interval, timeZone + ); } } } @@ -548,13 +436,15 @@ function createIntervalTicks( const newAddedTicks: ScaleTick[] = []; const isFirstLevel = !lastLevelTicks.length; - if (isPrimaryUnitValueAndGreaterSame(getPrimaryTimeUnit(unitName), extent[0], extent[1], isUTC)) { + if (isPrimaryUnitValueAndGreaterSame( + getPrimaryTimeUnit(unitName), extent[0], extent[1], timeZone + )) { return; } if (isFirstLevel) { lastLevelTicks = [{ - value: getFirstTimestampOfUnit(extent[0], unitName, isUTC), + value: getFirstTimestampOfUnit(extent[0], unitName, timeZone) }, { value: extent[1] }]; @@ -568,52 +458,42 @@ function createIntervalTicks( } let interval: number; - let getterName: JSDateGetterNames; - let setterName: JSDateSetterNames; - let isDate = false; + let timeUnit: PrimaryTimeUnit; switch (unitName) { case 'year': interval = Math.max(1, Math.round(approxInterval / ONE_DAY / 365)); - getterName = fullYearGetterName(isUTC); - setterName = fullYearSetterName(isUTC); + timeUnit = 'year'; break; case 'half-year': case 'quarter': case 'month': interval = getMonthInterval(approxInterval); - getterName = monthGetterName(isUTC); - setterName = monthSetterName(isUTC); + timeUnit = 'month'; break; case 'week': // PENDING If week is added. Ignore day. case 'half-week': case 'day': interval = getDateInterval(approxInterval, 31); // Use 32 days and let interval been 16 - getterName = dateGetterName(isUTC); - setterName = dateSetterName(isUTC); - isDate = true; + timeUnit = 'day'; break; case 'half-day': case 'quarter-day': case 'hour': interval = getHourInterval(approxInterval); - getterName = hoursGetterName(isUTC); - setterName = hoursSetterName(isUTC); + timeUnit = 'hour'; break; case 'minute': interval = getMinutesAndSecondsInterval(approxInterval, true); - getterName = minutesGetterName(isUTC); - setterName = minutesSetterName(isUTC); + timeUnit = 'minute'; break; case 'second': interval = getMinutesAndSecondsInterval(approxInterval, false); - getterName = secondsGetterName(isUTC); - setterName = secondsSetterName(isUTC); + timeUnit = 'second'; break; case 'millisecond': interval = getMillisecondsInterval(approxInterval); - getterName = millisecondsGetterName(isUTC); - setterName = millisecondsSetterName(isUTC); + timeUnit = 'millisecond'; break; } @@ -622,7 +502,7 @@ function createIntervalTicks( // data zoom and axis breaks. Thus trim them here. if (endTick >= extent[0] && startTick <= extent[1]) { addTicksInSpan( - interval, startTick, endTick, getterName, setterName, isDate, newAddedTicks + interval, startTick, endTick, timeUnit, newAddedTicks ); } @@ -698,7 +578,7 @@ function createIntervalTicks( for (let i = 0; i < levelsTicksInExtent.length; ++i) { const levelTicks = levelsTicksInExtent[i]; for (let k = 0; k < levelTicks.length; ++k) { - const unit = getUnitFromValue(levelTicks[k].value, isUTC); + const unit = getUnitFromValue(levelTicks[k].value, timeZone); ticks.push({ value: levelTicks[k].value, time: { @@ -717,8 +597,8 @@ function createIntervalTicks( const currMinTick = ticks[0]; const currMaxTick = ticks[ticks.length - 1]; - const extent0Unit = getUnitFromValue(extent[0], isUTC); - const extent1Unit = getUnitFromValue(extent[1], isUTC); + const extent0Unit = getUnitFromValue(extent[0], timeZone); + const extent1Unit = getUnitFromValue(extent[1], timeZone); if (!currMinTick || currMinTick.value > extent[0]) { ticks.unshift({ value: extent[0], diff --git a/src/util/format.ts b/src/util/format.ts index 6378072327..f85d0c074a 100644 --- a/src/util/format.ts +++ b/src/util/format.ts @@ -23,7 +23,7 @@ import { parseDate, isNumeric, numericToNumber, isNullableNumberFinite } from '. import { TooltipRenderMode, ColorString, ZRColor, DimensionType } from './types'; import { Dictionary } from 'zrender/src/core/types'; import { GradientObject } from 'zrender/src/graphic/Gradient'; -import { format as timeFormat, pad } from './time'; +import { format as timeFormat, getSystemTimeZone, pad } from './time'; import { deprecateReplaceLog } from './log'; /** @@ -64,8 +64,25 @@ export { encodeHTML }; export function makeValueReadable( value: unknown, valueType: DimensionType, - useUTC: boolean + timeZone: string +): string; +/** + * @deprecated Pass a time zone string instead of the legacy `isUTC` boolean. + */ +export function makeValueReadable( + value: unknown, + valueType: DimensionType, + isUTC: boolean +): string; +export function makeValueReadable( + value: unknown, + valueType: DimensionType, + timeZoneOrUTC: string | boolean ): string { + if (__DEV__ && typeof timeZoneOrUTC === 'boolean') { + deprecateReplaceLog('isUTC boolean parameter', 'timeZone string parameter', 'makeValueReadable'); + } + const USER_READABLE_DEFUALT_TIME_PATTERN = '{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}'; function stringToUserReadable(str: string): string { @@ -80,7 +97,10 @@ export function makeValueReadable( if (isTypeTime || isValueDate) { const date = isTypeTime ? parseDate(value) : value; if (!isNaN(+date)) { - return timeFormat(date, USER_READABLE_DEFUALT_TIME_PATTERN, useUTC); + const timeZone = typeof timeZoneOrUTC === 'string' + ? timeZoneOrUTC + : timeZoneOrUTC ? 'UTC' : getSystemTimeZone(); + return timeFormat(date, USER_READABLE_DEFUALT_TIME_PATTERN, timeZone); } else if (isValueDate) { return '-'; diff --git a/src/util/time.ts b/src/util/time.ts index 6c6ed6bfe8..a805c874ed 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -18,6 +18,7 @@ */ import * as zrUtil from 'zrender/src/core/util'; +import LRU from 'zrender/src/core/LRU'; import { TimeAxisLabelFormatterDictionary, TimeAxisLabelFormatterDictionaryOption, @@ -32,6 +33,7 @@ import {NullUndefined, ScaleTick} from './types'; import { getDefaultLocaleModel, getLocaleModel, SYSTEM_LANG, LocaleOption } from '../core/locale'; import Model from '../model/Model'; import { getScaleBreakHelper } from '../scale/break'; +import { deprecateReplaceLog } from './log'; export const ONE_SECOND = 1000; export const ONE_MINUTE = ONE_SECOND * 60; @@ -274,24 +276,42 @@ export function getDefaultFormatPrecisionOfInterval(timeUnit: PrimaryTimeUnit): } } +export function format( + // Note: The result based on `timeZone` can be totally different, which can not be just simply + // substituted by the result without `timeZone`. So we make the param `timeZone` mandatory. + time: unknown, template: string, timeZone: string, lang?: string | Model +): string; +/** + * @deprecated Pass a time zone string instead of the legacy `isUTC` boolean. + */ export function format( // Note: The result based on `isUTC` are totally different, which can not be just simply // substituted by the result without `isUTC`. So we make the param `isUTC` mandatory. time: unknown, template: string, isUTC: boolean, lang?: string | Model +): string; +export function format( + time: unknown, template: string, timeZoneOrUTC: string | boolean, lang?: string | Model ): string { + if (__DEV__ && typeof timeZoneOrUTC === 'boolean') { + deprecateReplaceLog('isUTC boolean parameter', 'timeZone string parameter', 'echarts.time.format'); + } const date = numberUtil.parseDate(time); - const y = date[fullYearGetterName(isUTC)](); - const M = date[monthGetterName(isUTC)]() + 1; + const timeZone = normalizeTimeZone(timeZoneOrUTC); + const parts = getTimeZoneParts(date.getTime(), timeZone); + const y = parts.year; + const M = parts.month; const q = Math.floor((M - 1) / 3) + 1; - const d = date[dateGetterName(isUTC)](); - const e = date['get' + (isUTC ? 'UTC' : '') + 'Day' as 'getDay' | 'getUTCDay'](); - const H = date[hoursGetterName(isUTC)](); + const d = parts.day; + const e = parts.dayOfWeek; + const H = parts.hours; const h = (H - 1) % 12 + 1; - const m = date[minutesGetterName(isUTC)](); - const s = date[secondsGetterName(isUTC)](); - const S = date[millisecondsGetterName(isUTC)](); + const m = parts.minutes; + const s = parts.seconds; + const S = parts.milliseconds; const a = H >= 12 ? 'pm' : 'am'; const A = a.toUpperCase(); + const Z = timeZone === 'UTC' ? 'Z' : formatTimeZoneOffset(parts.offsetMinutes, false); + const ZZ = timeZone === 'UTC' ? 'Z' : formatTimeZoneOffset(parts.offsetMinutes, true); const localeModel = lang instanceof Model ? lang : getLocaleModel(lang || SYSTEM_LANG) || getDefaultLocaleModel(); @@ -325,16 +345,53 @@ export function format( .replace(/{ss}/g, pad(s, 2)) .replace(/{s}/g, s + '') .replace(/{SSS}/g, pad(S, 3)) - .replace(/{S}/g, S + ''); + .replace(/{S}/g, S + '') + .replace(/{ZZ}/g, ZZ) + .replace(/{Z}/g, Z); } +function formatTimeZoneOffset(offsetMinutes: number, padded: boolean): string { + if (!offsetMinutes) { + return padded ? '+00:00' : '+0'; + } + + const sign = offsetMinutes < 0 ? '-' : '+'; + const absoluteOffset = Math.abs(offsetMinutes); + const hours = Math.floor(absoluteOffset / 60); + const minutes = absoluteOffset % 60; + return sign + + (padded ? pad(hours, 2) : hours) + + (padded || minutes ? ':' + pad(minutes, 2) : ''); +} + +export function leveledFormat( + tick: ScaleTick, + idx: number, + formatter: TimeAxisLabelFormatterParsed, + lang: string | Model, + timeZone: string +): string; +/** + * @deprecated Pass a time zone string instead of the legacy `isUTC` boolean. + */ export function leveledFormat( tick: ScaleTick, idx: number, formatter: TimeAxisLabelFormatterParsed, lang: string | Model, isUTC: boolean -) { +): string; +export function leveledFormat( + tick: ScaleTick, + idx: number, + formatter: TimeAxisLabelFormatterParsed, + lang: string | Model, + timeZoneOrUTC: string | boolean +): string { + if (__DEV__ && typeof timeZoneOrUTC === 'boolean') { + deprecateReplaceLog('isUTC boolean parameter', 'timeZone string parameter', 'leveledFormat'); + } + const timeZone = normalizeTimeZone(timeZoneOrUTC); let template = null; if (zrUtil.isString(formatter)) { // Single formatter for all units at all levels @@ -359,25 +416,40 @@ export function leveledFormat( } else { // tick may be from customTicks or timeline therefore no tick.time. - const unit = getUnitFromValue(tick.value, isUTC); + const unit = getUnitFromValue(tick.value, timeZone); template = formatter[unit][unit][0]; } } - return format(new Date(tick.value), template, isUTC, lang); + return format(new Date(tick.value), template, timeZone, lang); } +export function getUnitFromValue( + value: number | string | Date, + timeZone: string +): PrimaryTimeUnit; +/** + * @deprecated Pass a time zone string instead of the legacy `isUTC` boolean. + */ export function getUnitFromValue( value: number | string | Date, isUTC: boolean +): PrimaryTimeUnit; +export function getUnitFromValue( + value: number | string | Date, + timeZoneOrUTC: string | boolean ): PrimaryTimeUnit { + if (__DEV__ && typeof timeZoneOrUTC === 'boolean') { + deprecateReplaceLog('isUTC boolean parameter', 'timeZone string parameter', 'getUnitFromValue'); + } const date = numberUtil.parseDate(value); - const M = (date as any)[monthGetterName(isUTC)]() + 1; - const d = (date as any)[dateGetterName(isUTC)](); - const h = (date as any)[hoursGetterName(isUTC)](); - const m = (date as any)[minutesGetterName(isUTC)](); - const s = (date as any)[secondsGetterName(isUTC)](); - const S = (date as any)[millisecondsGetterName(isUTC)](); + const parts = getTimeZoneParts(date.getTime(), normalizeTimeZone(timeZoneOrUTC)); + const M = parts.month; + const d = parts.day; + const h = parts.hours; + const m = parts.minutes; + const s = parts.seconds; + const S = parts.milliseconds; const isSecond = S === 0; const isMinute = isSecond && s === 0; @@ -409,40 +481,6 @@ export function getUnitFromValue( } } -// export function getUnitValue( -// value: number | Date, -// unit: TimeUnit, -// isUTC: boolean -// ) : number { -// const date = zrUtil.isNumber(value) -// ? numberUtil.parseDate(value) -// : value; -// unit = unit || getUnitFromValue(value, isUTC); - -// switch (unit) { -// case 'year': -// return date[fullYearGetterName(isUTC)](); -// case 'half-year': -// return date[monthGetterName(isUTC)]() >= 6 ? 1 : 0; -// case 'quarter': -// return Math.floor((date[monthGetterName(isUTC)]() + 1) / 4); -// case 'month': -// return date[monthGetterName(isUTC)](); -// case 'day': -// return date[dateGetterName(isUTC)](); -// case 'half-day': -// return date[hoursGetterName(isUTC)]() / 24; -// case 'hour': -// return date[hoursGetterName(isUTC)](); -// case 'minute': -// return date[minutesGetterName(isUTC)](); -// case 'second': -// return date[secondsGetterName(isUTC)](); -// case 'millisecond': -// return date[millisecondsGetterName(isUTC)](); -// } -// } - /** * e.g., * If timeUnit is 'year', return the Jan 1st 00:00:00 000 of that year. @@ -450,76 +488,526 @@ export function getUnitFromValue( * * @return The input date. */ -export function roundTime(date: Date, timeUnit: PrimaryTimeUnit, isUTC: boolean): Date { - switch (timeUnit) { - case 'year': - date[monthSetterName(isUTC)](0); - case 'month': - date[dateSetterName(isUTC)](1); - case 'day': - date[hoursSetterName(isUTC)](0); - case 'hour': - date[minutesSetterName(isUTC)](0); - case 'minute': - date[secondsSetterName(isUTC)](0); - case 'second': - date[millisecondsSetterName(isUTC)](0); +export function roundTime( + date: Date, + timeUnit: PrimaryTimeUnit, + timeZone: string +): Date; +/** + * @deprecated Pass a time zone string instead of the legacy `isUTC` boolean. + */ +export function roundTime( + date: Date, + timeUnit: PrimaryTimeUnit, + isUTC: boolean +): Date; +export function roundTime( + date: Date, + timeUnit: PrimaryTimeUnit, + timeZoneOrUTC: string | boolean +): Date { + if (__DEV__ && typeof timeZoneOrUTC === 'boolean') { + deprecateReplaceLog('isUTC boolean parameter', 'timeZone string parameter', 'echarts.time.roundTime'); } + date.setTime(roundTimeInTimeZone( + date.getTime(), timeUnit, normalizeTimeZone(timeZoneOrUTC) + )); return date; } +function normalizeTimeZone(timeZoneOrUTC: string | boolean): string { + return typeof timeZoneOrUTC === 'string' + ? timeZoneOrUTC + : timeZoneOrUTC ? 'UTC' : getSystemTimeZone(); +} + +/** + * @deprecated Use `getTimeZoneParts` to read values in a specific time zone. + */ export function fullYearGetterName(isUTC: boolean) { return isUTC ? 'getUTCFullYear' : 'getFullYear'; } +/** + * @deprecated Use `getTimeZoneParts` to read values in a specific time zone. + */ export function monthGetterName(isUTC: boolean) { return isUTC ? 'getUTCMonth' : 'getMonth'; } +/** + * @deprecated Use `getTimeZoneParts` to read values in a specific time zone. + */ export function dateGetterName(isUTC: boolean) { return isUTC ? 'getUTCDate' : 'getDate'; } +/** + * @deprecated Use `getTimeZoneParts` to read values in a specific time zone. + */ export function hoursGetterName(isUTC: boolean) { return isUTC ? 'getUTCHours' : 'getHours'; } +/** + * @deprecated Use `getTimeZoneParts` to read values in a specific time zone. + */ export function minutesGetterName(isUTC: boolean) { return isUTC ? 'getUTCMinutes' : 'getMinutes'; } +/** + * @deprecated Use `getTimeZoneParts` to read values in a specific time zone. + */ export function secondsGetterName(isUTC: boolean) { return isUTC ? 'getUTCSeconds' : 'getSeconds'; } +/** + * @deprecated Use `getTimeZoneParts` to read values in a specific time zone. + */ export function millisecondsGetterName(isUTC: boolean) { return isUTC ? 'getUTCMilliseconds' : 'getMilliseconds'; } +/** + * @deprecated Use time-zone-aware utilities instead of selecting a local/UTC `Date` setter. + */ export function fullYearSetterName(isUTC: boolean) { return isUTC ? 'setUTCFullYear' : 'setFullYear'; } +/** + * @deprecated Use time-zone-aware utilities instead of selecting a local/UTC `Date` setter. + */ export function monthSetterName(isUTC: boolean) { return isUTC ? 'setUTCMonth' : 'setMonth'; } +/** + * @deprecated Use time-zone-aware utilities instead of selecting a local/UTC `Date` setter. + */ export function dateSetterName(isUTC: boolean) { return isUTC ? 'setUTCDate' : 'setDate'; } +/** + * @deprecated Use time-zone-aware utilities instead of selecting a local/UTC `Date` setter. + */ export function hoursSetterName(isUTC: boolean) { return isUTC ? 'setUTCHours' : 'setHours'; } +/** + * @deprecated Use time-zone-aware utilities instead of selecting a local/UTC `Date` setter. + */ export function minutesSetterName(isUTC: boolean) { return isUTC ? 'setUTCMinutes' : 'setMinutes'; } +/** + * @deprecated Use time-zone-aware utilities instead of selecting a local/UTC `Date` setter. + */ export function secondsSetterName(isUTC: boolean) { return isUTC ? 'setUTCSeconds' : 'setSeconds'; } +/** + * @deprecated Use time-zone-aware utilities instead of selecting a local/UTC `Date` setter. + */ export function millisecondsSetterName(isUTC: boolean) { return isUTC ? 'setUTCMilliseconds' : 'setMilliseconds'; } + +interface TimeZoneDateParts { + year: number; + // Calendar month, from 1 (January) to 12 (December), matching Intl/Temporal. + month: number; + day: number; + dayOfWeek: number; + hours: number; + minutes: number; + seconds: number; + milliseconds: number; + // Same sign as an ISO offset: UTC-05:00 is -300 and UTC+05:30 is 330. + offsetMinutes: number; +} + +type TimeZoneWallTimeParts = Omit; + +interface TimeZoneDayInfo { + offsetBefore: number; + transitionTimestamp?: number; + offsetAfter: number; +} + +// Required for IANA time zones. Legacy environments can provide an Intl polyfill. +// eslint-disable-next-line no-restricted-globals +const intl = Intl; +type TimeZoneFormatter = ReturnType; +type TimeZoneFormatterOptions = NonNullable[1]>; + +const MINUTES_PER_DAY = ONE_DAY / ONE_MINUTE; +const TIME_ZONE_FORMATTER_CACHE_SIZE = 32; +const TIME_ZONE_DAY_CACHE_SIZE = 4 * 1024; +const formatterCache = new LRU(TIME_ZONE_FORMATTER_CACHE_SIZE); +const timeZoneDayCache = new LRU(TIME_ZONE_DAY_CACHE_SIZE); +let systemTimeZone: string; + +function getFormatter(timeZone: string): TimeZoneFormatter { + const cacheKey = 'timeZone:' + timeZone; + let formatter = formatterCache.get(cacheKey); + if (!formatter) { + try { + formatter = new intl.DateTimeFormat( + 'en-US-u-ca-gregory-nu-latn', + { + timeZone: timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + // Use 00-23 so midnight is represented as hour 00. + hourCycle: 'h23' + } as TimeZoneFormatterOptions + ); + } + catch (err) { + throw new Error(`Invalid time zone: ${timeZone}`); + } + formatterCache.put(cacheKey, formatter); + } + return formatter; +} + +function makeUTCTimestamp(parts: TimeZoneWallTimeParts): number { + const date = new Date(0); + date.setUTCFullYear(parts.year, parts.month - 1, parts.day); + date.setUTCHours(parts.hours, parts.minutes, parts.seconds, parts.milliseconds); + return date.getTime(); +} + +function getUTCParts(timestamp: number): TimeZoneDateParts { + const date = new Date(timestamp); + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + dayOfWeek: date.getUTCDay(), + hours: date.getUTCHours(), + minutes: date.getUTCMinutes(), + seconds: date.getUTCSeconds(), + milliseconds: date.getUTCMilliseconds(), + offsetMinutes: 0 + }; +} + +function getLocalParts(timestamp: number): TimeZoneDateParts { + const date = new Date(timestamp); + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + dayOfWeek: date.getDay(), + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), + milliseconds: date.getMilliseconds(), + offsetMinutes: -date.getTimezoneOffset() + }; +} + +function getFormattedTimeZoneParts( + timestamp: number, + timeZone: string +): TimeZoneWallTimeParts { + const values: {[type: string]: number} = {}; + const parts = getFormatter(timeZone).formatToParts(timestamp); + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part.type !== 'literal') { + values[part.type] = +part.value; + } + } + + let hours = values.hour; + if (hours === 24) { + hours = 0; + } + return { + year: values.year, + month: values.month, + day: values.day, + hours: hours, + minutes: values.minute, + seconds: values.second, + // Offset probes and transition searches deliberately use minute precision. + milliseconds: 0 + }; +} + +function getRawTimeZoneOffset(timestamp: number, timeZone: string): number { + const offset = makeUTCTimestamp(getFormattedTimeZoneParts(timestamp, timeZone)) - timestamp; + return Math.round(offset / ONE_MINUTE) * ONE_MINUTE; +} + +function getTimeZoneDayCacheKey(timeZone: string, dayIndex: number): string { + return timeZone + '\0' + dayIndex; +} + +function findTransitionTimestamp( + dayStart: number, + timeZone: string, + offsetBefore: number +): number { + // Time-zone offsets and modern IANA transitions use minute precision. + let leftMinute = 0; + let rightMinute = MINUTES_PER_DAY; + while (rightMinute - leftMinute > 1) { + const middleMinute = Math.floor((leftMinute + rightMinute) / 2); + if (getRawTimeZoneOffset( + dayStart + middleMinute * ONE_MINUTE, timeZone + ) === offsetBefore) { + leftMinute = middleMinute; + } + else { + rightMinute = middleMinute; + } + } + return dayStart + rightMinute * ONE_MINUTE; +} + +function getTimeZoneDayInfo(timestamp: number, timeZone: string): TimeZoneDayInfo { + const dayIndex = Math.floor(timestamp / ONE_DAY); + const cacheKey = getTimeZoneDayCacheKey(timeZone, dayIndex); + let dayInfo = timeZoneDayCache.get(cacheKey); + if (!dayInfo) { + const previousDay = timeZoneDayCache.get( + getTimeZoneDayCacheKey(timeZone, dayIndex - 1) + ); + const nextDay = timeZoneDayCache.get( + getTimeZoneDayCacheKey(timeZone, dayIndex + 1) + ); + const offsetBefore = previousDay + ? previousDay.offsetAfter + : getRawTimeZoneOffset(dayIndex * ONE_DAY, timeZone); + const offsetAfter = nextDay + ? nextDay.offsetBefore + : getRawTimeZoneOffset((dayIndex + 1) * ONE_DAY, timeZone); + dayInfo = { + offsetBefore: offsetBefore, + offsetAfter: offsetAfter + }; + // IANA transitions are separated by more than one day. Comparing UTC + // day boundaries therefore identifies the only possible transition in + // this day, without scanning every minute. + if (offsetBefore !== offsetAfter) { + dayInfo.transitionTimestamp = findTransitionTimestamp( + dayIndex * ONE_DAY, timeZone, offsetBefore + ); + } + timeZoneDayCache.put(cacheKey, dayInfo); + } + return dayInfo; +} + +export function getSystemTimeZone(): string { + return systemTimeZone || (systemTimeZone = new intl.DateTimeFormat().resolvedOptions().timeZone); +} + +export function validateTimeZone(timeZone: string): string { + if (timeZone !== 'UTC' && timeZone !== getSystemTimeZone()) { + getFormatter(timeZone); + } + return timeZone; +} + +export function getTimeZoneParts(timestamp: number, timeZone: string): TimeZoneDateParts { + if (timeZone === 'UTC') { + return getUTCParts(timestamp); + } + if (timeZone === getSystemTimeZone()) { + return getLocalParts(timestamp); + } + + const offset = getTimeZoneOffset(timestamp, timeZone); + const parts = getUTCParts(timestamp + offset); + parts.offsetMinutes = offset / ONE_MINUTE; + return parts; +} + +function getTimeZoneOffset(timestamp: number, timeZone: string): number { + if (timeZone === 'UTC') { + return 0; + } + if (timeZone === getSystemTimeZone()) { + return -new Date(timestamp).getTimezoneOffset() * ONE_MINUTE; + } + + const dayInfo = getTimeZoneDayInfo(timestamp, timeZone); + return dayInfo.transitionTimestamp == null || timestamp < dayInfo.transitionTimestamp + ? dayInfo.offsetBefore + : dayInfo.offsetAfter; +} + +function makeTimeZoneDate( + parts: TimeZoneWallTimeParts, + timeZone: string, + preferredOffsetMinutes?: number +): number { + if (timeZone === 'UTC') { + return makeUTCTimestamp(parts); + } + + const isSystemTimeZone = timeZone === getSystemTimeZone(); + let timestamp: number; + if (preferredOffsetMinutes != null) { + timestamp = makeUTCTimestamp(parts); + const preferredOffset = preferredOffsetMinutes * ONE_MINUTE; + const preferredTimestamp = timestamp - preferredOffset; + if (getTimeZoneOffset(preferredTimestamp, timeZone) === preferredOffset) { + return preferredTimestamp; + } + } + + if (isSystemTimeZone) { + const date = new Date(0); + date.setFullYear(parts.year, parts.month - 1, parts.day); + date.setHours(parts.hours, parts.minutes, parts.seconds, parts.milliseconds); + return date.getTime(); + } + + if (preferredOffsetMinutes == null) { + timestamp = makeUTCTimestamp(parts); + } + const probeDistance = 3 * ONE_DAY; + const offsets = [ + getTimeZoneOffset(timestamp - probeDistance, timeZone), + getTimeZoneOffset(timestamp, timeZone), + getTimeZoneOffset(timestamp + probeDistance, timeZone) + ]; + const uniqueOffsets: number[] = []; + for (let i = 0; i < offsets.length; i++) { + if (zrUtil.indexOf(uniqueOffsets, offsets[i]) < 0) { + uniqueOffsets.push(offsets[i]); + } + } + + let validTimestamp = Infinity; + let laterTimestamp = Infinity; + let laterDifference = Infinity; + let earlierTimestamp = -Infinity; + let earlierDifference = -Infinity; + + for (let i = 0; i < uniqueOffsets.length; i++) { + const assumedOffset = uniqueOffsets[i]; + const candidate = timestamp - assumedOffset; + const candidateOffset = getTimeZoneOffset(candidate, timeZone); + if (candidateOffset === assumedOffset) { + // Compatible disambiguation chooses the earlier instant in a fold. + validTimestamp = Math.min(validTimestamp, candidate); + continue; + } + + const difference = candidateOffset - assumedOffset; + if (difference > 0 && difference < laterDifference) { + laterDifference = difference; + laterTimestamp = candidate; + } + else if (difference < 0 && difference > earlierDifference) { + earlierDifference = difference; + earlierTimestamp = candidate; + } + } + + if (validTimestamp !== Infinity) { + return validTimestamp; + } + // Compatible disambiguation moves a nonexistent wall time forward by the gap. + if (laterTimestamp !== Infinity) { + return laterTimestamp; + } + if (earlierTimestamp !== -Infinity) { + return earlierTimestamp; + } + + throw new Error(`Unable to resolve time in time zone ${timeZone}.`); +} + +function roundTimeInTimeZone( + timestamp: number, + timeUnit: PrimaryTimeUnit, + timeZone: string +): number { + if (timeZone === 'UTC') { + const date = new Date(timestamp); + switch (timeUnit) { + case 'year': + date.setUTCMonth(0); + case 'month': + date.setUTCDate(1); + case 'day': + date.setUTCHours(0); + case 'hour': + date.setUTCMinutes(0); + case 'minute': + date.setUTCSeconds(0); + case 'second': + date.setUTCMilliseconds(0); + } + return date.getTime(); + } + + const parts = getTimeZoneParts(timestamp, timeZone); + switch (timeUnit) { + case 'year': + parts.month = 1; + case 'month': + parts.day = 1; + case 'day': + parts.hours = 0; + case 'hour': + parts.minutes = 0; + case 'minute': + parts.seconds = 0; + case 'second': + parts.milliseconds = 0; + } + return makeTimeZoneDate(parts, timeZone, parts.offsetMinutes); +} + +export function addTimeInTimeZone( + timestamp: number, + timeUnit: PrimaryTimeUnit, + amount: number, + timeZone: string +): number { + switch (timeUnit) { + case 'hour': + return timestamp + amount * ONE_HOUR; + case 'minute': + return timestamp + amount * ONE_MINUTE; + case 'second': + return timestamp + amount * ONE_SECOND; + case 'millisecond': + return timestamp + amount; + } + + const parts = getTimeZoneParts(timestamp, timeZone); + const date = new Date(makeUTCTimestamp(parts)); + switch (timeUnit) { + case 'year': + date.setUTCFullYear(date.getUTCFullYear() + amount); + break; + case 'month': + date.setUTCMonth(date.getUTCMonth() + amount); + break; + case 'day': + date.setUTCDate(date.getUTCDate() + amount); + break; + } + const normalized = getUTCParts(date.getTime()); + return makeTimeZoneDate(normalized, timeZone, parts.offsetMinutes); +} diff --git a/src/util/types.ts b/src/util/types.ts index 3496f5eacc..bb4b52df72 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -800,7 +800,15 @@ export type ECUnitOption = { backgroundColor?: ZRColor darkMode?: boolean | 'auto' textStyle?: GlobalTextStyleOption + /** + * @deprecated Use `timeZone` instead. + */ useUTC?: boolean + /** + * IANA time zone used by temporal components unless overridden locally. + * Takes precedence over the legacy `useUTC` option when both are specified. + */ + timeZone?: string hoverLayerThreshold?: number legacyViewCoordSysCenterBase?: boolean diff --git a/test/ut/spec/component/tooltip/timeZone.test.ts b/test/ut/spec/component/tooltip/timeZone.test.ts new file mode 100644 index 0000000000..dd492d688e --- /dev/null +++ b/test/ut/spec/component/tooltip/timeZone.test.ts @@ -0,0 +1,225 @@ +/* +* 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 { createChart, getECModel } from '../../../core/utHelper'; +import { EChartsType } from '@/src/echarts'; +import { normalizeTooltipFormatResult } from '@/src/model/mixin/dataFormat'; +import { + buildTooltipMarkup, + createTooltipMarkup, + TooltipMarkupNameValueBlock, + TooltipMarkupSection, + TooltipMarkupStyleCreator +} from '@/src/component/tooltip/tooltipMarkup'; + +describe('tooltip_timeZone', function () { + + let chart: EChartsType; + + beforeEach(function () { + chart = createChart(); + }); + + afterEach(function () { + chart.dispose(); + }); + + it('formats each temporal series dimension in its axis time zone', function () { + const xValue = Date.parse('2024-07-15T08:00:00.000-04:00'); + const yValue = Date.parse('2024-07-15T15:00:00.000+02:00'); + chart.setOption({ + timeZone: 'UTC', + xAxis: { type: 'time', timeZone: 'America/New_York' }, + yAxis: { type: 'time', timeZone: 'Europe/Paris' }, + series: [{ type: 'line', data: [[xValue, yValue]] }] + }); + + const series = getECModel(chart).getSeriesByIndex(0); + const fragment = normalizeTooltipFormatResult( + series.formatTooltip(0, false, null) + ).frag as TooltipMarkupSection; + const valueBlock = fragment.blocks[0] as TooltipMarkupNameValueBlock; + + expect(valueBlock.timeZone).toEqual(['America/New_York', 'Europe/Paris']); + + const tooltipText = buildTooltipMarkup( + fragment, + new TooltipMarkupStyleCreator(), + 'html', + 'seriesAsc', + series.ecModel.getTimeZone(), + {} + ); + expect(tooltipText).toContain('2024-07-15 08:00:00'); + expect(tooltipText).toContain('2024-07-15 15:00:00'); + }); + + it('assigns axis time zones to named tooltip sub-blocks', function () { + const xValue = Date.parse('2024-07-15T08:00:00.000-04:00'); + const yValue = Date.parse('2024-07-15T15:00:00.000+02:00'); + chart.setOption({ + timeZone: 'UTC', + xAxis: { type: 'time', timeZone: 'America/New_York' }, + yAxis: { type: 'time', timeZone: 'Europe/Paris' }, + series: [{ + type: 'line', + dimensions: [ + { name: 'xTime', type: 'time' }, + { name: 'yTime', type: 'time' } + ], + encode: { + x: 'xTime', + y: 'yTime', + tooltip: ['xTime', 'yTime'] + }, + data: [[xValue, yValue]] + }] + }); + + const series = getECModel(chart).getSeriesByIndex(0); + const fragment = normalizeTooltipFormatResult( + series.formatTooltip(0, false, null) + ).frag as TooltipMarkupSection; + const timeBlocks = fragment.blocks.filter( + block => block.type === 'nameValue' && block.name + ) as TooltipMarkupNameValueBlock[]; + + expect(timeBlocks.map(block => block.timeZone)) + .toEqual(['America/New_York', 'Europe/Paris']); + + const tooltipText = buildTooltipMarkup( + fragment, + new TooltipMarkupStyleCreator(), + 'html', + 'seriesAsc', + series.ecModel.getTimeZone(), + {} + ); + expect(tooltipText).toContain('2024-07-15 08:00:00'); + expect(tooltipText).toContain('2024-07-15 15:00:00'); + }); + + it('uses the global time zone for a tooltip dimension without a coordinate axis', function () { + const value = Date.parse('2024-07-15T14:00:00.000+02:00'); + chart.setOption({ + timeZone: 'Europe/Paris', + xAxis: {}, + yAxis: {}, + series: [{ + type: 'line', + dimensions: ['x', 'y', { name: 'eventTime', type: 'time' }], + encode: { + x: 'x', + y: 'y', + tooltip: ['eventTime'] + }, + data: [[0, 1, value]] + }] + }); + + const series = getECModel(chart).getSeriesByIndex(0); + const dimInfo = series.getData().getDimensionInfo('eventTime'); + dimInfo.coordDim = undefined; + const getAxis = jest.spyOn(series.coordinateSystem, 'getAxis'); + const fragment = normalizeTooltipFormatResult( + series.formatTooltip(0, false, null) + ).frag as TooltipMarkupSection; + const valueBlock = fragment.blocks[0] as TooltipMarkupNameValueBlock; + + expect(getAxis).not.toHaveBeenCalled(); + expect(valueBlock.timeZone).toBe('Europe/Paris'); + }); + + it('uses scalar and fallback time zones when building markup', function () { + const value = Date.parse('2024-07-15T12:00:00.000Z'); + const styleCreator = new TooltipMarkupStyleCreator(); + const explicitTimeZone = createTooltipMarkup('nameValue', { + value: value, + valueType: 'time', + timeZone: 'America/New_York' + }); + const fallbackTimeZone = createTooltipMarkup('nameValue', { + value: value, + valueType: 'time' + }); + + expect(buildTooltipMarkup( + explicitTimeZone, styleCreator, 'html', 'seriesAsc', 'Europe/Paris', {} + )).toContain('2024-07-15 08:00:00'); + expect(buildTooltipMarkup( + fallbackTimeZone, styleCreator, 'html', 'seriesAsc', 'Europe/Paris', {} + )).toContain('2024-07-15 14:00:00'); + }); + + it('formats an axis tooltip with the time zone of that axis', function () { + const value = Date.parse('2024-11-03T01:30:00.000-05:00'); + chart.setOption({ + animation: false, + timeZone: 'UTC', + tooltip: { + trigger: 'axis', + renderMode: 'html', + formatter: '{yyyy}-{MM}-{dd} {HH}:{mm} {ZZ}' + }, + xAxis: { + type: 'time', + timeZone: 'America/New_York', + min: Date.parse('2024-11-03T00:00:00.000-04:00'), + max: Date.parse('2024-11-03T03:00:00.000-05:00') + }, + yAxis: {}, + series: [{ type: 'line', data: [[value, 1]] }] + }); + + chart.dispatchAction({ + type: 'showTip', + seriesIndex: 0, + dataIndex: 0 + }); + + expect(chart.getDom().innerHTML).toContain('2024-11-03 01:30 -05:00'); + }); + + it('renders an item tooltip through TooltipView in the axis time zone', function () { + const value = Date.parse('2024-07-15T08:00:00.000-04:00'); + chart.setOption({ + animation: false, + timeZone: 'UTC', + tooltip: { + trigger: 'item', + renderMode: 'html' + }, + xAxis: { type: 'time', timeZone: 'America/New_York' }, + yAxis: {}, + series: [{ + type: 'line', + encode: { tooltip: [0, 1] }, + data: [[value, 1]] + }] + }); + + chart.dispatchAction({ + type: 'showTip', + seriesIndex: 0, + dataIndex: 0 + }); + + expect(chart.getDom().innerHTML).toContain('2024-07-15 08:00:00'); + }); +}); diff --git a/test/ut/spec/scale/time.test.ts b/test/ut/spec/scale/time.test.ts new file mode 100644 index 0000000000..97ec8dc800 --- /dev/null +++ b/test/ut/spec/scale/time.test.ts @@ -0,0 +1,355 @@ +/* +* 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 { createChart, getECModel } from '../../core/utHelper'; +import { EChartsType } from '@/src/echarts'; +import CartesianAxisModel from '@/src/coord/cartesian/AxisModel'; +import TimeScale from '@/src/scale/Time'; +import { getSystemTimeZone, getTimeZoneParts } from '@/src/util/time'; + +describe('scale_timeZone', function () { + + let chart: EChartsType; + + beforeEach(function () { + chart = createChart(); + }); + + afterEach(function () { + jest.restoreAllMocks(); + chart.dispose(); + }); + + it('resolves global, legacy UTC and axis time zones at scale creation', function () { + chart.setOption({ + timeZone: 'America/New_York', + xAxis: [ + { type: 'time' }, + { type: 'time', timeZone: 'Europe/Paris' } + ], + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + + expect(getTimeScale(chart, 'xAxis', 0).getTimeZone()).toBe('America/New_York'); + expect(getTimeScale(chart, 'xAxis', 1).getTimeZone()).toBe('Europe/Paris'); + expect(chart.getOption().useUTC).toBe(false); + + chart.dispose(); + chart = createChart(); + chart.setOption({ + useUTC: true, + xAxis: [ + { type: 'time' }, + { type: 'time', timeZone: 'Europe/Paris' } + ], + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + expect(getTimeScale(chart, 'xAxis', 0).getTimeZone()).toBe('UTC'); + expect(getTimeScale(chart, 'xAxis', 1).getTimeZone()).toBe('Europe/Paris'); + }); + + it('resolves the system time zone when useUTC is absent or false', function () { + chart.setOption({ + xAxis: { type: 'time' }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + expect(getTimeScale(chart, 'xAxis', 0).getTimeZone()).toBe(getSystemTimeZone()); + + chart.dispose(); + chart = createChart(); + chart.setOption({ + useUTC: false, + xAxis: { type: 'time' }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + expect(getTimeScale(chart, 'xAxis', 0).getTimeZone()).toBe(getSystemTimeZone()); + }); + + it('rejects invalid global and axis time zones', function () { + expect(() => chart.setOption({ + timeZone: 'Not/A_Time_Zone', + xAxis: { type: 'time' }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + })).toThrow(/Invalid time zone/); + + chart.dispose(); + chart = createChart(); + expect(() => chart.setOption({ + xAxis: { type: 'time', timeZone: 'Not/A_Time_Zone' }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + })).toThrow(/Invalid time zone/); + }); + + it('prefers global timeZone over legacy useUTC', function () { + chart.setOption({ + timeZone: 'Europe/Paris', + useUTC: true, + xAxis: { type: 'time' }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + + expect(getTimeScale(chart, 'xAxis', 0).getTimeZone()).toBe('Europe/Paris'); + expect(chart.getOption().useUTC).toBe(true); + }); + + it('keeps timeZone precedence across merged and round-tripped options', function () { + chart.setOption({ + useUTC: true, + xAxis: { type: 'time' }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + + chart.setOption({ timeZone: 'Europe/Paris' }); + expect(getTimeScale(chart, 'xAxis', 0).getTimeZone()).toBe('Europe/Paris'); + + chart.setOption({ useUTC: false }); + expect(getTimeScale(chart, 'xAxis', 0).getTimeZone()).toBe('Europe/Paris'); + + chart.setOption(chart.getOption()); + expect(getTimeScale(chart, 'xAxis', 0).getTimeZone()).toBe('Europe/Paris'); + }); + + it('allows a media option to override the base time-zone mode', function () { + chart.setOption({ + baseOption: { + useUTC: true, + xAxis: { type: 'time' }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }, + media: [{ + option: { timeZone: 'Europe/Paris' } + }] + }); + + expect(getTimeScale(chart, 'xAxis', 0).getTimeZone()).toBe('Europe/Paris'); + }); + + it('recreates the effective scale context when timeZone changes', function () { + chart.setOption({ + timeZone: 'UTC', + xAxis: { type: 'time' }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + const utcScale = getTimeScale(chart, 'xAxis', 0); + + chart.setOption({ timeZone: 'Europe/Paris' }); + const parisScale = getTimeScale(chart, 'xAxis', 0); + + expect(parisScale).not.toBe(utcScale); + expect(parisScale.getTimeZone()).toBe('Europe/Paris'); + }); + + it('formats labels in the axis time zone', function () { + const value = Date.parse('2024-07-15T08:34:56.000-04:00'); + chart.setOption({ + timeZone: 'UTC', + xAxis: { + type: 'time', + timeZone: 'America/New_York', + min: value - 1000, + max: value + 1000 + }, + yAxis: {}, + series: [{ type: 'line', data: [[value, 1]] }] + }); + + expect(getTimeScale(chart, 'xAxis', 0).getLabel({ value: value })) + .toContain('08:34:56'); + }); + + it('formats both occurrences of a repeated hour with their offsets', function () { + const timeZone = 'America/New_York'; + chart.setOption({ + xAxis: { type: 'time', timeZone: timeZone }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + const scale = getTimeScale(chart, 'xAxis', 0); + const formatter = '{HH}:{mm} {ZZ}'; + + expect(scale.getFormattedLabel({ + value: Date.parse('2024-11-03T01:30:00.000-04:00') + }, 0, formatter)).toBe('01:30 -04:00'); + expect(scale.getFormattedLabel({ + value: Date.parse('2024-11-03T01:30:00.000-05:00') + }, 1, formatter)).toBe('01:30 -05:00'); + }); + + it('aligns hourly ticks to wall time through a DST gap', function () { + chart.setOption({ + xAxis: { + type: 'time', + timeZone: 'America/New_York', + min: Date.parse('2024-03-10T00:00:00.000-05:00'), + max: Date.parse('2024-03-10T05:00:00.000-04:00'), + splitNumber: 4 + }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + + const scale = getTimeScale(chart, 'xAxis', 0); + const hourlyLabels = scale.getTicks() + .filter(tick => tick.time && tick.time.upperTimeUnit === 'hour') + .map(tick => scale.getLabel(tick)); + + expect(hourlyLabels.some(label => label.indexOf('02:00') >= 0)).toBe(false); + expect(hourlyLabels.some(label => label.indexOf('01:00') >= 0)).toBe(true); + expect(hourlyLabels.some(label => label.indexOf('03:00') >= 0)).toBe(true); + }); + + it('keeps hourly ticks monotonic through both occurrences of a DST fold', function () { + const firstOccurrence = Date.parse('2024-11-03T01:00:00.000-04:00'); + const secondOccurrence = Date.parse('2024-11-03T01:00:00.000-05:00'); + chart.setOption({ + xAxis: { + type: 'time', + timeZone: 'America/New_York', + min: Date.parse('2024-11-03T00:00:00.000-04:00'), + max: Date.parse('2024-11-03T03:00:00.000-05:00'), + splitNumber: 4 + }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + + const ticks = getTimeScale(chart, 'xAxis', 0).getTicks(); + const values = ticks.map(tick => tick.value); + + expect(values).toContain(firstOccurrence); + expect(values).toContain(secondOccurrence); + for (let i = 1; i < values.length; i++) { + expect(values[i]).toBeGreaterThan(values[i - 1]); + } + }); + + it('aligns daily ticks to local midnight across DST', function () { + const timeZone = 'America/New_York'; + const beforeTransition = Date.parse('2024-03-10T00:00:00.000-05:00'); + const afterTransition = Date.parse('2024-03-11T00:00:00.000-04:00'); + chart.setOption({ + xAxis: { + type: 'time', + timeZone: timeZone, + min: Date.parse('2024-03-08T00:00:00.000-05:00'), + max: Date.parse('2024-03-13T00:00:00.000-04:00'), + splitNumber: 5 + }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + + const tickValues = getTimeScale(chart, 'xAxis', 0).getTicks().map(tick => tick.value); + expect(tickValues).toContain(beforeTransition); + expect(tickValues).toContain(afterTransition); + expect(afterTransition - beforeTransition).toBe(23 * 60 * 60 * 1000); + }); + + it('aligns month and year ticks to the IANA calendar', function () { + const timeZone = 'America/New_York'; + chart.setOption({ + xAxis: { + type: 'time', + timeZone: timeZone, + min: Date.parse('2023-10-15T00:00:00.000-04:00'), + max: Date.parse('2025-03-15T00:00:00.000-04:00'), + splitNumber: 6 + }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + + const calendarTicks = getTimeScale(chart, 'xAxis', 0).getTicks() + .map(tick => ({ + tick: tick, + parts: getTimeZoneParts(tick.value, timeZone) + })) + .filter(item => item.parts.day === 1 && item.parts.hours === 0); + + expect(calendarTicks.length).toBeGreaterThan(2); + expect(calendarTicks.some(item => + item.parts.year === 2024 && item.parts.month === 1 + )).toBe(true); + expect(calendarTicks.some(item => + item.parts.year === 2025 && item.parts.month === 1 + )).toBe(true); + }); + + it('formats time-axis break boundaries in the axis time zone', function () { + const timeZone = 'America/New_York'; + const breakStart = Date.parse('2024-03-10T01:30:00.000-05:00'); + const breakEnd = Date.parse('2024-03-10T03:30:00.000-04:00'); + chart.setOption({ + xAxis: { + type: 'time', + timeZone: timeZone, + min: Date.parse('2024-03-10T00:00:00.000-05:00'), + max: Date.parse('2024-03-10T05:00:00.000-04:00'), + breaks: [{ start: breakStart, end: breakEnd, gap: 0 }] + }, + yAxis: {}, + series: [{ type: 'line', data: [] }] + }); + + const scale = getTimeScale(chart, 'xAxis', 0); + const breakTicks = scale.getTicks().filter(tick => tick.break); + expect(breakTicks.map(tick => tick.value)).toEqual([breakStart, breakEnd]); + expect(breakTicks.map((tick, idx) => scale.getFormattedLabel( + tick, idx, '{HH}:{mm} {ZZ}' + ))).toEqual(['01:30 -05:00', '03:30 -04:00']); + }); + + it('does not perform zoned calendar conversion for every data point', function () { + const format = jest.spyOn(Intl.DateTimeFormat.prototype, 'format', 'get'); + const formatToParts = jest.spyOn(Intl.DateTimeFormat.prototype, 'formatToParts'); + const start = Date.parse('2024-01-01T00:00:00.000-05:00'); + const data: number[][] = []; + for (let i = 0; i < 5000; i++) { + data.push([start + i * 60 * 1000, i]); + } + + chart.setOption({ + xAxis: { type: 'time', timeZone: 'America/New_York' }, + yAxis: {}, + series: [{ type: 'line', data: data }] + }); + + expect(format.mock.calls.length + formatToParts.mock.calls.length) + .toBeLessThan(data.length / 5); + }); +}); + +function getTimeScale( + chart: EChartsType, + mainType: 'xAxis' | 'yAxis', + index: number +): TimeScale { + const axisModel = getECModel(chart).getComponent(mainType, index) as CartesianAxisModel; + return axisModel.axis.scale as TimeScale; +} diff --git a/test/ut/spec/util/format.test.ts b/test/ut/spec/util/format.test.ts new file mode 100644 index 0000000000..92d2aced28 --- /dev/null +++ b/test/ut/spec/util/format.test.ts @@ -0,0 +1,61 @@ +/* +* 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 { makeValueReadable } from '@/src/util/format'; + +describe('util/format timeZone', function () { + + afterEach(function () { + jest.restoreAllMocks(); + }); + + it('makes temporal values readable in an IANA time zone', function () { + const value = Date.parse('2024-07-15T12:34:56.789Z'); + + expect(makeValueReadable(value, 'time', 'America/New_York')) + .toBe('2024-07-15 08:34:56'); + }); + + it('keeps legacy boolean time-zone selection', function () { + const value = Date.parse('2024-07-15T12:34:56.789Z'); + const warn = jest.spyOn(console, 'warn').mockImplementation(function () {}); + + expect(makeValueReadable(value, 'time', true)) + .toBe('2024-07-15 12:34:56'); + expect(makeValueReadable(value, 'time', false)) + .toBe(formatLocalTime(value)); + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + '[makeValueReadable]isUTC boolean parameter is deprecated' + )); + }); +}); + +function formatLocalTime(value: number): string { + const date = new Date(value); + return date.getFullYear() + + '-' + pad(date.getMonth() + 1, 2) + + '-' + pad(date.getDate(), 2) + + ' ' + pad(date.getHours(), 2) + + ':' + pad(date.getMinutes(), 2) + + ':' + pad(date.getSeconds(), 2); +} + +function pad(value: number, length: number): string { + return String(value).padStart(length, '0'); +} diff --git a/test/ut/spec/util/time.test.ts b/test/ut/spec/util/time.test.ts index 60d5467fc7..42b3afe41b 100755 --- a/test/ut/spec/util/time.test.ts +++ b/test/ut/spec/util/time.test.ts @@ -19,11 +19,40 @@ */ import { - format, roundTime + addTimeInTimeZone, + format, + getSystemTimeZone, + getTimeZoneParts, + getUnitFromValue, + leveledFormat, + roundTime, + validateTimeZone } from '@/src/util/time'; +import { getDefaultLocaleModel } from '@/src/core/locale'; describe('util/time', function () { + afterEach(function () { + jest.restoreAllMocks(); + }); + + it('warns when legacy boolean overloads are used', function () { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const time = Date.parse('2024-01-01T00:00:00.000Z'); + + format(time, '{yyyy}', true); + leveledFormat({ value: time }, 0, '{yyyy}', getDefaultLocaleModel(), true); + getUnitFromValue(time, true); + roundTime(new Date(time), 'year', true); + + expect(warn.mock.calls.map(call => call[0])).toEqual(expect.arrayContaining([ + expect.stringContaining('[echarts.time.format]isUTC boolean parameter is deprecated'), + expect.stringContaining('[leveledFormat]isUTC boolean parameter is deprecated'), + expect.stringContaining('[getUnitFromValue]isUTC boolean parameter is deprecated'), + expect.stringContaining('[echarts.time.roundTime]isUTC boolean parameter is deprecated') + ])); + }); + describe('format', function () { const time = new Date('2003-04-09 01:04:02.300 UTC'); @@ -140,6 +169,26 @@ describe('util/time', function () { expect(format(anotherTime, '{A}', true)).toEqual('AM'); expect(format(oneMoreTime, '{A}', true)).toEqual('PM'); }); + + it('should format time zone offsets', function () { + const time = Date.parse('2024-01-15T12:00:00.000Z'); + + expect(format(time, '{Z} {ZZ}', 'UTC')).toEqual('Z Z'); + expect(format(time, '{Z} {ZZ}', 'Africa/Abidjan')).toEqual('+0 +00:00'); + expect(format(time, '{Z} {ZZ}', 'America/New_York')).toEqual('-5 -05:00'); + expect(format(time, '{Z} {ZZ}', 'America/St_Johns')).toEqual('-3:30 -03:30'); + expect(format(time, '{Z} {ZZ}', 'Asia/Kathmandu')).toEqual('+5:45 +05:45'); + }); + + it('should format both offsets in a repeated DST hour', function () { + const timeZone = 'America/New_York'; + const template = '{HH}:{mm} {Z} {ZZ}'; + + expect(format(Date.parse('2024-11-03T01:30:00.000-04:00'), template, timeZone)) + .toEqual('01:30 -4 -04:00'); + expect(format(Date.parse('2024-11-03T01:30:00.000-05:00'), template, timeZone)) + .toEqual('01:30 -5 -05:00'); + }); }); describe('roundTime', function () { @@ -157,34 +206,275 @@ describe('util/time', function () { }); it('roundTime_locale', function () { - const timezoneStr = getISOTimezone(); - const time1 = new Date(`1986-10-06T11:25:45.678${timezoneStr}`); + // The local UTC offset is date-dependent because of DST, so each + // expected wall time must resolve its own offset. + const time1 = makeLocalDate('1986-10-06T11:25:45.678'); expect(roundTime(new Date(time1), 'year', false).getTime()) - .toEqual(new Date(`1986-01-01T00:00:00.000${timezoneStr}`).getTime()); + .toEqual(makeLocalDate('1986-01-01T00:00:00.000').getTime()); expect(roundTime(new Date(time1), 'month', false).getTime()) - .toEqual(new Date(`1986-10-01T00:00:00.000${timezoneStr}`).getTime()); + .toEqual(makeLocalDate('1986-10-01T00:00:00.000').getTime()); expect(roundTime(new Date(time1), 'day', false).getTime()) - .toEqual(new Date(`1986-10-06T00:00:00.000${timezoneStr}`).getTime()); + .toEqual(makeLocalDate('1986-10-06T00:00:00.000').getTime()); expect(roundTime(new Date(time1), 'hour', false).getTime()) - .toEqual(new Date(`1986-10-06T11:00:00.000${timezoneStr}`).getTime()); + .toEqual(makeLocalDate('1986-10-06T11:00:00.000').getTime()); expect(roundTime(new Date(time1), 'minute', false).getTime()) - .toEqual(new Date(`1986-10-06T11:25:00.000${timezoneStr}`).getTime()); + .toEqual(makeLocalDate('1986-10-06T11:25:00.000').getTime()); expect(roundTime(new Date(time1), 'second', false).getTime()) - .toEqual(new Date(`1986-10-06T11:25:45.000${timezoneStr}`).getTime()); + .toEqual(makeLocalDate('1986-10-06T11:25:45.000').getTime()); expect(roundTime(new Date(time1), 'millisecond', false).getTime()) - .toEqual(new Date(`1986-10-06T11:25:45.678${timezoneStr}`).getTime()); + .toEqual(makeLocalDate('1986-10-06T11:25:45.678').getTime()); + }); + + it('roundTime_TimeZone', function () { + const timeZone = 'America/New_York'; + const time1 = new Date('1986-10-06T11:25:45.678-04:00'); + + expect(roundTime(new Date(time1), 'year', timeZone).getTime()) + .toEqual(new Date('1986-01-01T00:00:00.000-05:00').getTime()); + expect(roundTime(new Date(time1), 'month', timeZone).getTime()) + .toEqual(new Date('1986-10-01T00:00:00.000-04:00').getTime()); + expect(roundTime(new Date(time1), 'day', timeZone).getTime()) + .toEqual(new Date('1986-10-06T00:00:00.000-04:00').getTime()); + expect(roundTime(new Date(time1), 'hour', timeZone).getTime()) + .toEqual(new Date('1986-10-06T11:00:00.000-04:00').getTime()); + expect(roundTime(new Date(time1), 'minute', timeZone).getTime()) + .toEqual(new Date('1986-10-06T11:25:00.000-04:00').getTime()); + expect(roundTime(new Date(time1), 'second', timeZone).getTime()) + .toEqual(new Date('1986-10-06T11:25:45.000-04:00').getTime()); + expect(roundTime(new Date(time1), 'millisecond', timeZone).getTime()) + .toEqual(new Date('1986-10-06T11:25:45.678-04:00').getTime()); + }); + + it('preserves the occurrence when rounding a repeated DST hour', function () { + const timeZone = 'America/New_York'; + const firstOccurrence = new Date('2024-11-03T01:45:00.000-04:00'); + const secondOccurrence = new Date('2024-11-03T01:45:00.000-05:00'); + + expect(roundTime(firstOccurrence, 'hour', timeZone).getTime()) + .toBe(Date.parse('2024-11-03T01:00:00.000-04:00')); + expect(roundTime(secondOccurrence, 'hour', timeZone).getTime()) + .toBe(Date.parse('2024-11-03T01:00:00.000-05:00')); + }); + }); + + describe('timeZone', function () { + it('validates time zones', function () { + expect(validateTimeZone('Europe/Paris')).toBe('Europe/Paris'); + expect(() => validateTimeZone('Not/A_Time_Zone')).toThrow(/Invalid time zone/); + }); + + it('rejects invalid time zones consistently in public helpers', function () { + const value = Date.parse('2024-01-01T00:00:00.000Z'); + const timeZone = 'Not/A_Time_Zone'; + const error = `Invalid time zone: ${timeZone}`; + + expect(() => format(value, '{yyyy}', timeZone)).toThrow(error); + expect(() => leveledFormat( + { value: value }, 0, '{yyyy}', getDefaultLocaleModel(), timeZone + )).toThrow(error); + expect(() => getUnitFromValue(value, timeZone)).toThrow(error); + expect(() => roundTime(new Date(value), 'year', timeZone)).toThrow(error); + expect(() => format(value, '{yyyy}', '__proto__')) + .toThrow('Invalid time zone: __proto__'); + }); + + it('extracts civil parts and offsets in IANA time zones', function () { + const winter = Date.parse('2024-01-15T07:34:56.789-05:00'); + const summer = Date.parse('2024-07-15T08:34:56.789-04:00'); + const kathmandu = Date.parse('2024-01-15T18:19:56.789+05:45'); + + expect(getTimeZoneParts(winter, 'America/New_York')).toMatchObject({ + year: 2024, + month: 1, + day: 15, + hours: 7, + minutes: 34, + seconds: 56, + milliseconds: 789, + offsetMinutes: -300 + }); + expect(getTimeZoneParts(summer, 'America/New_York')).toMatchObject({ + hours: 8, + minutes: 34, + offsetMinutes: -240 + }); + expect(getTimeZoneParts(kathmandu, 'Asia/Kathmandu')).toMatchObject({ + hours: 18, + minutes: 19, + offsetMinutes: 345 + }); + }); + + it('distinguishes repeated civil time by its offset', function () { + const firstOccurrence = getTimeZoneParts( + Date.parse('2024-11-03T01:30:00.000-04:00'), 'America/New_York' + ); + const secondOccurrence = getTimeZoneParts( + Date.parse('2024-11-03T01:30:00.000-05:00'), 'America/New_York' + ); + + expect(firstOccurrence).toMatchObject({ + hours: 1, + minutes: 30, + offsetMinutes: -240 + }); + expect(secondOccurrence).toMatchObject({ + hours: 1, + minutes: 30, + offsetMinutes: -300 + }); + }); + + it('uses compatible disambiguation in DST gaps and folds', function () { + const springGap = addTimeInTimeZone( + Date.parse('2024-03-10T01:30:00.000-05:00'), + 'hour', 1, 'America/New_York' + ); + expect(springGap).toBe(Date.parse('2024-03-10T03:30:00.000-04:00')); + + const autumnFold = addTimeInTimeZone( + Date.parse('2024-11-03T00:30:00.000-04:00'), + 'hour', 1, 'America/New_York' + ); + expect(autumnFold).toBe(Date.parse('2024-11-03T01:30:00.000-04:00')); + }); + + it('advances fixed-duration units through both occurrences of a DST fold', function () { + const timeZone = 'America/New_York'; + + expect(addTimeInTimeZone( + Date.parse('2024-11-03T01:30:00.000-04:00'), 'hour', 1, timeZone + )).toBe(Date.parse('2024-11-03T01:30:00.000-05:00')); + expect(addTimeInTimeZone( + Date.parse('2024-11-03T01:59:00.000-04:00'), 'minute', 1, timeZone + )).toBe(Date.parse('2024-11-03T01:00:00.000-05:00')); + expect(addTimeInTimeZone( + Date.parse('2024-11-03T01:59:59.000-04:00'), 'second', 1, timeZone + )).toBe(Date.parse('2024-11-03T01:00:00.000-05:00')); + expect(addTimeInTimeZone( + Date.parse('2024-11-03T01:59:59.999-04:00'), 'millisecond', 1, timeZone + )).toBe(Date.parse('2024-11-03T01:00:00.000-05:00')); + }); + + it('advances month and year units in the configured calendar', function () { + const timeZone = 'America/New_York'; + const start = Date.parse('2024-02-01T00:00:00.000-05:00'); + + expect(addTimeInTimeZone(start, 'month', 1, timeZone)) + .toBe(Date.parse('2024-03-01T00:00:00.000-05:00')); + expect(addTimeInTimeZone(start, 'month', 2, timeZone)) + .toBe(Date.parse('2024-04-01T00:00:00.000-04:00')); + expect(addTimeInTimeZone(start, 'year', 1, timeZone)) + .toBe(Date.parse('2025-02-01T00:00:00.000-05:00')); + }); + + it('classifies a timestamp using the configured time zone', function () { + const value = Date.parse('2024-01-01T00:00:00.000Z'); + + expect(getUnitFromValue(value, 'UTC')).toBe('year'); + expect(getUnitFromValue(value, 'America/New_York')).toBe('hour'); + expect(getUnitFromValue(value, true)).toBe('year'); + }); + + it('handles non-hour time-zone transitions', function () { + const halfHourGap = addTimeInTimeZone( + Date.parse('2024-10-06T01:45:00.000+10:30'), + 'minute', 30, 'Australia/Lord_Howe' + ); + expect(halfHourGap).toBe(Date.parse('2024-10-06T02:45:00.000+11:00')); + + const skippedDay = addTimeInTimeZone( + Date.parse('2011-12-29T12:00:00.000-10:00'), + 'day', 1, 'Pacific/Apia' + ); + expect(skippedDay).toBe(Date.parse('2011-12-31T12:00:00.000+14:00')); + }); + + it('caches scanned days and the transition within a day', function () { + const timeZone = getSystemTimeZone() === 'Europe/Paris' + ? 'America/New_York' + : 'Europe/Paris'; + const isParis = timeZone === 'Europe/Paris'; + const formatToParts = jest.spyOn(Intl.DateTimeFormat.prototype, 'formatToParts'); + const ordinaryDay = Date.parse( + isParis ? '2035-01-15T13:00:00.000+01:00' : '2035-01-15T07:00:00.000-05:00' + ); + + getTimeZoneParts(ordinaryDay, timeZone); + const firstDayCalls = formatToParts.mock.calls.length; + getTimeZoneParts(ordinaryDay + 60 * 60 * 1000, timeZone); + expect(formatToParts.mock.calls.length).toBe(firstDayCalls); + + getTimeZoneParts(ordinaryDay + 24 * 60 * 60 * 1000, timeZone); + expect(formatToParts.mock.calls.length).toBe(firstDayCalls + 1); + + const beforeTransition = Date.parse( + isParis ? '2035-03-25T01:30:00.000+01:00' : '2035-03-11T01:30:00.000-05:00' + ); + const afterTransition = Date.parse( + isParis ? '2035-03-25T03:30:00.000+02:00' : '2035-03-11T03:30:00.000-04:00' + ); + expect(getTimeZoneParts(beforeTransition, timeZone)).toMatchObject({ + hours: 1, + minutes: 30 + }); + expect(getTimeZoneParts(afterTransition, timeZone)).toMatchObject({ + hours: 3, + minutes: 30 + }); + const transitionDayCalls = formatToParts.mock.calls.length; + + getTimeZoneParts(beforeTransition, timeZone); + getTimeZoneParts(afterTransition, timeZone); + expect(formatToParts.mock.calls.length).toBe(transitionDayCalls); + }); + + it('uses preferred offsets only while valid for calendar targets', function () { + const beforeSpring = Date.parse('2024-03-09T12:00:00.000-05:00'); + expect(addTimeInTimeZone( + beforeSpring, 'day', 1, 'America/New_York' + )).toBe(Date.parse('2024-03-10T12:00:00.000-04:00')); + + const beforeSpringGap = Date.parse('2024-03-09T02:30:00.000-05:00'); + expect(addTimeInTimeZone( + beforeSpringGap, 'day', 1, 'America/New_York' + )).toBe(Date.parse('2024-03-10T03:30:00.000-04:00')); + + const beforeAutumnFold = Date.parse('2024-11-02T01:30:00.000-04:00'); + expect(addTimeInTimeZone( + beforeAutumnFold, 'day', 1, 'America/New_York' + )).toBe(Date.parse('2024-11-03T01:30:00.000-04:00')); + + const afterAutumnFold = Date.parse('2024-11-04T01:30:00.000-05:00'); + expect(addTimeInTimeZone( + afterAutumnFold, 'day', -1, 'America/New_York' + )).toBe(Date.parse('2024-11-03T01:30:00.000-05:00')); + + const duringDay = Date.parse('2024-03-10T14:45:12.345-04:00'); + expect(roundTime( + new Date(duringDay), 'day', 'America/New_York' + ).getTime()).toBe(Date.parse('2024-03-10T00:00:00.000-05:00')); + }); + + it('formats the same instant in the configured time zone', function () { + const instant = Date.parse('2024-07-15T08:34:56.789-04:00'); + expect(format( + instant, + '{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}', + 'America/New_York' + )).toBe('2024-07-15 08:34:56 789'); }); }); }); -// return timezone format like `'-06:00'` or `'+05:45'` -function getISOTimezone(): string { - const offsetMinutes = (new Date(0)).getTimezoneOffset(); +function makeLocalDate(localTime: string): Date { + const offsetMinutes = new Date(localTime).getTimezoneOffset(); // Invert sign because getTimezoneOffset() returns minutes behind UTC const sign = offsetMinutes > 0 ? '-' : '+'; const absMinutes = Math.abs(offsetMinutes); const hours = Math.floor(absMinutes / 60); const minutes = absMinutes % 60; - return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; + const offset = `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; + return new Date(`${localTime}${offset}`); }