From 27133362edf8cff880f21c7293f7c992554f3873 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 3 May 2019 17:41:16 +0200 Subject: [PATCH] fix(scales): improve ticks for time domains spanning a DST switch (#204) Take current offset into account when shifting ticks back into the real domain to make sure ticks are placed correctly. --- src/lib/utils/scales/scale_continuous.test.ts | 239 +++++++++++++++++- src/lib/utils/scales/scale_continuous.ts | 13 +- 2 files changed, 248 insertions(+), 4 deletions(-) diff --git a/src/lib/utils/scales/scale_continuous.test.ts b/src/lib/utils/scales/scale_continuous.test.ts index 113facc755..5831824c76 100644 --- a/src/lib/utils/scales/scale_continuous.test.ts +++ b/src/lib/utils/scales/scale_continuous.test.ts @@ -1,7 +1,7 @@ -import { DateTime } from 'luxon'; import { XDomain } from '../../series/domains/x_domain'; import { computeXScale } from '../../series/scales'; import { Domain } from '../domain'; +import { DateTime, Settings } from 'luxon'; import { ScaleBand } from './scale_band'; import { isLogarithmicScale, ScaleContinuous } from './scale_continuous'; import { ScaleType } from './scales'; @@ -139,4 +139,241 @@ describe('Scale Continuous', () => { expect(scaleLinear.invertWithStep(50, data)).toBe(50); expect(scaleLinear.invertWithStep(90, data)).toBe(90); }); + + describe('time ticks', () => { + const timezonesToTest = [ + 'Asia/Tokyo', + 'Europe/Berlin', + 'UTC', + 'America/New_York', + 'America/Los_Angeles', + ]; + + function getTicksForDomain(domainStart: number, domainEnd: number) { + const scale = new ScaleContinuous( + ScaleType.Time, + [domainStart, domainEnd], + [0, 100], + 0, + 0, + Settings.defaultZoneName, + ); + return scale.tickValues; + } + + const currentTz = Settings.defaultZoneName; + + afterEach(() => { + Settings.defaultZoneName = currentTz; + }); + + timezonesToTest.map((tz) => { + describe(`standard tests in ${tz}`, () => { + beforeEach(() => { + Settings.defaultZoneName = tz; + }); + + test('should return nice daily ticks', () => { + const ticks = getTicksForDomain( + DateTime.fromISO('2019-04-04T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-08T00:00:00.000').toMillis(), + ); + + expect(ticks).toEqual([ + DateTime.fromISO('2019-04-04T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T12:00:00.000').toMillis(), + DateTime.fromISO('2019-04-05T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-05T12:00:00.000').toMillis(), + DateTime.fromISO('2019-04-06T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-06T12:00:00.000').toMillis(), + DateTime.fromISO('2019-04-07T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-07T12:00:00.000').toMillis(), + DateTime.fromISO('2019-04-08T00:00:00.000').toMillis(), + ]); + }); + + test('should return nice hourly ticks', () => { + const ticks = getTicksForDomain( + DateTime.fromISO('2019-04-04T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T08:00:00.000').toMillis(), + ); + + expect(ticks).toEqual([ + DateTime.fromISO('2019-04-04T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T01:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T02:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T03:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T04:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T05:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T06:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T07:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T08:00:00.000').toMillis(), + ]); + }); + + test('should return nice yearly ticks', () => { + const ticks = getTicksForDomain( + DateTime.fromISO('2010-04-04T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T04:00:00.000').toMillis(), + ); + + expect(ticks).toEqual([ + DateTime.fromISO('2011-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2012-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2013-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2014-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2015-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2016-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2017-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2018-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-01-01T00:00:00.000').toMillis(), + ]); + }); + + test('should return nice yearly ticks from leap year to leap year', () => { + const ticks = getTicksForDomain( + DateTime.fromISO('2016-02-29T00:00:00.000').toMillis(), + DateTime.fromISO('2024-04-29T00:00:00.000').toMillis(), + ); + + expect(ticks).toEqual([ + DateTime.fromISO('2017-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2018-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2020-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2021-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2022-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2023-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2024-01-01T00:00:00.000').toMillis(), + ]); + }); + }); + }); + + describe('dst switch', () => { + test('should not leave gaps in hourly ticks on dst switch winter to summer time', () => { + Settings.defaultZoneName = 'Europe/Berlin'; + + const ticks = getTicksForDomain( + DateTime.fromISO('2019-03-31T01:00:00.000').toMillis(), + DateTime.fromISO('2019-03-31T10:00:00.000').toMillis(), + ); + + expect(ticks).toEqual([ + DateTime.fromISO('2019-03-31T01:00:00.000').toMillis(), + DateTime.fromISO('2019-03-31T02:00:00.000').toMillis(), + // 3 AM is missing because it is the same as 2 AM + DateTime.fromISO('2019-03-31T04:00:00.000').toMillis(), + DateTime.fromISO('2019-03-31T05:00:00.000').toMillis(), + DateTime.fromISO('2019-03-31T06:00:00.000').toMillis(), + DateTime.fromISO('2019-03-31T07:00:00.000').toMillis(), + DateTime.fromISO('2019-03-31T08:00:00.000').toMillis(), + DateTime.fromISO('2019-03-31T09:00:00.000').toMillis(), + DateTime.fromISO('2019-03-31T10:00:00.000').toMillis(), + ]); + }); + + test('should not leave gaps in hourly ticks on dst switch summer to winter time', () => { + Settings.defaultZoneName = 'Europe/Berlin'; + + const ticks = getTicksForDomain( + DateTime.fromISO('2019-10-27T01:00:00.000').toMillis(), + DateTime.fromISO('2019-10-27T09:00:00.000').toMillis(), + ); + + expect(ticks).toEqual([ + DateTime.fromISO('2019-10-27T01:00:00.000').toMillis(), + DateTime.fromISO('2019-10-27T02:00:00.000').toMillis(), + // this is the "first" 3 o'clock still in summer time + DateTime.fromISO('2019-10-27T03:00:00.000+02:00').toMillis(), + DateTime.fromISO('2019-10-27T03:00:00.000').toMillis(), + DateTime.fromISO('2019-10-27T04:00:00.000').toMillis(), + DateTime.fromISO('2019-10-27T05:00:00.000').toMillis(), + DateTime.fromISO('2019-10-27T06:00:00.000').toMillis(), + DateTime.fromISO('2019-10-27T07:00:00.000').toMillis(), + DateTime.fromISO('2019-10-27T08:00:00.000').toMillis(), + DateTime.fromISO('2019-10-27T09:00:00.000').toMillis(), + ]); + }); + + test('should set nice daily ticks on dst switch summer to winter time', () => { + Settings.defaultZoneName = 'Europe/Berlin'; + + const ticks = getTicksForDomain( + DateTime.fromISO('2019-10-25T16:00:00.000').toMillis(), + DateTime.fromISO('2019-11-03T08:00:00.000').toMillis(), + ); + + expect(ticks).toEqual([ + DateTime.fromISO('2019-10-26T00:00:00.000').toMillis(), + DateTime.fromISO('2019-10-27T00:00:00.000').toMillis(), + DateTime.fromISO('2019-10-28T00:00:00.000').toMillis(), + DateTime.fromISO('2019-10-29T00:00:00.000').toMillis(), + DateTime.fromISO('2019-10-30T00:00:00.000').toMillis(), + DateTime.fromISO('2019-10-31T00:00:00.000').toMillis(), + DateTime.fromISO('2019-11-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-11-02T00:00:00.000').toMillis(), + DateTime.fromISO('2019-11-03T00:00:00.000').toMillis(), + ]); + }); + + test('should set nice daily ticks on dst switch winter to summer time', () => { + Settings.defaultZoneName = 'Europe/Berlin'; + + const ticks = getTicksForDomain( + DateTime.fromISO('2019-03-29T16:00:00.000').toMillis(), + DateTime.fromISO('2019-04-07T08:00:00.000').toMillis(), + ); + + expect(ticks).toEqual([ + DateTime.fromISO('2019-03-30T00:00:00.000').toMillis(), + DateTime.fromISO('2019-03-31T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-02T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-03T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-04T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-05T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-06T00:00:00.000').toMillis(), + DateTime.fromISO('2019-04-07T00:00:00.000').toMillis(), + ]); + }); + + test('should set nice monthly ticks on two dst switches from winter to winter time', () => { + Settings.defaultZoneName = 'Europe/Berlin'; + + const ticks = getTicksForDomain( + DateTime.fromISO('2019-03-29T00:00:00.000').toMillis(), + DateTime.fromISO('2019-11-02T00:00:00.000').toMillis(), + ); + + expect(ticks).toEqual([ + DateTime.fromISO('2019-04-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-05-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-06-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-07-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-08-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-09-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-10-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-11-01T00:00:00.000').toMillis(), + ]); + }); + + test('should set nice monthly ticks on two dst switches from summer to summer time', () => { + Settings.defaultZoneName = 'Europe/Berlin'; + + const ticks = getTicksForDomain( + DateTime.fromISO('2018-10-26T00:00:00.000').toMillis(), + DateTime.fromISO('2019-03-31T20:00:00.000').toMillis(), + ); + + expect(ticks).toEqual([ + DateTime.fromISO('2018-11-01T00:00:00.000').toMillis(), + DateTime.fromISO('2018-12-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-01-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-02-01T00:00:00.000').toMillis(), + DateTime.fromISO('2019-03-01T00:00:00.000').toMillis(), + ]); + }); + }); + }); }); diff --git a/src/lib/utils/scales/scale_continuous.ts b/src/lib/utils/scales/scale_continuous.ts index 5524f17b84..4ee3ce2775 100644 --- a/src/lib/utils/scales/scale_continuous.ts +++ b/src/lib/utils/scales/scale_continuous.ts @@ -138,11 +138,18 @@ export class ScaleContinuous implements Scale { const shiftedDomainMax = endDomain.plus({ minutes: offset }).toMillis(); const tzShiftedScale = scaleUtc().domain([shiftedDomainMin, shiftedDomainMax]); - this.tickValues = tzShiftedScale.ticks().map((d: Date) => { - return DateTime.fromMillis(d.getTime(), { zone: this.timeZone }) - .minus({ minutes: offset }) + const rawTicks = tzShiftedScale.ticks(); + const timePerTick = (shiftedDomainMax - shiftedDomainMin) / rawTicks.length; + const hasHourTicks = timePerTick < 1000 * 60 * 60 * 12; + + this.tickValues = rawTicks.map((d: Date) => { + const currentDateTime = DateTime.fromJSDate(d, { zone: this.timeZone }); + const currentOffset = hasHourTicks ? offset : currentDateTime.offset; + return currentDateTime + .minus({ minutes: currentOffset }) .toMillis(); }); + } else { if (this.minInterval > 0) { const intervalCount = Math.floor((this.domain[1] - this.domain[0]) / this.minInterval);