Skip to content

Commit d860c97

Browse files
authored
fix(timescale): consider timezone on axis ticks (#151)
The d3-scale function, used to compute axis ticks, compute a discrete and nice rounded number of ticks on a time scale. By the way, this rounding is applied using UTC or local timezone, meaning that we cannot display a nicely rounded tick if we want to display data in a timezone different from the local or utc one. This commit includes a new optional prop to each series `timeZone` that can be used to configure this behaviour (default to utc). fix #130
1 parent f25ef46 commit d860c97

File tree

13 files changed

+802
-69
lines changed

13 files changed

+802
-69
lines changed

.storybook/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function loadStories() {
4343
require('../stories/styling.tsx');
4444
require('../stories/grid.tsx');
4545
require('../stories/annotations.tsx');
46+
require('../stories/scales.tsx');
4647
}
4748

4849
configure(loadStories, module);

src/lib/axes/axis_utils.ts

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -144,36 +144,39 @@ export const getMaxBboxDimensions = (
144144
fontSize: number,
145145
fontFamily: string,
146146
tickLabelRotation: number,
147-
) => (acc: { [key: string]: number }, tickLabel: string): {
148-
maxLabelBboxWidth: number,
149-
maxLabelBboxHeight: number,
150-
maxLabelTextWidth: number,
151-
maxLabelTextHeight: number,
147+
) => (
148+
acc: { [key: string]: number },
149+
tickLabel: string,
150+
): {
151+
maxLabelBboxWidth: number;
152+
maxLabelBboxHeight: number;
153+
maxLabelTextWidth: number;
154+
maxLabelTextHeight: number;
152155
} => {
153-
const bbox = bboxCalculator.compute(tickLabel, fontSize, fontFamily).getOrElse({
154-
width: 0,
155-
height: 0,
156-
});
156+
const bbox = bboxCalculator.compute(tickLabel, fontSize, fontFamily).getOrElse({
157+
width: 0,
158+
height: 0,
159+
});
157160

158-
const rotatedBbox = computeRotatedLabelDimensions(bbox, tickLabelRotation);
161+
const rotatedBbox = computeRotatedLabelDimensions(bbox, tickLabelRotation);
159162

160-
const width = Math.ceil(rotatedBbox.width);
161-
const height = Math.ceil(rotatedBbox.height);
162-
const labelWidth = Math.ceil(bbox.width);
163-
const labelHeight = Math.ceil(bbox.height);
163+
const width = Math.ceil(rotatedBbox.width);
164+
const height = Math.ceil(rotatedBbox.height);
165+
const labelWidth = Math.ceil(bbox.width);
166+
const labelHeight = Math.ceil(bbox.height);
164167

165-
const prevWidth = acc.maxLabelBboxWidth;
166-
const prevHeight = acc.maxLabelBboxHeight;
167-
const prevLabelWidth = acc.maxLabelTextWidth;
168-
const prevLabelHeight = acc.maxLabelTextHeight;
168+
const prevWidth = acc.maxLabelBboxWidth;
169+
const prevHeight = acc.maxLabelBboxHeight;
170+
const prevLabelWidth = acc.maxLabelTextWidth;
171+
const prevLabelHeight = acc.maxLabelTextHeight;
169172

170-
return {
171-
maxLabelBboxWidth: prevWidth > width ? prevWidth : width,
172-
maxLabelBboxHeight: prevHeight > height ? prevHeight : height,
173-
maxLabelTextWidth: prevLabelWidth > labelWidth ? prevLabelWidth : labelWidth,
174-
maxLabelTextHeight: prevLabelHeight > labelHeight ? prevLabelHeight : labelHeight,
175-
};
173+
return {
174+
maxLabelBboxWidth: prevWidth > width ? prevWidth : width,
175+
maxLabelBboxHeight: prevHeight > height ? prevHeight : height,
176+
maxLabelTextWidth: prevLabelWidth > labelWidth ? prevLabelWidth : labelWidth,
177+
maxLabelTextHeight: prevLabelHeight > labelHeight ? prevLabelHeight : labelHeight,
176178
};
179+
};
177180

178181
function computeTickDimensions(
179182
scale: Scale,
@@ -562,11 +565,7 @@ export function getAxisTicksPositions(
562565
}
563566

564567
const allTicks = getAvailableTicks(axisSpec, scale, totalGroupsCount);
565-
const visibleTicks = getVisibleTicks(
566-
allTicks,
567-
axisSpec,
568-
axisDim,
569-
);
568+
const visibleTicks = getVisibleTicks(allTicks, axisSpec, axisDim);
570569

571570
if (axisSpec.showGridLines) {
572571
const isVerticalAxis = isVertical(axisSpec.position);
@@ -637,7 +636,9 @@ export function isUpperBound(domain: Partial<CompleteBoundedDomain>): domain is
637636
return domain.max != null;
638637
}
639638

640-
export function isCompleteBound(domain: Partial<CompleteBoundedDomain>): domain is CompleteBoundedDomain {
639+
export function isCompleteBound(
640+
domain: Partial<CompleteBoundedDomain>,
641+
): domain is CompleteBoundedDomain {
641642
return domain.max != null && domain.min != null;
642643
}
643644

@@ -675,8 +676,8 @@ export function mergeDomainsByGroupId(
675676
if (prevGroupDomain) {
676677
const prevDomain = prevGroupDomain as DomainRange;
677678

678-
const prevMin = (isLowerBound(prevDomain)) ? prevDomain.min : undefined;
679-
const prevMax = (isUpperBound(prevDomain)) ? prevDomain.max : undefined;
679+
const prevMin = isLowerBound(prevDomain) ? prevDomain.min : undefined;
680+
const prevMax = isUpperBound(prevDomain) ? prevDomain.max : undefined;
680681

681682
let max = prevMax;
682683
let min = prevMin;

src/lib/series/domains/domain.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ import { ScaleType } from '../../utils/scales/scales';
44
export interface BaseDomain {
55
scaleType: ScaleType;
66
domain: Domain;
7+
/* if the scale needs to be a band scale: used when displaying bars */
78
isBandScale: boolean;
89
}

src/lib/series/domains/x_domain.test.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,61 @@ describe('X Domain', () => {
5959
});
6060
});
6161
test('Should return correct scale type with single line (time)', () => {
62-
const seriesSpecs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType'>> = [
62+
const seriesSpecs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType' | 'timeZone'>> = [
63+
{
64+
seriesType: 'line',
65+
xScaleType: ScaleType.Time,
66+
timeZone: 'utc-3',
67+
},
68+
];
69+
const mainXScale = convertXScaleTypes(seriesSpecs);
70+
expect(mainXScale).toEqual({
71+
scaleType: ScaleType.Time,
72+
isBandScale: false,
73+
timeZone: 'utc-3',
74+
});
75+
});
76+
test('Should return correct scale type with multi line with same scale types (time) same tz', () => {
77+
const seriesSpecs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType' | 'timeZone'>> = [
78+
{
79+
seriesType: 'line',
80+
xScaleType: ScaleType.Time,
81+
timeZone: 'UTC-3',
82+
},
83+
{
84+
seriesType: 'line',
85+
xScaleType: ScaleType.Time,
86+
timeZone: 'utc-3',
87+
},
88+
];
89+
const mainXScale = convertXScaleTypes(seriesSpecs);
90+
expect(mainXScale).toEqual({
91+
scaleType: ScaleType.Time,
92+
isBandScale: false,
93+
timeZone: 'utc-3',
94+
});
95+
});
96+
test('Should return correct scale type with multi line with same scale types (time) coerce to UTC', () => {
97+
const seriesSpecs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType' | 'timeZone'>> = [
6398
{
6499
seriesType: 'line',
65100
xScaleType: ScaleType.Time,
101+
timeZone: 'utc-3',
102+
},
103+
{
104+
seriesType: 'line',
105+
xScaleType: ScaleType.Time,
106+
timeZone: 'utc+3',
66107
},
67108
];
68109
const mainXScale = convertXScaleTypes(seriesSpecs);
69110
expect(mainXScale).toEqual({
70111
scaleType: ScaleType.Time,
71112
isBandScale: false,
113+
timeZone: 'utc',
72114
});
73115
});
116+
74117
test('Should return correct scale type with multi line with different scale types (linear, ordinal)', () => {
75118
const seriesSpecs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType'>> = [
76119
{
@@ -106,14 +149,15 @@ describe('X Domain', () => {
106149
});
107150
});
108151
test('Should return correct scale type with multi bar, area with same scale types (linear, linear)', () => {
109-
const seriesSpecs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType'>> = [
152+
const seriesSpecs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType' | 'timeZone'>> = [
110153
{
111154
seriesType: 'bar',
112155
xScaleType: ScaleType.Linear,
113156
},
114157
{
115158
seriesType: 'area',
116159
xScaleType: ScaleType.Time,
160+
timeZone: 'utc+3',
117161
},
118162
];
119163
const mainXScale = convertXScaleTypes(seriesSpecs);
@@ -272,11 +316,11 @@ describe('X Domain', () => {
272316
[
273317
{
274318
seriesType: 'bar',
275-
xScaleType: ScaleType.Time,
319+
xScaleType: ScaleType.Linear,
276320
},
277321
{
278322
seriesType: 'bar',
279-
xScaleType: ScaleType.Time,
323+
xScaleType: ScaleType.Linear,
280324
},
281325
],
282326
xValues,
@@ -374,7 +418,7 @@ describe('X Domain', () => {
374418
seriesType: 'bar',
375419
xAccessor: 'x',
376420
yAccessors: ['y'],
377-
xScaleType: ScaleType.Linear,
421+
xScaleType: ScaleType.Ordinal,
378422
yScaleType: ScaleType.Linear,
379423
yScaleToDataExtent: false,
380424
data: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }, { x: 5, y: 0 }],
@@ -457,7 +501,7 @@ describe('X Domain', () => {
457501
const ds1: BasicSeriesSpec = {
458502
id: getSpecId('ds1'),
459503
groupId: getGroupId('g1'),
460-
seriesType: 'line',
504+
seriesType: 'area',
461505
xAccessor: 'x',
462506
yAccessors: ['y'],
463507
xScaleType: ScaleType.Linear,
@@ -471,7 +515,7 @@ describe('X Domain', () => {
471515
seriesType: 'line',
472516
xAccessor: 'x',
473517
yAccessors: ['y'],
474-
xScaleType: ScaleType.Linear,
518+
xScaleType: ScaleType.Ordinal,
475519
yScaleType: ScaleType.Linear,
476520
yScaleToDataExtent: false,
477521
data: new Array(maxValues).fill(0).map((d, i) => ({ x: i, y: i })),

src/lib/series/domains/x_domain.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import { BaseDomain } from './domain';
77

88
export type XDomain = BaseDomain & {
99
type: 'xDomain';
10-
/* if the scale needs to be a band scale: used when displaying bars */
11-
isBandScale: boolean;
1210
/* the minimum interval of the scale if not-ordinal band-scale*/
1311
minInterval: number;
12+
/** if x domain is time, we should also specify the timezone */
13+
timeZone?: string;
1414
};
1515

1616
/**
@@ -25,7 +25,6 @@ export function mergeXDomain(
2525
if (!mainXScaleType) {
2626
throw new Error('Cannot merge the domain. Missing X scale types');
2727
}
28-
// TODO: compute this domain merging also/overwritted by any configured static domains
2928

3029
const values = [...xValues.values()];
3130
let seriesXComputedDomains;
@@ -81,6 +80,7 @@ export function mergeXDomain(
8180
isBandScale: mainXScaleType.isBandScale,
8281
domain: seriesXComputedDomains,
8382
minInterval,
83+
timeZone: mainXScaleType.timeZone,
8484
};
8585
}
8686

@@ -119,20 +119,37 @@ export function findMinInterval(xValues: number[]): number {
119119
* @returns {ChartScaleType}
120120
*/
121121
export function convertXScaleTypes(
122-
specs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType'>>,
123-
): Pick<XDomain, 'scaleType' | 'isBandScale'> | null {
122+
specs: Array<Pick<BasicSeriesSpec, 'seriesType' | 'xScaleType' | 'timeZone'>>,
123+
): {
124+
scaleType: ScaleType;
125+
isBandScale: boolean;
126+
timeZone?: string;
127+
} | null {
124128
const seriesTypes = new Set<string>();
125129
const scaleTypes = new Set<ScaleType>();
130+
const timeZones = new Set<string>();
126131
specs.forEach((spec) => {
127132
seriesTypes.add(spec.seriesType);
128133
scaleTypes.add(spec.xScaleType);
134+
if (spec.timeZone) {
135+
timeZones.add(spec.timeZone.toLowerCase());
136+
}
129137
});
130138
if (specs.length === 0 || seriesTypes.size === 0 || scaleTypes.size === 0) {
131139
return null;
132140
}
133141
const isBandScale = seriesTypes.has('bar');
134142
if (scaleTypes.size === 1) {
135-
return { scaleType: [...scaleTypes.values()][0], isBandScale };
143+
const scaleType = scaleTypes.values().next().value;
144+
let timeZone: string | undefined;
145+
if (scaleType === ScaleType.Time) {
146+
if (timeZones.size > 1) {
147+
timeZone = 'utc';
148+
} else {
149+
timeZone = timeZones.values().next().value;
150+
}
151+
}
152+
return { scaleType, isBandScale, timeZone };
136153
}
137154

138155
if (scaleTypes.size > 1 && scaleTypes.has(ScaleType.Ordinal)) {

src/lib/series/scales.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export function computeXScale(
5353
minRange: number,
5454
maxRange: number,
5555
): Scale {
56-
const { scaleType, minInterval, domain, isBandScale } = xDomain;
56+
const { scaleType, minInterval, domain, isBandScale, timeZone } = xDomain;
5757
const rangeDiff = Math.abs(maxRange - minRange);
5858
const isInverse = maxRange < minRange;
5959
if (scaleType === ScaleType.Ordinal) {
@@ -74,6 +74,7 @@ export function computeXScale(
7474
bandwidth / totalBarsInCluster,
7575
false,
7676
minInterval,
77+
timeZone,
7778
);
7879
} else {
7980
return createContinuousScale(
@@ -84,6 +85,7 @@ export function computeXScale(
8485
0,
8586
undefined,
8687
minInterval,
88+
timeZone,
8789
);
8890
}
8991
}

src/lib/series/specs.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ export interface SeriesScales {
6666
* @default ScaleType.Ordinal
6767
*/
6868
xScaleType: ScaleType.Ordinal | ScaleType.Linear | ScaleType.Time;
69+
/**
70+
* If using a ScaleType.Time this timezone identifier is required to
71+
* compute a nice set of xScale ticks. Can be any IANA zone supported by
72+
* the host environment, or a fixed-offset name of the form 'utc+3',
73+
* or the strings 'local' or 'utc'.
74+
*/
75+
timeZone?: string;
6976
/**
7077
* The y axis scale type
7178
* @default ScaleType.Linear

src/lib/utils/scales/scale_continuous.test.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,7 @@ import { ScaleBand } from './scale_band';
33
import { isLogarithmicScale, ScaleContinuous } from './scale_continuous';
44
import { ScaleType } from './scales';
55

6-
describe.only('Scale Continuous', () => {
7-
/**
8-
* These tests cover the following cases:
9-
* line/area with simple linear scale
10-
* line/area chart with time scale (ac: w axis)
11-
* barscale with linear scale (bc: with linear x axis)
12-
* barscale with time scale (bc: with time x axis)
13-
* bar + line with linear scale (mc: bar and lines)
14-
* bar + line with time scale (missing story)
15-
* bar clustered with time scale (bc: time clustered using various specs)
16-
* bar clustered with linear scale (bc: clustered multiple series specs)
17-
*/
6+
describe('Scale Continuous', () => {
187
test('shall invert on continuous scale linear', () => {
198
const domain = [0, 2];
209
const minRange = 0;

0 commit comments

Comments
 (0)