Skip to content

Commit bd34ec2

Browse files
committed
use UTC milliseconds for internal representation of dates
We still interpret JS date objects according to the date and hour they have in the local timezone, and for backward compatibility we shift milliseconds (in ranges etc) by the local/UTC offset.
1 parent 899866b commit bd34ec2

File tree

5 files changed

+107
-49
lines changed

5 files changed

+107
-49
lines changed

src/lib/dates.js

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ var MIN_MS, MAX_MS;
8787
exports.dateTime2ms = function(s) {
8888
// first check if s is a date object
8989
if(exports.isJSDate(s)) {
90-
s = Number(s);
90+
// Convert to the UTC milliseconds that give the same
91+
// hours as this date has in the local timezone
92+
s = Number(s) - s.getTimezoneOffset() * ONEMIN;
9193
if(s >= MIN_MS && s <= MAX_MS) return s;
9294
return BADNUM;
9395
}
@@ -109,14 +111,10 @@ exports.dateTime2ms = function(s) {
109111

110112
// javascript takes new Date(0..99,m,d) to mean 1900-1999, so
111113
// to support years 0-99 we need to use setFullYear explicitly
112-
var date = new Date(2000, m - 1, d, H, M);
113-
date.setFullYear(y);
114+
var date = new Date(Date.UTC(2000, m - 1, d, H, M));
115+
date.setUTCFullYear(y);
114116

115-
if(date.getDate() !== d) return BADNUM;
116-
117-
// does that hour exist in this day? (Daylight time!)
118-
// (TODO: remove this check when we move to UTC)
119-
if(date.getHours() !== H) return BADNUM;
117+
if(date.getUTCDate() !== d) return BADNUM;
120118

121119
return date.getTime() + S * ONESEC;
122120
};
@@ -150,16 +148,41 @@ exports.ms2DateTime = function(ms, r) {
150148

151149
if(!r) r = 0;
152150

153-
var d = new Date(Math.floor(ms)),
154-
dateStr = d3.time.format('%Y-%m-%d')(d),
151+
var msecTenths = Math.round(((ms % 1) + 1) * 10) % 10,
152+
d = new Date(Math.round(ms - msecTenths / 10)),
153+
dateStr = d3.time.format.utc('%Y-%m-%d')(d),
155154
// <90 days: add hours and minutes - never *only* add hours
156-
h = (r < NINETYDAYS) ? d.getHours() : 0,
157-
m = (r < NINETYDAYS) ? d.getMinutes() : 0,
155+
h = (r < NINETYDAYS) ? d.getUTCHours() : 0,
156+
m = (r < NINETYDAYS) ? d.getUTCMinutes() : 0,
158157
// <3 hours: add seconds
159-
s = (r < THREEHOURS) ? d.getSeconds() : 0,
158+
s = (r < THREEHOURS) ? d.getUTCSeconds() : 0,
160159
// <5 minutes: add ms (plus one extra digit, this is msec*10)
161-
msec10 = (r < FIVEMIN) ? Math.round((d.getMilliseconds() + (((ms % 1) + 1) % 1)) * 10) : 0;
160+
msec10 = (r < FIVEMIN) ? d.getUTCMilliseconds() * 10 + msecTenths : 0;
161+
162+
return includeTime(dateStr, h, m, s, msec10);
163+
};
164+
165+
// For converting old-style milliseconds to date strings,
166+
// we use the local timezone rather than UTC like we use
167+
// everywhere else, both for backward compatibility and
168+
// because that's how people mostly use javasript date objects.
169+
// Clip one extra day off our date range though so we can't get
170+
// thrown beyond the range by the timezone shift.
171+
exports.ms2DateTimeLocal = function(ms) {
172+
if(!(ms >= MIN_MS + ONEDAY && ms <= MAX_MS - ONEDAY)) return BADNUM;
173+
174+
var msecTenths = Math.round(((ms % 1) + 1) * 10) % 10,
175+
d = new Date(Math.round(ms - msecTenths / 10)),
176+
dateStr = d3.time.format('%Y-%m-%d')(d),
177+
h = d.getHours(),
178+
m = d.getMinutes(),
179+
s = d.getSeconds(),
180+
msec10 = d.getUTCMilliseconds() * 10 + msecTenths;
181+
182+
return includeTime(dateStr, h, m, s, msec10);
183+
};
162184

185+
function includeTime(dateStr, h, m, s, msec10) {
163186
// include each part that has nonzero data in or after it
164187
if(h || m || s || msec10) {
165188
dateStr += ' ' + lpad(h, 2) + ':' + lpad(m, 2);
@@ -176,7 +199,7 @@ exports.ms2DateTime = function(ms, r) {
176199
}
177200
}
178201
return dateStr;
179-
};
202+
}
180203

