From b0aabb5506a3c52e956072b3bc827e0e910c149b Mon Sep 17 00:00:00 2001 From: Oliver Bell Date: Wed, 14 Jan 2026 19:02:50 +0000 Subject: [PATCH] feat: support timezone on time scale charts --- build/pre-publish.js | 4 +- package-lock.json | 11 +++++ package.json | 1 + src/component/timeline/SliderTimelineView.ts | 3 +- src/coord/axisHelper.ts | 1 + src/scale/Time.ts | 45 ++++++++++++-------- src/util/number.ts | 13 +++--- src/util/time.ts | 17 +++++--- src/util/types.ts | 1 + tsconfig.json | 3 +- 10 files changed, 64 insertions(+), 35 deletions(-) diff --git a/build/pre-publish.js b/build/pre-publish.js index f7476b2afe..eab1312695 100644 --- a/build/pre-publish.js +++ b/build/pre-publish.js @@ -312,8 +312,8 @@ function singleTransformImport(code, replacement) { return transformImport( code.replace(/([\"\'])zrender\/src\//g, `$1zrender/${replacement}/`), (moduleName) => { - // Ignore 'tslib' and 'echarts' in the extensions. - if (moduleName === 'tslib' || moduleName === 'echarts') { + // Ignore 'tslib', '@date-fns/tz' and 'echarts' in the extensions. + if (moduleName === 'tslib' || moduleName === 'echarts' || moduleName === "@date-fns/tz") { return moduleName; } else if (moduleName === 'zrender/lib/export') { diff --git a/package-lock.json b/package-lock.json index 58a25dcfc0..6083ed390d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "6.0.0", "license": "Apache-2.0", "dependencies": { + "@date-fns/tz": "^1.4.1", "tslib": "2.3.0", "zrender": "6.0.0" }, @@ -697,6 +698,11 @@ "node": ">=0.1.95" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==" + }, "node_modules/@definitelytyped/header-parser": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/@definitelytyped/header-parser/-/header-parser-0.2.16.tgz", @@ -12282,6 +12288,11 @@ "minimist": "^1.2.0" } }, + "@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==" + }, "@definitelytyped/header-parser": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/@definitelytyped/header-parser/-/header-parser-0.2.16.tgz", diff --git a/package.json b/package.json index fd57b487c3..935f4efe5e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "lint:dist": "echo 'It might take a while. Please wait ...' && npx jshint --config .jshintrc-dist dist/echarts.js" }, "dependencies": { + "@date-fns/tz": "^1.4.1", "tslib": "2.3.0", "zrender": "6.0.0" }, diff --git a/src/component/timeline/SliderTimelineView.ts b/src/component/timeline/SliderTimelineView.ts index 0a547abd75..62882d6b1e 100644 --- a/src/component/timeline/SliderTimelineView.ts +++ b/src/component/timeline/SliderTimelineView.ts @@ -743,7 +743,8 @@ function createScaleByModel(model: SliderTimelineModel, axisType?: string): Scal case 'time': return new TimeScale({ locale: model.ecModel.getLocaleModel(), - useUTC: model.ecModel.get('useUTC') + useUTC: model.ecModel.get('useUTC'), + timezone: model.ecModel.get('timezone'), }); default: // default to be value diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index 24cb773ee3..905c2d950c 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -210,6 +210,7 @@ export function createScaleByModel(model: AxisBaseModel, axisType?: string): Sca return new TimeScale({ locale: model.ecModel.getLocaleModel(), useUTC: model.ecModel.get('useUTC'), + timezone: model.ecModel.get('timezone'), }); default: // case 'value'/'interval', 'log', or others. diff --git a/src/scale/Time.ts b/src/scale/Time.ts index a3a7de479c..c38c5ddacf 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -72,7 +72,7 @@ import { JSDateSetterNames, getUnitFromValue, primaryTimeUnits, - roundTime + roundTime, } from '../util/time'; import * as scaleHelper from './helper'; import IntervalScale from './Interval'; @@ -84,6 +84,7 @@ import { LocaleOption } from '../core/locale'; import Model from '../model/Model'; import { each, filter, indexOf, isNumber, map } from 'zrender/src/core/util'; import { ScaleBreakContext, getScaleBreakHelper } from './break'; +import { TZDate } from '@date-fns/tz'; // FIXME 公用? const bisect = function ( @@ -107,6 +108,7 @@ const bisect = function ( type TimeScaleSetting = { locale: Model; useUTC: boolean; + timezone?: string; // IANA timezone (e.g. "Europe/London") modelAxisBreaks?: AxisBreakOption[]; }; @@ -128,13 +130,15 @@ class TimeScale extends IntervalScale { */ getLabel(tick: TimeScaleTick): string { const useUTC = this.getSetting('useUTC'); + const timeZone = this.getSetting('timezone'); return format( tick.value, fullLeveledFormatter[ getDefaultFormatPrecisionOfInterval(getPrimaryTimeUnit(this._minLevelUnit)) ] || fullLeveledFormatter.second, useUTC, - this.getSetting('locale') + this.getSetting('locale'), + timeZone ); } @@ -145,7 +149,8 @@ class TimeScale extends IntervalScale { ): string { const isUTC = this.getSetting('useUTC'); const lang = this.getSetting('locale'); - return leveledFormat(tick, idx, labelFormatter, lang, isUTC); + const timeZone = this.getSetting('timezone'); + return leveledFormat(tick, idx, labelFormatter, lang, isUTC, timeZone); } /** @@ -165,13 +170,14 @@ class TimeScale extends IntervalScale { } const useUTC = this.getSetting('useUTC'); + const timeZone = this.getSetting('timezone'); if (scaleBreakHelper && opt.breakTicks === 'only_break') { getScaleBreakHelper().addBreaksToTicks(ticks, this._brkCtx!.breaks, this._extent); return ticks; } - const extent0Unit = getUnitFromValue(extent[1], useUTC); + const extent0Unit = getUnitFromValue(extent[1], useUTC, timeZone); ticks.push({ value: extent[0], time: { @@ -187,12 +193,13 @@ class TimeScale extends IntervalScale { useUTC, extent, this._getExtentSpanWithBreaks(), - this._brkCtx + this._brkCtx, + timeZone ); ticks = ticks.concat(innerTicks); - const extent1Unit = getUnitFromValue(extent[1], useUTC); + const extent1Unit = getUnitFromValue(extent[1], useUTC, timeZone); ticks.push({ value: extent[1], time: { @@ -226,13 +233,13 @@ class TimeScale extends IntervalScale { getScaleBreakHelper().addBreaksToTicks(ticks, this._brkCtx!.breaks, this._extent, trimmedBrk => { // @see `parseTimeAxisLabelFormatterDictionary`. const lowerBrkUnitIndex = Math.max( - indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmin, isUTC)), - indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmax, isUTC)), + indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmin, isUTC, timeZone)), + indexOf(primaryTimeUnits, getUnitFromValue(trimmedBrk.vmax, isUTC, timeZone)), ); let upperBrkUnitIndex = 0; for (let unitIdx = 0; unitIdx < primaryTimeUnits.length; unitIdx++) { if (!isPrimaryUnitValueAndGreaterSame( - primaryTimeUnits[unitIdx], trimmedBrk.vmin, trimmedBrk.vmax, isUTC + primaryTimeUnits[unitIdx], trimmedBrk.vmin, trimmedBrk.vmax, isUTC, timeZone )) { upperBrkUnitIndex = unitIdx; break; @@ -351,10 +358,11 @@ function isPrimaryUnitValueAndGreaterSame( unit: PrimaryTimeUnit, valueA: number, valueB: number, - isUTC: boolean + isUTC: boolean, + timeZone?: string ): boolean { - return roundTime(new Date(valueA), unit, isUTC).getTime() - === roundTime(new Date(valueB), unit, isUTC).getTime(); + return roundTime(new TZDate(valueA, timeZone), unit, isUTC).getTime() + === roundTime(new TZDate(valueB, timeZone), unit, isUTC).getTime(); } // function isUnitValueSame( @@ -492,9 +500,9 @@ 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, isUTC: boolean, timeZone?: string) { const upperUnitIdx = Math.max(0, indexOf(primaryTimeUnits, unitName) - 1); - return roundTime(new Date(timestamp), primaryTimeUnits[upperUnitIdx], isUTC).getTime(); + return roundTime(new TZDate(timestamp, timeZone), primaryTimeUnits[upperUnitIdx], isUTC).getTime(); } function createEstimateNiceMultiple( @@ -524,6 +532,7 @@ function getIntervalTicks( extent: number[], extentSpanWithBreaks: number, brkCtx: ScaleBreakContext | NullUndefined, + timeZone?: string ): TimeScaleTick[] { const safeLimit = 10000; const unitNames = timeUnits; @@ -548,7 +557,7 @@ function getIntervalTicks( const estimateNiceMultiple = createEstimateNiceMultiple(setMethodName, interval); let dateTime = minTimestamp; - const date = new Date(dateTime); + const date = new TZDate(dateTime, timeZone); // if (isDate) { // d -= 1; // Starts with 0; PENDING @@ -593,13 +602,13 @@ function getIntervalTicks( const newAddedTicks: ScaleTick[] = []; const isFirstLevel = !lastLevelTicks.length; - if (isPrimaryUnitValueAndGreaterSame(getPrimaryTimeUnit(unitName), extent[0], extent[1], isUTC)) { + if (isPrimaryUnitValueAndGreaterSame(getPrimaryTimeUnit(unitName), extent[0], extent[1], isUTC, timeZone)) { return; } if (isFirstLevel) { lastLevelTicks = [{ - value: getFirstTimestampOfUnit(extent[0], unitName, isUTC), + value: getFirstTimestampOfUnit(extent[0], unitName, isUTC, timeZone), }, { value: extent[1] }]; @@ -742,7 +751,7 @@ function getIntervalTicks( 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, isUTC, timeZone); ticks.push({ value: levelTicks[k].value, time: { diff --git a/src/util/number.ts b/src/util/number.ts index f156e6d3de..ea432a07ea 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -26,6 +26,7 @@ * ). */ +import { TZDate } from '@date-fns/tz'; import * as zrUtil from 'zrender/src/core/util'; const RADIAN_EPSILON = 1e-4; @@ -369,7 +370,7 @@ const TIME_REG = /^(?:(\d{4})(?:[-\/](\d{1,2})(?:[-\/](\d{1,2})(?:[T ](\d{1,2})( * + a timestamp, which represent a time in UTC. * @return date Never be null/undefined. If invalid, return `new Date(NaN)`. */ -export function parseDate(value: unknown): Date { +export function parseDate(value: unknown, timeZone?: string): Date { if (value instanceof Date) { return value; } @@ -390,14 +391,14 @@ export function parseDate(value: unknown): Date { if (!match[8]) { // match[n] can only be string or undefined. // But take care of '12' + 1 => '121'. - return new Date( + return new TZDate( +match[1], +(match[2] || 1) - 1, +match[3] || 1, +match[4] || 0, +(match[5] || 0), +match[6] || 0, - match[7] ? +match[7].substring(0, 3) : 0 + match[7] ? +match[7].substring(0, 3) : 0, timeZone ); } // Timezoneoffset of Javascript Date has considered DST (Daylight Saving Time, @@ -412,7 +413,7 @@ export function parseDate(value: unknown): Date { if (match[8].toUpperCase() !== 'Z') { hour -= +match[8].slice(0, 3); } - return new Date(Date.UTC( + return new TZDate(Date.UTC( +match[1], +(match[2] || 1) - 1, +match[3] || 1, @@ -420,14 +421,14 @@ export function parseDate(value: unknown): Date { +(match[5] || 0), +match[6] || 0, match[7] ? +match[7].substring(0, 3) : 0 - )); + ), timeZone); } } else if (value == null) { return new Date(NaN); } - return new Date(Math.round(value as number)); + return new TZDate(Math.round(value as number), timeZone); } /** diff --git a/src/util/time.ts b/src/util/time.ts index 6c6ed6bfe8..dba17c935a 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -32,6 +32,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 { TZDate } from '@date-fns/tz'; export const ONE_SECOND = 1000; export const ONE_MINUTE = ONE_SECOND * 60; @@ -277,9 +278,9 @@ export function getDefaultFormatPrecisionOfInterval(timeUnit: PrimaryTimeUnit): 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 + time: unknown, template: string, isUTC: boolean, lang?: string | Model, timeZone?: string ): string { - const date = numberUtil.parseDate(time); + const date = numberUtil.parseDate(time, timeZone); const y = date[fullYearGetterName(isUTC)](); const M = date[monthGetterName(isUTC)]() + 1; const q = Math.floor((M - 1) / 3) + 1; @@ -333,7 +334,8 @@ export function leveledFormat( idx: number, formatter: TimeAxisLabelFormatterParsed, lang: string | Model, - isUTC: boolean + isUTC: boolean, + timeZone?: string ) { let template = null; if (zrUtil.isString(formatter)) { @@ -359,19 +361,20 @@ 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, isUTC, timeZone); template = formatter[unit][unit][0]; } } - return format(new Date(tick.value), template, isUTC, lang); + return format(new TZDate(tick.value, timeZone), template, isUTC, lang); } export function getUnitFromValue( value: number | string | Date, - isUTC: boolean + isUTC: boolean, + timeZone?: string ): PrimaryTimeUnit { - const date = numberUtil.parseDate(value); + const date = numberUtil.parseDate(value, timeZone); const M = (date as any)[monthGetterName(isUTC)]() + 1; const d = (date as any)[dateGetterName(isUTC)](); const h = (date as any)[hoursGetterName(isUTC)](); diff --git a/src/util/types.ts b/src/util/types.ts index 1d6521d51e..84c547dc9c 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -705,6 +705,7 @@ export type ECUnitOption = { darkMode?: boolean | 'auto' textStyle?: GlobalTextStyleOption useUTC?: boolean + timezone?: string; hoverLayerThreshold?: number legacyViewCoordSysCenterBase?: boolean diff --git a/tsconfig.json b/tsconfig.json index 3d7d3c377b..8124df89e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "target": "ES3", - + + "skipLibCheck": true, "noImplicitAny": true, "noImplicitThis": true, "strictBindCallApply": true,