diff --git a/.storybook/config.ts b/.storybook/config.ts index bd429173b4..af8e9664b3 100644 --- a/.storybook/config.ts +++ b/.storybook/config.ts @@ -42,6 +42,7 @@ function loadStories() { require('../stories/styling.tsx'); require('../stories/grid.tsx'); require('../stories/annotations.tsx'); + require('../stories/scales.tsx'); } configure(loadStories, module); diff --git a/src/lib/axes/axis_utils.ts b/src/lib/axes/axis_utils.ts index 7f8d95c84d..fdd3de3c20 100644 --- a/src/lib/axes/axis_utils.ts +++ b/src/lib/axes/axis_utils.ts @@ -144,36 +144,39 @@ export const getMaxBboxDimensions = ( fontSize: number, fontFamily: string, tickLabelRotation: number, -) => (acc: { [key: string]: number }, tickLabel: string): { - maxLabelBboxWidth: number, - maxLabelBboxHeight: number, - maxLabelTextWidth: number, - maxLabelTextHeight: number, +) => ( + acc: { [key: string]: number }, + tickLabel: string, +): { + maxLabelBboxWidth: number; + maxLabelBboxHeight: number; + maxLabelTextWidth: number; + maxLabelTextHeight: number; } => { - const bbox = bboxCalculator.compute(tickLabel, fontSize, fontFamily).getOrElse({ - width: 0, - height: 0, - }); + const bbox = bboxCalculator.compute(tickLabel, fontSize, fontFamily).getOrElse({ + width: 0, + height: 0, + }); - const rotatedBbox = computeRotatedLabelDimensions(bbox, tickLabelRotation); + const rotatedBbox = computeRotatedLabelDimensions(bbox, tickLabelRotation); - const width = Math.ceil(rotatedBbox.width); - const height = Math.ceil(rotatedBbox.height); - const labelWidth = Math.ceil(bbox.width); - const labelHeight = Math.ceil(bbox.height); + const width = Math.ceil(rotatedBbox.width); + const height = Math.ceil(rotatedBbox.height); + const labelWidth = Math.ceil(bbox.width); + const labelHeight = Math.ceil(bbox.height); - const prevWidth = acc.maxLabelBboxWidth; - const prevHeight = acc.maxLabelBboxHeight; - const prevLabelWidth = acc.maxLabelTextWidth; - const prevLabelHeight = acc.maxLabelTextHeight; + const prevWidth = acc.maxLabelBboxWidth; + const prevHeight = acc.maxLabelBboxHeight; + const prevLabelWidth = acc.maxLabelTextWidth; + const prevLabelHeight = acc.maxLabelTextHeight; - return { - maxLabelBboxWidth: prevWidth > width ? prevWidth : width, - maxLabelBboxHeight: prevHeight > height ? prevHeight : height, - maxLabelTextWidth: prevLabelWidth > labelWidth ? prevLabelWidth : labelWidth, - maxLabelTextHeight: prevLabelHeight > labelHeight ? prevLabelHeight : labelHeight, - }; + return { + maxLabelBboxWidth: prevWidth > width ? prevWidth : width, + maxLabelBboxHeight: prevHeight > height ? prevHeight : height, + maxLabelTextWidth: prevLabelWidth > labelWidth ? prevLabelWidth : labelWidth, + maxLabelTextHeight: prevLabelHeight > labelHeight ? prevLabelHeight : labelHeight, }; +}; function computeTickDimensions( scale: Scale, @@ -562,11 +565,7 @@ export function getAxisTicksPositions( } const allTicks = getAvailableTicks(axisSpec, scale, totalGroupsCount); - const visibleTicks = getVisibleTicks( - allTicks, - axisSpec, - axisDim, - ); + const visibleTicks = getVisibleTicks(allTicks, axisSpec, axisDim); if (axisSpec.showGridLines) { const isVerticalAxis = isVertical(axisSpec.position); @@ -637,7 +636,9 @@ export function isUpperBound(domain: Partial): domain is return domain.max != null; } -export function isCompleteBound(domain: Partial): domain is CompleteBoundedDomain { +export function isCompleteBound( + domain: Partial, +): domain is CompleteBoundedDomain { return domain.max != null && domain.min != null; } @@ -675,8 +676,8 @@ export function mergeDomainsByGroupId( if (prevGroupDomain) { const prevDomain = prevGroupDomain as DomainRange; - const prevMin = (isLowerBound(prevDomain)) ? prevDomain.min : undefined; - const prevMax = (isUpperBound(prevDomain)) ? prevDomain.max : undefined; + const prevMin = isLowerBound(prevDomain) ? prevDomain.min : undefined; + const prevMax = isUpperBound(prevDomain) ? prevDomain.max : undefined; let max = prevMax; let min = prevMin; diff --git a/src/lib/series/domains/domain.ts b/src/lib/series/domains/domain.ts index 801b009415..d8020edadd 100644 --- a/src/lib/series/domains/domain.ts +++ b/src/lib/series/domains/domain.ts @@ -4,5 +4,6 @@ import { ScaleType } from '../../utils/scales/scales'; export interface BaseDomain { scaleType: ScaleType; domain: Domain; + /* if the scale needs to be a band scale: used when displaying bars */ isBandScale: boolean; } diff --git a/src/lib/series/domains/x_domain.test.ts b/src/lib/series/domains/x_domain.test.ts index 49340a821c..01dd25fbf3 100644 --- a/src/lib/series/domains/x_domain.test.ts +++ b/src/lib/series/domains/x_domain.test.ts @@ -59,18 +59,61 @@ describe('X Domain', () => { }); }); test('Should return correct scale type with single line (time)', () => { - const seriesSpecs: Array> = [ + const seriesSpecs: Array> = [ + { + seriesType: 'line', + xScaleType: ScaleType.Time, + timeZone: 'utc-3', + }, + ]; + const mainXScale = convertXScaleTypes(seriesSpecs); + expect(mainXScale).toEqual({ + scaleType: ScaleType.Time, + isBandScale: false, + timeZone: 'utc-3', + }); + }); + test('Should return correct scale type with multi line with same scale types (time) same tz', () => { + const seriesSpecs: Array> = [ + { + seriesType: 'line', + xScaleType: ScaleType.Time, + timeZone: 'UTC-3', + }, + { + seriesType: 'line', + xScaleType: ScaleType.Time, + timeZone: 'utc-3', + }, + ]; + const mainXScale = convertXScaleTypes(seriesSpecs); + expect(mainXScale).toEqual({ + scaleType: ScaleType.Time, + isBandScale: false, + timeZone: 'utc-3', + }); + }); + test('Should return correct scale type with multi line with same scale types (time) coerce to UTC', () => { + const seriesSpecs: Array> = [ { seriesType: 'line', xScaleType: ScaleType.Time, + timeZone: 'utc-3', + }, + { + seriesType: 'line', + xScaleType: ScaleType.Time, + timeZone: 'utc+3', }, ]; const mainXScale = convertXScaleTypes(seriesSpecs); expect(mainXScale).toEqual({ scaleType: ScaleType.Time, isBandScale: false, + timeZone: 'utc', }); }); + test('Should return correct scale type with multi line with different scale types (linear, ordinal)', () => { const seriesSpecs: Array> = [ { @@ -106,7 +149,7 @@ describe('X Domain', () => { }); }); test('Should return correct scale type with multi bar, area with same scale types (linear, linear)', () => { - const seriesSpecs: Array> = [ + const seriesSpecs: Array> = [ { seriesType: 'bar', xScaleType: ScaleType.Linear, @@ -114,6 +157,7 @@ describe('X Domain', () => { { seriesType: 'area', xScaleType: ScaleType.Time, + timeZone: 'utc+3', }, ]; const mainXScale = convertXScaleTypes(seriesSpecs); @@ -272,11 +316,11 @@ describe('X Domain', () => { [ { seriesType: 'bar', - xScaleType: ScaleType.Time, + xScaleType: ScaleType.Linear, }, { seriesType: 'bar', - xScaleType: ScaleType.Time, + xScaleType: ScaleType.Linear, }, ], xValues, @@ -374,7 +418,7 @@ describe('X Domain', () => { seriesType: 'bar', xAccessor: 'x', yAccessors: ['y'], - xScaleType: ScaleType.Linear, + xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, yScaleToDataExtent: false, data: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }, { x: 5, y: 0 }], @@ -457,7 +501,7 @@ describe('X Domain', () => { const ds1: BasicSeriesSpec = { id: getSpecId('ds1'), groupId: getGroupId('g1'), - seriesType: 'line', + seriesType: 'area', xAccessor: 'x', yAccessors: ['y'], xScaleType: ScaleType.Linear, @@ -471,7 +515,7 @@ describe('X Domain', () => { seriesType: 'line', xAccessor: 'x', yAccessors: ['y'], - xScaleType: ScaleType.Linear, + xScaleType: ScaleType.Ordinal, yScaleType: ScaleType.Linear, yScaleToDataExtent: false, data: new Array(maxValues).fill(0).map((d, i) => ({ x: i, y: i })), diff --git a/src/lib/series/domains/x_domain.ts b/src/lib/series/domains/x_domain.ts index b428658cae..e996e3ed1e 100644 --- a/src/lib/series/domains/x_domain.ts +++ b/src/lib/series/domains/x_domain.ts @@ -7,10 +7,10 @@ import { BaseDomain } from './domain'; export type XDomain = BaseDomain & { type: 'xDomain'; - /* if the scale needs to be a band scale: used when displaying bars */ - isBandScale: boolean; /* the minimum interval of the scale if not-ordinal band-scale*/ minInterval: number; + /** if x domain is time, we should also specify the timezone */ + timeZone?: string; }; /** @@ -25,7 +25,6 @@ export function mergeXDomain( if (!mainXScaleType) { throw new Error('Cannot merge the domain. Missing X scale types'); } - // TODO: compute this domain merging also/overwritted by any configured static domains const values = [...xValues.values()]; let seriesXComputedDomains; @@ -81,6 +80,7 @@ export function mergeXDomain( isBandScale: mainXScaleType.isBandScale, domain: seriesXComputedDomains, minInterval, + timeZone: mainXScaleType.timeZone, }; } @@ -119,20 +119,37 @@ export function findMinInterval(xValues: number[]): number { * @returns {ChartScaleType} */ export function convertXScaleTypes( - specs: Array>, -): Pick | null { + specs: Array>, +): { + scaleType: ScaleType; + isBandScale: boolean; + timeZone?: string; +} | null { const seriesTypes = new Set(); const scaleTypes = new Set(); + const timeZones = new Set(); specs.forEach((spec) => { seriesTypes.add(spec.seriesType); scaleTypes.add(spec.xScaleType); + if (spec.timeZone) { + timeZones.add(spec.timeZone.toLowerCase()); + } }); if (specs.length === 0 || seriesTypes.size === 0 || scaleTypes.size === 0) { return null; } const isBandScale = seriesTypes.has('bar'); if (scaleTypes.size === 1) { - return { scaleType: [...scaleTypes.values()][0], isBandScale }; + const scaleType = scaleTypes.values().next().value; + let timeZone: string | undefined; + if (scaleType === ScaleType.Time) { + if (timeZones.size > 1) { + timeZone = 'utc'; + } else { + timeZone = timeZones.values().next().value; + } + } + return { scaleType, isBandScale, timeZone }; } if (scaleTypes.size > 1 && scaleTypes.has(ScaleType.Ordinal)) { diff --git a/src/lib/series/scales.ts b/src/lib/series/scales.ts index 394f42a38a..704fa2abab 100644 --- a/src/lib/series/scales.ts +++ b/src/lib/series/scales.ts @@ -53,7 +53,7 @@ export function computeXScale( minRange: number, maxRange: number, ): Scale { - const { scaleType, minInterval, domain, isBandScale } = xDomain; + const { scaleType, minInterval, domain, isBandScale, timeZone } = xDomain; const rangeDiff = Math.abs(maxRange - minRange); const isInverse = maxRange < minRange; if (scaleType === ScaleType.Ordinal) { @@ -74,6 +74,7 @@ export function computeXScale( bandwidth / totalBarsInCluster, false, minInterval, + timeZone, ); } else { return createContinuousScale( @@ -84,6 +85,7 @@ export function computeXScale( 0, undefined, minInterval, + timeZone, ); } } diff --git a/src/lib/series/specs.ts b/src/lib/series/specs.ts index cf19aa4c0c..d8c1c659de 100644 --- a/src/lib/series/specs.ts +++ b/src/lib/series/specs.ts @@ -66,6 +66,13 @@ export interface SeriesScales { * @default ScaleType.Ordinal */ xScaleType: ScaleType.Ordinal | ScaleType.Linear | ScaleType.Time; + /** + * If using a ScaleType.Time this timezone identifier is required to + * compute a nice set of xScale ticks. Can be any IANA zone supported by + * the host environment, or a fixed-offset name of the form 'utc+3', + * or the strings 'local' or 'utc'. + */ + timeZone?: string; /** * The y axis scale type * @default ScaleType.Linear diff --git a/src/lib/utils/scales/scale_continuous.test.ts b/src/lib/utils/scales/scale_continuous.test.ts index cf7b9b1f1e..eabe9c75e6 100644 --- a/src/lib/utils/scales/scale_continuous.test.ts +++ b/src/lib/utils/scales/scale_continuous.test.ts @@ -3,18 +3,7 @@ import { ScaleBand } from './scale_band'; import { isLogarithmicScale, ScaleContinuous } from './scale_continuous'; import { ScaleType } from './scales'; -describe.only('Scale Continuous', () => { - /** - * These tests cover the following cases: - * line/area with simple linear scale - * line/area chart with time scale (ac: w axis) - * barscale with linear scale (bc: with linear x axis) - * barscale with time scale (bc: with time x axis) - * bar + line with linear scale (mc: bar and lines) - * bar + line with time scale (missing story) - * bar clustered with time scale (bc: time clustered using various specs) - * bar clustered with linear scale (bc: clustered multiple series specs) - */ +describe('Scale Continuous', () => { test('shall invert on continuous scale linear', () => { const domain = [0, 2]; const minRange = 0; diff --git a/src/lib/utils/scales/scale_continuous.ts b/src/lib/utils/scales/scale_continuous.ts index ca8813e06d..1d9f861909 100644 --- a/src/lib/utils/scales/scale_continuous.ts +++ b/src/lib/utils/scales/scale_continuous.ts @@ -1,4 +1,4 @@ -import { scaleLinear, scaleLog, scaleSqrt, scaleTime } from 'd3-scale'; +import { scaleLinear, scaleLog, scaleSqrt, scaleUtc } from 'd3-scale'; import { DateTime } from 'luxon'; import { ScaleContinuousType, ScaleType } from './scales'; import { Scale } from './scales'; @@ -7,7 +7,7 @@ const SCALES = { [ScaleType.Linear]: scaleLinear, [ScaleType.Log]: scaleLog, [ScaleType.Sqrt]: scaleSqrt, - [ScaleType.Time]: scaleTime, + [ScaleType.Time]: scaleUtc, }; export function limitToMin(value: number, positive: boolean) { @@ -74,6 +74,7 @@ export class ScaleContinuous implements Scale { readonly range: number[]; readonly isInverted: boolean; readonly tickValues: number[]; + readonly timeZone: string; private readonly d3Scale: any; constructor( @@ -84,6 +85,11 @@ export class ScaleContinuous implements Scale { bandwidth: number = 0, /** the min interval computed on the XDomain, not available for yDomains */ minInterval: number = 0, + /** + * A time zone identifier. Can be any IANA zone supported by he host environment, + * or a fixed-offset name of the form 'utc+3', or the strings 'local' or 'utc'. + */ + timeZone: string = 'utc', ) { this.d3Scale = SCALES[type](); if (type === ScaleType.Log) { @@ -102,9 +108,19 @@ export class ScaleContinuous implements Scale { this.range = range; this.minInterval = minInterval; this.isInverted = this.domain[0] > this.domain[1]; + this.timeZone = timeZone; if (type === ScaleType.Time) { - this.tickValues = this.d3Scale.ticks().map((d: Date) => { - return DateTime.fromJSDate(d).toMillis(); + const startDomain = DateTime.fromMillis(this.domain[0], { zone: this.timeZone }); + const endDomain = DateTime.fromMillis(this.domain[1], { zone: this.timeZone }); + const offset = startDomain.offset; + const shiftedDomainMin = startDomain.plus({ minutes: offset }).toMillis(); + 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 }) + .toMillis(); }); } else { if (this.minInterval > 0) { @@ -135,7 +151,7 @@ export class ScaleContinuous implements Scale { invertWithStep(value: number, stepType?: StepType) { const invertedValue = this.invert(value); const forcedStep = this.bandwidth > 0 ? StepType.StepAfter : stepType; - return invertValue(invertedValue, this.minInterval, forcedStep); + return invertValue(this.domain[0], invertedValue, this.minInterval, forcedStep); } } @@ -147,7 +163,12 @@ export function isLogarithmicScale(scale: Scale) { return scale.type === ScaleType.Log; } -function invertValue(invertedValue: number, minInterval: number, stepType?: StepType) { +function invertValue( + domainMin: number, + invertedValue: number, + minInterval: number, + stepType?: StepType, +) { if (minInterval > 0) { switch (stepType) { case StepType.StepAfter: @@ -156,7 +177,7 @@ function invertValue(invertedValue: number, minInterval: number, stepType?: Step return linearStepBefore(invertedValue, minInterval); case StepType.Step: default: - return linearStep(invertedValue, minInterval); + return linearStep(domainMin, invertedValue, minInterval); } } return invertedValue; @@ -176,13 +197,14 @@ export function linearStepAfter(invertedValue: number, minInterval: number): num * Return an inverted value that is valid from the half point before and half point * after the value. |----****|*****----| * till the end of the interval. + * @param domainMin the domain's minimum value * @param invertedValue the inverted value * @param minInterval the data minimum interval grether than 0 */ -export function linearStep(invertedValue: number, minInterval: number): number { - const diff = invertedValue / minInterval; +export function linearStep(domainMin: number, invertedValue: number, minInterval: number): number { + const diff = (invertedValue - domainMin) / minInterval; const base = diff - Math.floor(diff) > 0.5 ? 1 : 0; - return Math.floor(diff) * minInterval + minInterval * base; + return domainMin + Math.floor(diff) * minInterval + minInterval * base; } /** diff --git a/src/lib/utils/scales/scale_time.test.ts b/src/lib/utils/scales/scale_time.test.ts new file mode 100644 index 0000000000..34bd3354fc --- /dev/null +++ b/src/lib/utils/scales/scale_time.test.ts @@ -0,0 +1,254 @@ +import { DateTime } from 'luxon'; +import { ScaleContinuous } from './scale_continuous'; +import { ScaleType } from './scales'; + +describe('[Scale Time] - timezones', () => { + describe('timezone checks', () => { + // these tests are only for have a better understanding on how to deal with + // timezones, isos and formattings + test('[UTC] check equity of luxon and js Date', () => { + const DATE_STRING = '2019-01-01T00:00:00.000Z'; + const dateA = DateTime.fromISO(DATE_STRING, { setZone: true }); + const dateAInLocalTime = DateTime.fromISO(DATE_STRING, { setZone: false }); + const dateB = new Date(DATE_STRING); + expect(dateA.toMillis()).toBe(dateB.getTime()); + expect(dateA.zone.name).toBe('UTC'); + expect(dateA.toISO()).toEqual(DATE_STRING); + expect(dateB.toISOString()).toEqual(DATE_STRING); + expect(dateA.toISO()).toEqual(dateB.toISOString()); + // only valid if current timezone is +1 + // expect(dateAInLocalTime.toISO()).toEqual('2019-01-01T01:00:00.000+01:00'); + // if the date is already UTC, doesn't matter if you convert it to utc + expect(dateA.toUTC().toISO()).toEqual(DATE_STRING); + expect(dateB.toISOString()).toEqual(DATE_STRING); + expect(dateB.toISOString()).toEqual(dateA.toUTC().toISO()); + expect(dateB.toISOString()).toEqual(dateAInLocalTime.toUTC().toISO()); + }); + test('[with timezone] check equity of luxon and js Date', () => { + const DATE_STRING = '2019-01-01T00:00:00.000+05:00'; + const dateA = DateTime.fromISO(DATE_STRING, { setZone: true }); + const dateAInLocalTime = DateTime.fromISO(DATE_STRING, { setZone: false }); + const dateB = new Date(DATE_STRING); + expect(dateA.toMillis()).toBe(dateB.getTime()); + expect(dateAInLocalTime.toMillis()).toBe(dateB.getTime()); + expect(dateA.zone.name).toBe('UTC+5'); + // setting the setZone to true, the outputted ISO will keep the timezone + expect(dateA.toISO()).toEqual(DATE_STRING); + // js date toISOString is always in UTC + expect(dateB.toISOString()).toEqual('2018-12-31T19:00:00.000Z'); + // if we need the UTC version of the date, just call toUtC() + expect(dateB.toISOString()).toEqual(dateA.toUTC().toISO()); + expect(dateB.toISOString()).toEqual(dateAInLocalTime.toUTC().toISO()); + // moving everything to UTC is locale independent + expect(dateA.toUTC().toISO()).toEqual(dateAInLocalTime.toUTC().toISO()); + }); + test('[with timezone from millis] check equity of luxon and js Date', () => { + const DATE_STRING = '2019-01-01T00:00:00.000+05:00'; + const dateAFromString = DateTime.fromISO(DATE_STRING, { setZone: true }); + expect(dateAFromString.zone.name).toBe('UTC+5'); + expect(dateAFromString.toISO()).toBe(DATE_STRING); + + const dateAMillis = dateAFromString.toMillis(); + const dateAFromMillis = DateTime.fromMillis(dateAMillis, { setZone: true }); + // we cannot reconstruct Timezone from millis, millis specifies UTC only + expect(dateAFromMillis.toUTC().toISO()).toBe('2018-12-31T19:00:00.000Z'); + + const dateAFromStringLocale = DateTime.fromISO(DATE_STRING, { setZone: false }); + // if we don't use setZone we are using locale timezone + expect(dateAFromStringLocale.zone.name).toBe( + Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + + const dateAMillisFromLocale = dateAFromStringLocale.toMillis(); + expect(dateAMillisFromLocale).toEqual(dateAMillis); + const dateAFromMillisLocale = DateTime.fromMillis(dateAMillis, { setZone: true }); + // we cannot reconstruct Timezone from millis, millis specifies UTC only + expect(dateAFromMillisLocale.toUTC().toISO()).toBe('2018-12-31T19:00:00.000Z'); + }); + }); + describe('invert and ticks on different timezone', () => { + test('shall invert local', () => { + const startTime = DateTime.fromISO('2019-01-01T00:00:00.000').toMillis(); + const midTime = DateTime.fromISO('2019-01-02T00:00:00.000').toMillis(); + const endTime = DateTime.fromISO('2019-01-03T00:00:00.000').toMillis(); + const domain = [startTime, endTime]; + const minRange = 0; + const maxRange = 100; + const minInterval = (endTime - startTime) / 2; + const scale = new ScaleContinuous( + domain, + [minRange, maxRange], + ScaleType.Time, + undefined, + undefined, + minInterval, + 'local', + ); + expect(scale.invert(0)).toBe(startTime); + expect(scale.invert(50)).toBe(midTime); + expect(scale.invert(100)).toBe(endTime); + expect(scale.invertWithStep(0)).toBe(startTime); + expect(scale.invertWithStep(25)).toBe(startTime); + expect(scale.invertWithStep(26)).toBe(midTime); + expect(scale.invertWithStep(50)).toBe(midTime); + expect(scale.invertWithStep(75)).toBe(midTime); + expect(scale.invertWithStep(76)).toBe(endTime); + expect(scale.invertWithStep(100)).toBe(endTime); + expect(scale.tickValues.length).toBe(9); + expect(scale.tickValues[0]).toEqual(startTime); + expect(scale.tickValues[4]).toEqual(midTime); + expect(scale.tickValues[8]).toEqual(endTime); + }); + test('shall invert UTC', () => { + const startTime = DateTime.fromISO('2019-01-01T00:00:00.000Z').toMillis(); + const midTime = DateTime.fromISO('2019-01-02T00:00:00.000Z').toMillis(); + const endTime = DateTime.fromISO('2019-01-03T00:00:00.000Z').toMillis(); + const domain = [startTime, endTime]; + const minRange = 0; + const maxRange = 100; + const minInterval = (endTime - startTime) / 2; + const scale = new ScaleContinuous( + domain, + [minRange, maxRange], + ScaleType.Time, + undefined, + undefined, + minInterval, + 'utc', + ); + expect(scale.invert(0)).toBe(startTime); + expect(scale.invert(50)).toBe(midTime); + expect(scale.invert(100)).toBe(endTime); + expect(scale.invertWithStep(0)).toBe(startTime); + expect(scale.invertWithStep(25)).toBe(startTime); + expect(scale.invertWithStep(26)).toBe(midTime); + expect(scale.invertWithStep(50)).toBe(midTime); + expect(scale.invertWithStep(75)).toBe(midTime); + expect(scale.invertWithStep(76)).toBe(endTime); + expect(scale.invertWithStep(100)).toBe(endTime); + expect(scale.tickValues.length).toBe(9); + expect(scale.tickValues[0]).toEqual(startTime); + expect(scale.tickValues[4]).toEqual(midTime); + expect(scale.tickValues[8]).toEqual(endTime); + }); + test('shall invert +08:00', () => { + const startTime = DateTime.fromISO('2019-01-01T00:00:00.000+08:00').toMillis(); + const midTime = DateTime.fromISO('2019-01-02T00:00:00.000+08:00').toMillis(); + const endTime = DateTime.fromISO('2019-01-03T00:00:00.000+08:00').toMillis(); + const domain = [startTime, endTime]; + const minRange = 0; + const maxRange = 100; + const minInterval = (endTime - startTime) / 2; + const scale = new ScaleContinuous( + domain, + [minRange, maxRange], + ScaleType.Time, + undefined, + undefined, + minInterval, + 'utc+8', + ); + expect(scale.invert(0)).toBe(startTime); + expect(scale.invert(50)).toBe(midTime); + expect(scale.invert(100)).toBe(endTime); + expect(scale.invertWithStep(0)).toBe(startTime); + expect(scale.invertWithStep(25)).toBe(startTime); + expect(scale.invertWithStep(26)).toBe(midTime); + expect(scale.invertWithStep(50)).toBe(midTime); + expect(scale.invertWithStep(75)).toBe(midTime); + expect(scale.invertWithStep(76)).toBe(endTime); + expect(scale.invertWithStep(100)).toBe(endTime); + expect(scale.tickValues.length).toBe(9); + expect(scale.tickValues[0]).toEqual(startTime); + expect(scale.tickValues[4]).toEqual(midTime); + expect(scale.tickValues[8]).toEqual(endTime); + }); + test('shall invert -08:00', () => { + const startTime = DateTime.fromISO('2019-01-01T00:00:00.000-08:00').toMillis(); + const midTime = DateTime.fromISO('2019-01-02T00:00:00.000-08:00').toMillis(); + const endTime = DateTime.fromISO('2019-01-03T00:00:00.000-08:00').toMillis(); + const domain = [startTime, endTime]; + const minRange = 0; + const maxRange = 100; + const minInterval = (endTime - startTime) / 2; + const scale = new ScaleContinuous( + domain, + [minRange, maxRange], + ScaleType.Time, + undefined, + undefined, + minInterval, + 'utc-8', + ); + expect(scale.invert(0)).toBe(startTime); + expect(scale.invert(50)).toBe(midTime); + expect(scale.invert(100)).toBe(endTime); + expect(scale.invertWithStep(0)).toBe(startTime); + expect(scale.invertWithStep(25)).toBe(startTime); + expect(scale.invertWithStep(26)).toBe(midTime); + expect(scale.invertWithStep(50)).toBe(midTime); + expect(scale.invertWithStep(75)).toBe(midTime); + expect(scale.invertWithStep(76)).toBe(endTime); + expect(scale.invertWithStep(100)).toBe(endTime); + expect(scale.tickValues.length).toBe(9); + expect(scale.tickValues[0]).toEqual(startTime); + expect(scale.tickValues[4]).toEqual(midTime); + expect(scale.tickValues[8]).toEqual(endTime); + }); + test('shall invert all timezones', () => { + for (let i = -11; i <= 12; i++) { + const timezone = i === 0 ? 'utc' : i > 0 ? `utc+${i}` : `utc${i}`; + const startTime = DateTime.fromISO('2019-01-01T00:00:00.000', { + zone: timezone, + }).toMillis(); + const midTime = DateTime.fromISO('2019-01-02T00:00:00.000', { zone: timezone }).toMillis(); + const endTime = DateTime.fromISO('2019-01-03T00:00:00.000', { zone: timezone }).toMillis(); + const domain = [startTime, endTime]; + const minRange = 0; + const maxRange = 100; + const minInterval = (endTime - startTime) / 2; + const scale = new ScaleContinuous( + domain, + [minRange, maxRange], + ScaleType.Time, + undefined, + undefined, + minInterval, + timezone, + ); + const formatFunction = (d: number) => { + return DateTime.fromMillis(d, { zone: timezone }).toISO(); + }; + expect(scale.invert(0)).toBe(startTime); + expect(scale.invert(50)).toBe(midTime); + expect(scale.invert(100)).toBe(endTime); + expect(scale.invertWithStep(0)).toBe(startTime); + expect(scale.invertWithStep(25)).toBe(startTime); + expect(scale.invertWithStep(26)).toBe(midTime); + expect(scale.invertWithStep(50)).toBe(midTime); + expect(scale.invertWithStep(75)).toBe(midTime); + expect(scale.invertWithStep(76)).toBe(endTime); + expect(scale.invertWithStep(100)).toBe(endTime); + expect(scale.tickValues.length).toBe(9); + expect(scale.tickValues[0]).toEqual(startTime); + expect(scale.tickValues[4]).toEqual(midTime); + expect(scale.tickValues[8]).toEqual(endTime); + expect(formatFunction(scale.tickValues[0])).toEqual( + DateTime.fromISO('2019-01-01T00:00:00.000', { + zone: timezone, + }).toISO(), + ); + expect(formatFunction(scale.tickValues[4])).toEqual( + DateTime.fromISO('2019-01-02T00:00:00.000', { + zone: timezone, + }).toISO(), + ); + expect(formatFunction(scale.tickValues[8])).toEqual( + DateTime.fromISO('2019-01-03T00:00:00.000', { + zone: timezone, + }).toISO(), + ); + } + }); + }); +}); diff --git a/src/lib/utils/scales/scales.ts b/src/lib/utils/scales/scales.ts index 31a00a73f9..9ed3ea623d 100644 --- a/src/lib/utils/scales/scales.ts +++ b/src/lib/utils/scales/scales.ts @@ -57,8 +57,17 @@ export function createContinuousScale( bandwidth?: number, clamp?: boolean, minInterval?: number, + timeZone?: string, ): Scale { - return new ScaleContinuous(domain, [minRange, maxRange], type, clamp, bandwidth, minInterval); + return new ScaleContinuous( + domain, + [minRange, maxRange], + type, + clamp, + bandwidth, + minInterval, + timeZone, + ); } /** diff --git a/src/state/chart_state.timescales.test.ts b/src/state/chart_state.timescales.test.ts new file mode 100644 index 0000000000..f9e43f439b --- /dev/null +++ b/src/state/chart_state.timescales.test.ts @@ -0,0 +1,178 @@ +import { DateTime } from 'luxon'; +import { LineSeriesSpec } from '../lib/series/specs'; +import { LIGHT_THEME } from '../lib/themes/light_theme'; +import { mergeWithDefaultTheme } from '../lib/themes/theme'; +import { getGroupId, getSpecId } from '../lib/utils/ids'; +import { ScaleType } from '../lib/utils/scales/scales'; +import { ChartStore } from './chart_state'; + +describe('Render chart', () => { + describe('line, utc-time, day interval', () => { + let store: ChartStore; + const day1 = 1546300800000; // 2019-01-01T00:00:00.000Z + const day2 = day1 + 1000 * 60 * 60 * 24; + const day3 = day2 + 1000 * 60 * 60 * 24; + beforeEach(() => { + store = new ChartStore(); + + const lineSeries: LineSeriesSpec = { + id: getSpecId('lines'), + groupId: getGroupId('line'), + seriesType: 'line', + xScaleType: ScaleType.Time, + yScaleType: ScaleType.Linear, + xAccessor: 0, + yAccessors: [1], + data: [[day1, 10], [day2, 22], [day3, 6]], + yScaleToDataExtent: false, + }; + store.chartTheme = mergeWithDefaultTheme( + { + chartPaddings: { top: 0, left: 0, bottom: 0, right: 0 }, + chartMargins: { top: 0, left: 0, bottom: 0, right: 0 }, + }, + LIGHT_THEME, + ); + + store.addSeriesSpec(lineSeries); + store.updateParentDimensions(100, 100, 0, 0); + }); + test('check rendered geometries', () => { + expect(store.geometries).toBeTruthy(); + expect(store.geometries!.lines).toBeDefined(); + expect(store.geometries!.lines.length).toBe(1); + expect(store.geometries!.lines[0].points.length).toBe(3); + }); + test('check mouse position correctly return inverted value', () => { + store.setCursorPosition(15, 10); // check first valid tooltip + expect(store.tooltipData.length).toBe(2); // x value + y value + expect(store.tooltipData[0].value).toBe(day1); // x value + expect(store.tooltipData[1].value).toBe(10); // y value + store.setCursorPosition(35, 10); // check first valid tooltip + expect(store.tooltipData.length).toBe(2); // x value + y value + expect(store.tooltipData[0].value).toBe(day2); // x value + expect(store.tooltipData[1].value).toBe(22); // y value + store.setCursorPosition(76, 10); // check first valid tooltip + expect(store.tooltipData.length).toBe(2); // x value + y value + expect(store.tooltipData[0].value).toBe(day3); // x value + expect(store.tooltipData[1].value).toBe(6); // y value + }); + }); + describe('line, utc-time, 5m interval', () => { + let store: ChartStore; + const date1 = 1546300800000; // 2019-01-01T00:00:00.000Z + const date2 = date1 + 1000 * 60 * 5; + const date3 = date2 + 1000 * 60 * 5; + beforeEach(() => { + store = new ChartStore(); + + const lineSeries: LineSeriesSpec = { + id: getSpecId('lines'), + groupId: getGroupId('line'), + seriesType: 'line', + xScaleType: ScaleType.Time, + yScaleType: ScaleType.Linear, + xAccessor: 0, + yAccessors: [1], + data: [[date1, 10], [date2, 22], [date3, 6]], + yScaleToDataExtent: false, + }; + store.chartTheme = mergeWithDefaultTheme( + { + chartPaddings: { top: 0, left: 0, bottom: 0, right: 0 }, + chartMargins: { top: 0, left: 0, bottom: 0, right: 0 }, + }, + LIGHT_THEME, + ); + + store.addSeriesSpec(lineSeries); + store.updateParentDimensions(100, 100, 0, 0); + }); + test('check rendered geometries', () => { + expect(store.geometries).toBeTruthy(); + expect(store.geometries!.lines).toBeDefined(); + expect(store.geometries!.lines.length).toBe(1); + expect(store.geometries!.lines[0].points.length).toBe(3); + }); + test('check mouse position correctly return inverted value', () => { + store.setCursorPosition(15, 10); // check first valid tooltip + expect(store.tooltipData.length).toBe(2); // x value + y value + expect(store.tooltipData[0].value).toBe(date1); // x value + expect(store.tooltipData[1].value).toBe(10); // y value + store.setCursorPosition(35, 10); // check first valid tooltip + expect(store.tooltipData.length).toBe(2); // x value + y value + expect(store.tooltipData[0].value).toBe(date2); // x value + expect(store.tooltipData[1].value).toBe(22); // y value + store.setCursorPosition(76, 10); // check first valid tooltip + expect(store.tooltipData.length).toBe(2); // x value + y value + expect(store.tooltipData[0].value).toBe(date3); // x value + expect(store.tooltipData[1].value).toBe(6); // y value + }); + }); + describe('line, non utc-time, 5m + 1s interval', () => { + let store: ChartStore; + const date1 = DateTime.fromISO('2019-01-01T00:00:01.000-0300', { setZone: true }).toMillis(); + const date2 = date1 + 1000 * 60 * 5; + const date3 = date2 + 1000 * 60 * 5; + beforeEach(() => { + store = new ChartStore(); + + const lineSeries: LineSeriesSpec = { + id: getSpecId('lines'), + groupId: getGroupId('line'), + seriesType: 'line', + xScaleType: ScaleType.Time, + yScaleType: ScaleType.Linear, + xAccessor: 0, + yAccessors: [1], + data: [[date1, 10], [date2, 22], [date3, 6]], + yScaleToDataExtent: false, + }; + store.chartTheme = mergeWithDefaultTheme( + { + chartPaddings: { top: 0, left: 0, bottom: 0, right: 0 }, + chartMargins: { top: 0, left: 0, bottom: 0, right: 0 }, + }, + LIGHT_THEME, + ); + + store.addSeriesSpec(lineSeries); + store.updateParentDimensions(100, 100, 0, 0); + }); + test('check rendered geometries', () => { + expect(store.geometries).toBeTruthy(); + expect(store.geometries!.lines).toBeDefined(); + expect(store.geometries!.lines.length).toBe(1); + expect(store.geometries!.lines[0].points.length).toBe(3); + }); + test('check scale values', () => { + expect(store.xScale!.minInterval).toBe(1000 * 60 * 5); + expect(store.xScale!.domain).toEqual([date1, date3]); + expect(store.xScale!.range).toEqual([0, 100]); + expect(store.xScale!.invert(0)).toBe(date1); + expect(store.xScale!.invert(50)).toBe(date2); + expect(store.xScale!.invert(100)).toBe(date3); + expect(store.xScale!.invertWithStep(5)).toBe(date1); + expect(store.xScale!.invertWithStep(20)).toBe(date1); + expect(store.xScale!.invertWithStep(30)).toBe(date2); + expect(store.xScale!.invertWithStep(50)).toBe(date2); + expect(store.xScale!.invertWithStep(70)).toBe(date2); + expect(store.xScale!.invertWithStep(80)).toBe(date3); + expect(store.xScale!.invertWithStep(100)).toBe(date3); + }); + test('check mouse position correctly return inverted value', () => { + store.setCursorPosition(15, 10); // check first valid tooltip + expect(store.tooltipData.length).toBe(2); // x value + y value + expect(store.tooltipData[0].value).toBe(date1); // x value + expect(store.tooltipData[1].value).toBe(10); // y value + store.setCursorPosition(35, 10); // check first valid tooltip + expect(store.tooltipData.length).toBe(2); // x value + y value + expect(store.tooltipData[0].value).toBe(date2); // x value + expect(store.tooltipData[1].value).toBe(22); // y value + store.setCursorPosition(76, 10); // check first valid tooltip + expect(store.tooltipData.length).toBe(2); // x value + y value + expect(store.tooltipData[0].value).toBe(date3); // x value + expect(store.tooltipData[1].value).toBe(6); // y value + }); + }); +}); diff --git a/stories/scales.tsx b/stories/scales.tsx new file mode 100644 index 0000000000..0c1a9c7325 --- /dev/null +++ b/stories/scales.tsx @@ -0,0 +1,208 @@ +import { select } from '@storybook/addon-knobs'; +import { storiesOf } from '@storybook/react'; +import { DateTime } from 'luxon'; +import React from 'react'; +import { Axis, Chart, getAxisId, getSpecId, LineSeries, Position, ScaleType } from '../src'; + +const today = new Date().getTime(); +const UTC_DATE = DateTime.fromISO('2019-01-01T00:00:00.000Z').toMillis(); +const UTC_PLUS8_DATE = DateTime.fromISO('2019-01-01T00:00:00.000+08:00', { + setZone: true, +}).toMillis(); +const UTC_MINUS8_DATE = DateTime.fromISO('2019-01-01T00:00:00.000-08:00', { + setZone: true, +}).toMillis(); +const DAY_INCREMENT_1 = 1000 * 60 * 60 * 24; +const UTC_DATASET = new Array(10).fill(0).map((d, i) => { + return [UTC_DATE + DAY_INCREMENT_1 * i, i % 5]; +}); +const CURRENT_TIMEZONE_DATASET = new Array(10).fill(0).map((d, i) => { + return [today + DAY_INCREMENT_1 * i, i % 5]; +}); +const OTHER_PLUS8_TIMEZONE_DATASET = new Array(10).fill(0).map((d, i) => { + return [UTC_PLUS8_DATE + DAY_INCREMENT_1 * i, i % 5]; +}); +const OTHER_MINUS8_TIMEZONE_DATASET = new Array(10).fill(0).map((d, i) => { + return [UTC_MINUS8_DATE + DAY_INCREMENT_1 * i, i % 5]; +}); + +storiesOf('Scales', module) + .add('line chart with different timezones', () => { + const timezones = { + utc: 'utc', + local: 'local', + utcplus8: 'utc+8', + utcminus8: 'utc-8', + }; + const datasetSelected = select('dataset', timezones, 'utc'); + const tooltipSelected = select('tooltip', timezones, 'utc'); + + let data; + switch (datasetSelected) { + case 'utc': + data = UTC_DATASET; + break; + case 'local': + data = CURRENT_TIMEZONE_DATASET; + break; + case 'utc+8': + data = OTHER_PLUS8_TIMEZONE_DATASET; + break; + case 'utc-8': + data = OTHER_MINUS8_TIMEZONE_DATASET; + break; + } + let tooltipFn: (d: number) => string; + switch (tooltipSelected) { + case 'local': + tooltipFn = (d: number) => { + return DateTime.fromMillis(d).toFormat('yyyy-MM-dd HH:mm:ss'); + }; + break; + case 'utc+8': + tooltipFn = (d: number) => { + return DateTime.fromMillis(d, { zone: 'utc+8' }).toFormat('yyyy-MM-dd HH:mm:ss'); + }; + break; + case 'utc-8': + tooltipFn = (d: number) => { + return DateTime.fromMillis(d, { zone: 'utc-8' }).toFormat('yyyy-MM-dd HH:mm:ss'); + }; + break; + default: + case 'utc': + tooltipFn = (d: number) => { + return DateTime.fromMillis(d) + .toUTC() + .toFormat('yyyy-MM-dd HH:mm:ss'); + }; + break; + } + return ( + + + + + + ); + }) + .add( + 'x scale: UTC Time zone - local tooltip', + () => { + return ( + + { + return DateTime.fromMillis(d).toFormat('yyyy-MM-dd HH:mm:ss'); + }} + /> + + + + ); + }, + { + info: { + text: `If your data is in UTC timezone, your tooltip and axis labels can + be configured to visualize the time translated to your local timezone. You should + be able to see the first value on \`2019-01-01 01:00:00.000 \``, + }, + }, + ) + .add( + 'x scale: UTC Time zone - UTC tooltip', + () => { + return ( + + { + return DateTime.fromMillis(d) + .toUTC() + .toFormat('yyyy-MM-dd HH:mm:ss'); + }} + /> + + + + ); + }, + { + info: { + text: `The default timezone is UTC. If you want to visualize data in UTC, + but you are in a different timezone, remember to format the millis from \`tickFormat\` + to UTC. In this example be able to see the first value on \`2019-01-01 00:00:00.000 \``, + }, + }, + ) + .add( + 'x scale year scale: custom timezone - same zone tooltip', + () => { + return ( + + { + return DateTime.fromMillis(d, { zone: 'utc-6' }).toISO(); + // return DateTime.fromMillis(d, { zone: 'utc-6' }).toISO(); + }} + /> + + + + ); + }, + { + info: { + text: `You can visualize data in a different timezone than your local or UTC zones. + Specify the \`timeZone={'utc-6'}\` property with the correct timezone and + remember to apply the same timezone also to each formatted tick in \`tickFormat\` `, + }, + }, + );