181204
// normalize date format to date string, in case it starts as
182205
// a Date object or milliseconds
@@ -186,7 +209,7 @@ exports.cleanDate = function(v, dflt) {
186209
// NOTE: if someone puts in a year as a number rather than a string,
187210
// this will mistakenly convert it thinking it's milliseconds from 1970
188211
// that is: '2012' -> Jan. 1, 2012, but 2012 -> 2012 epoch milliseconds
189-
v = exports.ms2DateTime(+v);
212+
v = exports.ms2DateTimeLocal(+v);
190213
if(!v && dflt !== undefined) return dflt;
191214
}
192215
else if(!exports.isDateTime(v)) {

src/plots/cartesian/axes.js

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,7 @@ axes.tickIncrement = function(x, dtick, axrev) {
917917
var y = new Date(x);
918918
// is this browser consistent? setMonth edits a date but
919919
// returns that date's milliseconds
920-
return y.setMonth(y.getMonth() + dtSigned);
920+
return y.setMonth(y.getUTCMonth() + dtSigned);
921921
}
922922

923923
// Log scales: Linear, Digits
@@ -968,9 +968,9 @@ axes.tickFirst = function(ax) {
968968
if(tType === 'M') {
969969
t0 = new Date(tick0);
970970
r0 = new Date(r0);
971-
mdif = (r0.getFullYear() - t0.getFullYear()) * 12 +
972-
r0.getMonth() - t0.getMonth();
973-
t1 = t0.setMonth(t0.getMonth() +
971+
mdif = (r0.getUTCFullYear() - t0.getUTCFullYear()) * 12 +
972+
r0.getUTCMonth() - t0.getUTCMonth();
973+
t1 = t0.setMonth(t0.getUTCMonth() +
974974
(Math.round(mdif / dtNum) + (axrev ? 1 : -1)) * dtNum);
975975

976976
while(axrev ? t1 > r0 : t1 < r0) {
@@ -994,12 +994,13 @@ axes.tickFirst = function(ax) {
994994
else throw 'unrecognized dtick ' + String(dtick);
995995
};
996996

997-
var yearFormat = d3.time.format('%Y'),
998-
monthFormat = d3.time.format('%b %Y'),
999-
dayFormat = d3.time.format('%b %-d'),
1000-
yearMonthDayFormat = d3.time.format('%b %-d, %Y'),
1001-
minuteFormat = d3.time.format('%H:%M'),
1002-
secondFormat = d3.time.format(':%S');
997+
var utcFormat = d3.time.format.utc,
998+
yearFormat = utcFormat('%Y'),
999+
monthFormat = utcFormat('%b %Y'),
1000+
dayFormat = utcFormat('%b %-d'),
1001+
yearMonthDayFormat = utcFormat('%b %-d, %Y'),
1002+
minuteFormat = utcFormat('%H:%M'),
1003+
secondFormat = utcFormat(':%S');
10031004

10041005
// add one item to d3's vocabulary:
10051006
// %{n}f where n is the max number of digits
@@ -1012,10 +1013,10 @@ function modDateFormat(fmt, x) {
10121013
var digits = Math.min(+fm[1] || 6, 6),
10131014
fracSecs = String((x / 1000 % 1) + 2.0000005)
10141015
.substr(2, digits).replace(/0+$/, '') || '0';
1015-
return d3.time.format(fmt.replace(fracMatch, fracSecs))(d);
1016+
return utcFormat(fmt.replace(fracMatch, fracSecs))(d);
10161017
}
10171018
else {
1018-
return d3.time.format(fmt)(d);
1019+
return utcFormat(fmt)(d);
10191020
}
10201021
}
10211022

src/plots/cartesian/dragbox.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,16 +298,18 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
298298
function zoomAxRanges(axList, r0Fraction, r1Fraction) {
299299
var i,
300300
axi,
301-
axRangeLinear;
301+
axRangeLinear0,
302+
axRangeLinearSpan;
302303

303304
for(i = 0; i < axList.length; i++) {
304305
axi = axList[i];
305306
if(axi.fixedrange) continue;
306307

307-
axRangeLinear = axi.range.map(axi.r2l);
308+
axRangeLinear0 = axi._rl[0];
309+
axRangeLinearSpan = axi._rl[1] - axRangeLinear0;
308310
axi.range = [
309-
axi.l2r(axRangeLinear[0] + (axRangeLinear[1] - axRangeLinear[0]) * r0Fraction),
310-
axi.l2r(axRangeLinear[0] + (axRangeLinear[1] - axRangeLinear[0]) * r1Fraction)
311+
axi.l2r(axRangeLinear0 + axRangeLinearSpan * r0Fraction),
312+
axi.l2r(axRangeLinear0 + axRangeLinearSpan * r1Fraction)
311313
];
312314
}
313315
}

src/plots/plots.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1390,7 +1390,7 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) {
13901390

13911391
// convert native dates to date strings...
13921392
// mostly for external users exporting to plotly
1393-
if(Lib.isJSDate(d)) return Lib.ms2DateTime(+d);
1393+
if(Lib.isJSDate(d)) return Lib.ms2DateTimeLocal(+d);
13941394

13951395
return d;
13961396
}

test/jasmine/tests/lib_date_test.js

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('dates', function() {
1818

1919
describe('dateTime2ms', function() {
2020
it('should accept valid date strings', function() {
21+
var tzOffset;
2122

2223
[
2324
['2016', new Date(2016, 0, 1)],
@@ -34,10 +35,11 @@ describe('dates', function() {
3435
// first century, also allow month, day, and hour to be 1-digit, and not all
3536
// three digits of milliseconds
3637
['0013-1-1 1:00:00.6', d1c],
37-
// we support more than 4 digits too, though Date objects don't. More than that
38+
// we support tenths of msec too, though Date objects don't. Smaller than that
3839
// and we hit the precision limit of js numbers unless we're close to the epoch.
3940
// It won't break though.
4041
['0013-1-1 1:00:00.6001', +d1c + 0.1],
42+
['0013-1-1 1:00:00.60011111111', +d1c + 0.11111111],
4143

4244
// 2-digit years get mapped to now-70 -> now+29
4345
[thisYear_2 + '-05', new Date(thisYear, 4, 1)],
@@ -50,11 +52,16 @@ describe('dates', function() {
5052
['2014-03-04 08:15:34+1200', new Date(2014, 2, 4, 8, 15, 34)],
5153
['2014-03-04 08:15:34.567-05:45', new Date(2014, 2, 4, 8, 15, 34, 567)],
5254
].forEach(function(v) {
53-
expect(Lib.dateTime2ms(v[0])).toBe(+v[1], v[0]);
55+
// just for sub-millisecond precision tests, use timezoneoffset
56+
// from the previous date object
57+
if(v[1].getTimezoneOffset) tzOffset = v[1].getTimezoneOffset();
58+
59+
var expected = +v[1] - (tzOffset * 60000);
60+
expect(Lib.dateTime2ms(v[0])).toBe(expected, v[0]);
5461

5562
// ISO-8601: all the same stuff with t or T as the separator
56-
expect(Lib.dateTime2ms(v[0].trim().replace(' ', 't'))).toBe(+v[1], v[0].trim().replace(' ', 't'));
57-
expect(Lib.dateTime2ms('\r\n\t ' + v[0].trim().replace(' ', 'T') + '\r\n\t ')).toBe(+v[1], v[0].trim().replace(' ', 'T'));
63+
expect(Lib.dateTime2ms(v[0].trim().replace(' ', 't'))).toBe(expected, v[0].trim().replace(' ', 't'));
64+
expect(Lib.dateTime2ms('\r\n\t ' + v[0].trim().replace(' ', 'T') + '\r\n\t ')).toBe(expected, v[0].trim().replace(' ', 'T'));
5865
});
5966
});
6067

@@ -69,7 +76,7 @@ describe('dates', function() {
6976
[
7077
1000, 9999, -1000, -9999
7178
].forEach(function(v) {
72-
expect(Lib.dateTime2ms(v)).toBe(+(new Date(v, 0, 1)), v);
79+
expect(Lib.dateTime2ms(v)).toBe(Date.UTC(v, 0, 1), v);
7380
});
7481

7582
[
@@ -78,7 +85,7 @@ describe('dates', function() {
7885
[nowMinus70_2, nowMinus70],
7986
[99, 1999]
8087
].forEach(function(v) {
81-
expect(Lib.dateTime2ms(v[0])).toBe(+(new Date(v[1], 0, 1)), v[0]);
88+
expect(Lib.dateTime2ms(v[0])).toBe(Date.UTC(v[1], 0, 1), v[0]);
8289
});
8390
});
8491

@@ -93,7 +100,7 @@ describe('dates', function() {
93100
d1c,
94101
new Date(2015, 8, 7, 23, 34, 45, 567)
95102
].forEach(function(v) {
96-
expect(Lib.dateTime2ms(v)).toBe(+v);
103+
expect(Lib.dateTime2ms(v)).toBe(+v - v.getTimezoneOffset() * 60000);
97104
});
98105
});
99106

@@ -124,6 +131,30 @@ describe('dates', function() {
124131
expect(Lib.dateTime2ms(v)).toBeUndefined(v);
125132
});
126133
});
134+
135+
var JULY1MS = 181 * 24 * 3600 * 1000;
136+
137+
it('should use UTC with no timezone offset or daylight saving time', function() {
138+
expect(Lib.dateTime2ms('1970-01-01')).toBe(0);
139+
140+
// 181 days (and no DST hours) between jan 1 and july 1 in a non-leap-year
141+
// 31 + 28 + 31 + 30 + 31 + 30
142+
expect(Lib.dateTime2ms('1970-07-01')).toBe(JULY1MS);
143+
});
144+
145+
it('should interpret JS dates by local time, not by its getTime()', function() {
146+
// not really part of the test, just to make sure the test is meaningful
147+
// the test should NOT be run in a UTC environment
148+
expect([
149+
Number(new Date(1970, 0, 1)),
150+
Number(new Date(1970, 6, 1))
151+
]).not.toEqual([0, JULY1MS]);
152+
153+
// now repeat the previous test and show that we throw away
154+
// timezone info from js dates
155+
expect(Lib.dateTime2ms(new Date(1970, 0, 1))).toBe(0);
156+
expect(Lib.dateTime2ms(new Date(1970, 6, 1))).toBe(JULY1MS);
157+
});
127158
});
128159

129160
describe('ms2DateTime', function() {
@@ -159,8 +190,8 @@ describe('dates', function() {
159190

160191
it('should not accept Date objects beyond our limits or other objects', function() {
161192
[
162-
+(new Date(10000, 0, 1)),
163-
+(new Date(-10000, 11, 31, 23, 59, 59, 999)),
193+
Date.UTC(10000, 0, 1),
194+
Date.UTC(-10000, 11, 31, 23, 59, 59, 999),
164195
'',
165196
'2016-01-01',
166197
'0',
@@ -191,19 +222,20 @@ describe('dates', function() {
191222
});
192223

193224
describe('cleanDate', function() {
194-
it('should convert any number or js Date within range to a date string', function() {
225+
it('should convert numbers or js Dates to strings based on local TZ', function() {
195226
[
196227
new Date(0),
197228
new Date(2000),
198229
new Date(2000, 0, 1),
199230
new Date(),
200-
new Date(-9999, 0, 1),
201-
new Date(9999, 11, 31, 23, 59, 59, 999)
231+
new Date(-9999, 0, 3), // we lose one day of range +/- tzoffset this way
232+
new Date(9999, 11, 29, 23, 59, 59, 999)
202233
].forEach(function(v) {
203-
expect(typeof Lib.ms2DateTime(+v)).toBe('string');
204-
expect(Lib.cleanDate(v)).toBe(Lib.ms2DateTime(+v));
205-
expect(Lib.cleanDate(+v)).toBe(Lib.ms2DateTime(+v));
206-
expect(Lib.cleanDate(v, '2000-01-01')).toBe(Lib.ms2DateTime(+v));
234+
var expected = Lib.ms2DateTime(Lib.dateTime2ms(v));
235+
expect(typeof expected).toBe('string');
236+
expect(Lib.cleanDate(v)).toBe(expected);
237+
expect(Lib.cleanDate(+v)).toBe(expected);
238+
expect(Lib.cleanDate(v, '2000-01-01')).toBe(expected);
207239
});
208240
});
209241

0 commit comments

Comments
 (0)