diff --git a/src/scale/Time.ts b/src/scale/Time.ts index b787ce2869..f2694ed9e8 100644 --- a/src/scale/Time.ts +++ b/src/scale/Time.ts @@ -81,7 +81,7 @@ import {TimeAxisLabelFormatterParsed} from '../coord/axisCommonTypes'; import { warn } from '../util/log'; import { LocaleOption } from '../core/locale'; import Model from '../model/Model'; -import { each, filter, indexOf, isNumber, map } from 'zrender/src/core/util'; +import { each, filter, indexOf, isNumber } from 'zrender/src/core/util'; import { BreakScaleMapper, getBreaksUnsafe, getScaleBreakHelper, simplyParseBreakOption } from './break'; import type { ScaleCalcNiceMethod } from '../coord/axisNiceTicks'; import { getMinorTicks } from './minorTicks'; @@ -508,6 +508,16 @@ function createIntervalTicks( // d -= 1; // Starts with 0; PENDING // } + function stepDate(step: number): number { + date[setMethodName](date[getMethodName]() + step); + if (isDate) { + // If local midnight does not exist on a day (DST change), the date is moved to + // 01:00 and would stay at 01:00 in the following days. So reset to the start of day. + date[hoursSetterName(isUTC)](0); + } + return date.getTime(); + } + while (dateTime < maxTimestamp && dateTime <= extent[1]) { out.push({ value: dateTime @@ -520,14 +530,12 @@ function createIntervalTicks( break; } - date[setMethodName](date[getMethodName]() + interval); - dateTime = date.getTime(); + dateTime = stepDate(interval); if (brk) { const moreMultiple = brk.calcNiceTickMultiple(dateTime, estimateNiceMultiple); if (moreMultiple > 0) { - date[setMethodName](date[getMethodName]() + moreMultiple * interval); - dateTime = date.getTime(); + dateTime = stepDate(moreMultiple * interval); } } } @@ -640,6 +648,7 @@ function createIntervalTicks( } const levelsTicks: InnerTimeTick[][] = []; + const levelsUnits: PrimaryTimeUnit[] = []; let currentLevelTicks: InnerTimeTick[] = []; let tickCount = 0; @@ -676,6 +685,7 @@ function createIntervalTicks( // Only treat primary time unit as one level. levelsTicks.push(levelTicksRemoveDuplicated); + levelsUnits.push(primaryTimeUnit); if (tickCount > targetTickNum || bottomUnitName === unitNames[i]) { break; @@ -688,17 +698,31 @@ function createIntervalTicks( } - const levelsTicksInExtent = filter(map(levelsTicks, levelTicks => { - return filter(levelTicks, tick => tick.value >= extent[0] && tick.value <= extent[1] && !tick.notAdd); - }), levelTicks => levelTicks.length > 0); + const levelsTicksInExtent: InnerTimeTick[][] = []; + const levelsUnitsInExtent: PrimaryTimeUnit[] = []; + each(levelsTicks, (levelTicks, idx) => { + const levelTicksInExtent = filter( + levelTicks, tick => tick.value >= extent[0] && tick.value <= extent[1] && !tick.notAdd + ); + if (levelTicksInExtent.length > 0) { + levelsTicksInExtent.push(levelTicksInExtent); + levelsUnitsInExtent.push(levelsUnits[idx]); + } + }); const maxLevel = levelsTicksInExtent.length - 1; const ticks: TimeScaleTick[] = []; for (let i = 0; i < levelsTicksInExtent.length; ++i) { const levelTicks = levelsTicksInExtent[i]; + const levelUnitIdx = indexOf(primaryTimeUnits, levelsUnitsInExtent[i]); for (let k = 0; k < levelTicks.length; ++k) { - const unit = getUnitFromValue(levelTicks[k].value, isUTC); + // The unit of a tick should not be finer than its level, e.g., a day tick may be at + // 01:00 rather than 00:00 if local midnight does not exist on that day (DST change). + const unit = primaryTimeUnits[Math.min( + indexOf(primaryTimeUnits, getUnitFromValue(levelTicks[k].value, isUTC)), + levelUnitIdx + )]; ticks.push({ value: levelTicks[k].value, time: { diff --git a/test/ut/core/environment.js b/test/ut/core/environment.js new file mode 100644 index 0000000000..76fe8bece1 --- /dev/null +++ b/test/ut/core/environment.js @@ -0,0 +1,49 @@ +/* +* 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. +*/ + +const JSDOMEnvironment = require('jest-environment-jsdom'); + +// `process.env` in tests is a copy made by jest, so setting `TZ` on it does not +// change the time zone used by `Date`. `setTimezone` sets it on the real process. +class TestEnvironment extends JSDOMEnvironment { + + constructor(config, context) { + super(config, context); + const originalTZ = process.env.TZ; + this.global.setTimezone = function (timezone) { + if (timezone == null) { + timezone = originalTZ; + } + if (timezone == null) { + delete process.env.TZ; + } + else { + process.env.TZ = timezone; + } + }; + } + + async teardown() { + // Do not let the time zone leak into other test files run by the same worker. + this.global.setTimezone(); + await super.teardown(); + } +} + +module.exports = TestEnvironment; diff --git a/test/ut/index.d.ts b/test/ut/index.d.ts index c486c8ea7f..054afc18bd 100644 --- a/test/ut/index.d.ts +++ b/test/ut/index.d.ts @@ -23,6 +23,9 @@ declare global { const __DEV__: boolean; + // Set by `core/environment.js`. Restores the original time zone if `timezone` is not given. + function setTimezone(timezone?: string): void; + namespace jest { interface Matchers { toBeFinite(): R diff --git a/test/ut/jest.config.cjs b/test/ut/jest.config.cjs index 5ca8031953..c079dc4a9d 100644 --- a/test/ut/jest.config.cjs +++ b/test/ut/jest.config.cjs @@ -22,7 +22,7 @@ const { compilerOptions } = require('./tsconfig') module.exports = { preset: 'ts-jest', - testEnvironment: 'jsdom', + testEnvironment: '/core/environment.js', rootDir: __dirname, collectCoverage: true, setupFiles: [ diff --git a/test/ut/spec/scale/time.test.ts b/test/ut/spec/scale/time.test.ts new file mode 100644 index 0000000000..a4b9489a6f --- /dev/null +++ b/test/ut/spec/scale/time.test.ts @@ -0,0 +1,82 @@ +/* +* 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'; + + +describe('scale_time', function () { + + describe('dst', function () { + + let chart: EChartsType; + beforeEach(function () { + // Local midnight does not exist on 2026-03-29 in Atlantic/Azores (00:00 -> 01:00). + setTimezone('Atlantic/Azores'); + chart = createChart({width: 600, height: 300}); + }); + + afterEach(function () { + chart.dispose(); + setTimezone(); + }); + + it('day_ticks_after_midnight_dst_change', function () { + chart.setOption({ + animation: false, + xAxis: {type: 'time'}, + yAxis: {type: 'value'}, + series: [{ + type: 'line', + data: [ + [Date.parse('2026-03-26T12:00Z'), 1], + [Date.parse('2026-04-03T00:00Z'), 2] + ] + }] + }); + + const xAxis = getECModel(chart).getComponent('xAxis', 0) as CartesianAxisModel; + const labels = xAxis.axis.getViewLabels().filter(function (item) { + return !item.tick.notNice; + }); + + expect(labels.map(function (item) { + return item.tick.value; + })).toEqual([ + new Date(2026, 2, 27).getTime(), + new Date(2026, 2, 28).getTime(), + // Starts at 01:00 since 00:00 does not exist on that day. + new Date(2026, 2, 29).getTime(), + new Date(2026, 2, 30).getTime(), + new Date(2026, 2, 31).getTime(), + new Date(2026, 3, 1).getTime(), + new Date(2026, 3, 2).getTime(), + new Date(2026, 3, 3).getTime() + ]); + expect(new Date(labels[2].tick.value).getHours()).toEqual(1); + + expect(labels.map(function (item) { + return item.formattedLabel; + })).toEqual([ + '27', '28', '29', '30', '31', '{primary|Apr}', '2', '3' + ]); + }); + }); +});