Fix tick placement spanning multiple dsts (#35577) (#35942)

This commit is contained in:
Joe Reuter 2019-05-03 14:03:36 +02:00 committed by GitHub
parent 40d144f455
commit b353cdd90b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 298 additions and 10 deletions

View file

@ -20,7 +20,9 @@
import d3 from 'd3';
import _ from 'lodash';
import moment from 'moment';
import { InvalidLogScaleValues } from '../../../errors';
import { timeTicks } from './time_ticks';
export function VislibAxisScaleProvider() {
class AxisScale {
@ -217,16 +219,7 @@ export function VislibAxisScaleProvider() {
this.validateScale(this.scale);
if (this.axisConfig.isTimeDomain()) {
// on a time domain shift it to have the buckets start at nice points in time (e.g. at the start of the day) in UTC
// then shift the calculated tick positions back into the real domain to have a nice tick position in the actual
// time zone. This is necessary because the d3 time scale doesn't provide a function to get nice time positions in
// a configurable time zone directly.
const offset = moment(domain[0]).utcOffset();
const shiftedDomain = domain.map(val => moment(val).add(offset, 'minute'));
this.tickScale = scale.copy().domain(shiftedDomain);
this.scale.timezoneCorrectedTicks = (n) => this.tickScale.ticks(n).map((d) => {
return moment(d).subtract(offset, 'minute').valueOf();
});
this.scale.timezoneCorrectedTicks = timeTicks(scale);
}
return this.scale;

View file

@ -0,0 +1,47 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. 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 moment from 'moment';
export const timeTicks = scale => {
// on a time domain shift it to have the buckets start at nice points in time (e.g. at the start of the day) in UTC
// then shift the calculated tick positions back into the real domain to have a nice tick position in the actual
// time zone. This is necessary because the d3 time scale doesn't provide a function to get nice time positions in
// a configurable time zone directly.
const domain = scale.domain();
const startOffset = moment(domain[0]).utcOffset();
const shiftedDomain = domain.map(val => moment(val).add(startOffset, 'minute'));
const tickScale = scale.copy().domain(shiftedDomain);
return n => {
const ticks = tickScale.ticks(n);
const timePerTick = (domain[1] - domain[0]) / ticks.length;
const hourTicks = timePerTick < 1000 * 60 * 60 * 12;
return ticks.map(d => {
// To get a nice date for the tick, we have to shift the offset of the current UTC tick. This is
// relevant in cases where the domain spans various DSTs.
// However if there are multiple ticks per day, this would cause a gap because the ticks are placed
// in UTC which doesn't have DST. In this case, always shift by the offset of the beginning of the domain.
const currentOffset = moment(d).utcOffset();
return moment(d)
.subtract(hourTicks ? startOffset : currentOffset, 'minute')
.valueOf();
});
};
};

View file

@ -0,0 +1,248 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. 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 d3 from 'd3';
import moment from 'moment-timezone';
import { timeTicks } from './time_ticks';
const timezonesToTest = [
'Asia/Tokyo',
'Europe/Berlin',
'UTC',
'America/New York',
'America/Los_Angeles',
];
describe('timeTicks', () => {
let scale;
beforeEach(() => {
scale = d3.time.scale.utc();
});
afterEach(() => {
moment.tz.setDefault();
});
timezonesToTest.map(tz => {
describe(`standard tests in ${tz}`, () => {
beforeEach(() => {
moment.tz.setDefault(tz);
});
it('should return nice daily ticks', () => {
scale.domain([
moment('2019-04-04 00:00:00').valueOf(),
moment('2019-04-08 00:00:00').valueOf(),
]);
const tickFn = timeTicks(scale);
const ticks = tickFn(5);
expect(ticks).toEqual([
moment('2019-04-04 00:00:00').valueOf(),
moment('2019-04-05 00:00:00').valueOf(),
moment('2019-04-06 00:00:00').valueOf(),
moment('2019-04-07 00:00:00').valueOf(),
moment('2019-04-08 00:00:00').valueOf(),
]);
});
it('should return nice hourly ticks', () => {
scale.domain([
moment('2019-04-04 00:00:00').valueOf(),
moment('2019-04-04 04:00:00').valueOf(),
]);
const tickFn = timeTicks(scale);
const ticks = tickFn(5);
expect(ticks).toEqual([
moment('2019-04-04 00:00:00').valueOf(),
moment('2019-04-04 01:00:00').valueOf(),
moment('2019-04-04 02:00:00').valueOf(),
moment('2019-04-04 03:00:00').valueOf(),
moment('2019-04-04 04:00:00').valueOf(),
]);
});
it('should return nice yearly ticks', () => {
scale.domain([
moment('2010-04-04 00:00:00').valueOf(),
moment('2019-04-04 04:00:00').valueOf(),
]);
const tickFn = timeTicks(scale);
const ticks = tickFn(9);
expect(ticks).toEqual([
moment('2011-01-01 00:00:00').valueOf(),
moment('2012-01-01 00:00:00').valueOf(),
moment('2013-01-01 00:00:00').valueOf(),
moment('2014-01-01 00:00:00').valueOf(),
moment('2015-01-01 00:00:00').valueOf(),
moment('2016-01-01 00:00:00').valueOf(),
moment('2017-01-01 00:00:00').valueOf(),
moment('2018-01-01 00:00:00').valueOf(),
moment('2019-01-01 00:00:00').valueOf(),
]);
});
it('should return nice yearly ticks from leap year to leap year', () => {
scale.domain([
moment('2016-02-29 00:00:00').valueOf(),
moment('2020-04-29 00:00:00').valueOf(),
]);
const tickFn = timeTicks(scale);
const ticks = tickFn(4);
expect(ticks).toEqual([
moment('2017-01-01 00:00:00').valueOf(),
moment('2018-01-01 00:00:00').valueOf(),
moment('2019-01-01 00:00:00').valueOf(),
moment('2020-01-01 00:00:00').valueOf(),
]);
});
});
});
describe('dst switch', () => {
it('should not leave gaps in hourly ticks on dst switch winter to summer time', () => {
moment.tz.setDefault('Europe/Berlin');
scale.domain([
moment('2019-03-31 01:00:00').valueOf(),
moment('2019-03-31 03:00:00').valueOf(),
]);
const tickFn = timeTicks(scale);
const ticks = tickFn(5);
expect(ticks).toEqual([
moment('2019-03-31 01:00:00').valueOf(),
moment('2019-03-31 01:15:00').valueOf(),
moment('2019-03-31 01:30:00').valueOf(),
moment('2019-03-31 01:45:00').valueOf(),
moment('2019-03-31 03:00:00').valueOf(),
]);
});
it('should not leave gaps in hourly ticks on dst switch summer to winter time', () => {
moment.tz.setDefault('Europe/Berlin');
scale.domain([
moment('2019-10-27 02:00:00').valueOf(),
moment('2019-10-27 05:00:00').valueOf(),
]);
const tickFn = timeTicks(scale);
const ticks = tickFn(5);
expect(ticks).toEqual([
moment('2019-10-27 02:00:00').valueOf(),
// this is the "first" 3 o'clock still in summer time
moment('2019-10-27 03:00:00+02:00').valueOf(),
moment('2019-10-27 03:00:00').valueOf(),
moment('2019-10-27 04:00:00').valueOf(),
moment('2019-10-27 05:00:00').valueOf(),
]);
});
it('should set nice daily ticks on dst switch summer to winter time', () => {
moment.tz.setDefault('Europe/Berlin');
scale.domain([
moment('2019-10-25 16:00:00').valueOf(),
moment('2019-10-30 08:00:00').valueOf(),
]);
const tickFn = timeTicks(scale);
const ticks = tickFn(5);
expect(ticks).toEqual([
moment('2019-10-26 00:00:00').valueOf(),
moment('2019-10-27 00:00:00').valueOf(),
moment('2019-10-28 00:00:00').valueOf(),
moment('2019-10-29 00:00:00').valueOf(),
moment('2019-10-30 00:00:00').valueOf(),
]);
});
it('should set nice daily ticks on dst switch winter to summer time', () => {
moment.tz.setDefault('Europe/Berlin');
scale.domain([
moment('2019-03-29 16:00:00').valueOf(),
moment('2019-04-03 08:00:00').valueOf(),
]);
const tickFn = timeTicks(scale);
const ticks = tickFn(5);
expect(ticks).toEqual([
moment('2019-03-30 00:00:00').valueOf(),
moment('2019-03-31 00:00:00').valueOf(),
moment('2019-04-01 00:00:00').valueOf(),
moment('2019-04-02 00:00:00').valueOf(),
moment('2019-04-03 00:00:00').valueOf(),
]);
});
it('should set nice monthly ticks on two dst switches from winter to winter time', () => {
moment.tz.setDefault('Europe/Berlin');
scale.domain([
moment('2019-03-29 00:00:00').valueOf(),
moment('2019-11-01 00:00:00').valueOf(),
]);
const tickFn = timeTicks(scale);
const ticks = tickFn(8);
expect(ticks).toEqual([
moment('2019-04-01 00:00:00').valueOf(),
moment('2019-05-01 00:00:00').valueOf(),
moment('2019-06-01 00:00:00').valueOf(),
moment('2019-07-01 00:00:00').valueOf(),
moment('2019-08-01 00:00:00').valueOf(),
moment('2019-09-01 00:00:00').valueOf(),
moment('2019-10-01 00:00:00').valueOf(),
moment('2019-11-01 00:00:00').valueOf(),
]);
});
it('should set nice monthly ticks on two dst switches from summer to summer time', () => {
moment.tz.setDefault('Europe/Berlin');
scale.domain([
moment('2018-10-26 00:00:00').valueOf(),
moment('2019-03-31 20:00:00').valueOf(),
]);
const tickFn = timeTicks(scale);
const ticks = tickFn(5);
expect(ticks).toEqual([
moment('2018-11-01 00:00:00').valueOf(),
moment('2018-12-01 00:00:00').valueOf(),
moment('2019-01-01 00:00:00').valueOf(),
moment('2019-02-01 00:00:00').valueOf(),
moment('2019-03-01 00:00:00').valueOf(),
]);
});
});
});