Skip to content

Dates: Accept ISO-8601 format, and use UTC milliseconds as the backend #1194

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Nov 25, 2016
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 39 additions & 16 deletions src/lib/dates.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ var MIN_MS, MAX_MS;
exports.dateTime2ms = function(s) {
// first check if s is a date object
if(exports.isJSDate(s)) {
s = Number(s);
// Convert to the UTC milliseconds that give the same
// hours as this date has in the local timezone
s = Number(s) - s.getTimezoneOffset() * ONEMIN;
if(s >= MIN_MS && s <= MAX_MS) return s;
return BADNUM;
}
Expand All @@ -109,14 +111,10 @@ exports.dateTime2ms = function(s) {

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

if(date.getDate() !== d) return BADNUM;

// does that hour exist in this day? (Daylight time!)
// (TODO: remove this check when we move to UTC)
if(date.getHours() !== H) return BADNUM;
if(date.getUTCDate() !== d) return BADNUM;

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

if(!r) r = 0;

var d = new Date(Math.floor(ms)),
dateStr = d3.time.format('%Y-%m-%d')(d),
var msecTenths = Math.round(((ms % 1) + 1) * 10) % 10,
d = new Date(Math.round(ms - msecTenths / 10)),
dateStr = d3.time.format.utc('%Y-%m-%d')(d),
// <90 days: add hours and minutes - never *only* add hours
h = (r < NINETYDAYS) ? d.getHours() : 0,
m = (r < NINETYDAYS) ? d.getMinutes() : 0,
h = (r < NINETYDAYS) ? d.getUTCHours() : 0,
m = (r < NINETYDAYS) ? d.getUTCMinutes() : 0,
// <3 hours: add seconds
s = (r < THREEHOURS) ? d.getSeconds() : 0,
s = (r < THREEHOURS) ? d.getUTCSeconds() : 0,
// <5 minutes: add ms (plus one extra digit, this is msec*10)
msec10 = (r < FIVEMIN) ? Math.round((d.getMilliseconds() + (((ms % 1) + 1) % 1)) * 10) : 0;
msec10 = (r < FIVEMIN) ? d.getUTCMilliseconds() * 10 + msecTenths : 0;

return includeTime(dateStr, h, m, s, msec10);
};

// For converting old-style milliseconds to date strings,
// we use the local timezone rather than UTC like we use
// everywhere else, both for backward compatibility and
// because that's how people mostly use javasript date objects.
// Clip one extra day off our date range though so we can't get
// thrown beyond the range by the timezone shift.
exports.ms2DateTimeLocal = function(ms) {
if(!(ms >= MIN_MS + ONEDAY && ms <= MAX_MS - ONEDAY)) return BADNUM;

var msecTenths = Math.round(((ms % 1) + 1) * 10) % 10,
d = new Date(Math.round(ms - msecTenths / 10)),
dateStr = d3.time.format('%Y-%m-%d')(d),
h = d.getHours(),
m = d.getMinutes(),
s = d.getSeconds(),
msec10 = d.getUTCMilliseconds() * 10 + msecTenths;

return includeTime(dateStr, h, m, s, msec10);
};

function includeTime(dateStr, h, m, s, msec10) {
// include each part that has nonzero data in or after it
if(h || m || s || msec10) {
dateStr += ' ' + lpad(h, 2) + ':' + lpad(m, 2);
Expand All @@ -176,7 +199,7 @@ exports.ms2DateTime = function(ms, r) {
}
}
return dateStr;
};
}

// normalize date format to date string, in case it starts as
// a Date object or milliseconds
Expand All @@ -186,7 +209,7 @@ exports.cleanDate = function(v, dflt) {
// NOTE: if someone puts in a year as a number rather than a string,
// this will mistakenly convert it thinking it's milliseconds from 1970
// that is: '2012' -> Jan. 1, 2012, but 2012 -> 2012 epoch milliseconds
v = exports.ms2DateTime(+v);
v = exports.ms2DateTimeLocal(+v);
if(!v && dflt !== undefined) return dflt;
}
else if(!exports.isDateTime(v)) {
Expand Down
25 changes: 13 additions & 12 deletions src/plots/cartesian/axes.js
Original file line number Diff line number Diff line change
Expand Up @@ -917,7 +917,7 @@ axes.tickIncrement = function(x, dtick, axrev) {
var y = new Date(x);
// is this browser consistent? setMonth edits a date but
// returns that date's milliseconds
return y.setMonth(y.getMonth() + dtSigned);
return y.setMonth(y.getUTCMonth() + dtSigned);
}

// Log scales: Linear, Digits
Expand Down Expand Up @@ -968,9 +968,9 @@ axes.tickFirst = function(ax) {
if(tType === 'M') {
t0 = new Date(tick0);
r0 = new Date(r0);
mdif = (r0.getFullYear() - t0.getFullYear()) * 12 +
r0.getMonth() - t0.getMonth();
t1 = t0.setMonth(t0.getMonth() +
mdif = (r0.getUTCFullYear() - t0.getUTCFullYear()) * 12 +
r0.getUTCMonth() - t0.getUTCMonth();
t1 = t0.setMonth(t0.getUTCMonth() +
(Math.round(mdif / dtNum) + (axrev ? 1 : -1)) * dtNum);

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

var yearFormat = d3.time.format('%Y'),
monthFormat = d3.time.format('%b %Y'),
dayFormat = d3.time.format('%b %-d'),
yearMonthDayFormat = d3.time.format('%b %-d, %Y'),
minuteFormat = d3.time.format('%H:%M'),
secondFormat = d3.time.format(':%S');
var utcFormat = d3.time.format.utc,
yearFormat = utcFormat('%Y'),
monthFormat = utcFormat('%b %Y'),
dayFormat = utcFormat('%b %-d'),
yearMonthDayFormat = utcFormat('%b %-d, %Y'),
minuteFormat = utcFormat('%H:%M'),
secondFormat = utcFormat(':%S');

// add one item to d3's vocabulary:
// %{n}f where n is the max number of digits
Expand All @@ -1012,10 +1013,10 @@ function modDateFormat(fmt, x) {
var digits = Math.min(+fm[1] || 6, 6),
fracSecs = String((x / 1000 % 1) + 2.0000005)
.substr(2, digits).replace(/0+$/, '') || '0';
return d3.time.format(fmt.replace(fracMatch, fracSecs))(d);
return utcFormat(fmt.replace(fracMatch, fracSecs))(d);
}
else {
return d3.time.format(fmt)(d);
return utcFormat(fmt)(d);
}
}

Expand Down
10 changes: 6 additions & 4 deletions src/plots/cartesian/dragbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,16 +298,18 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
function zoomAxRanges(axList, r0Fraction, r1Fraction) {
var i,
axi,
axRangeLinear;
axRangeLinear0,
axRangeLinearSpan;

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

axRangeLinear = axi.range.map(axi.r2l);
axRangeLinear0 = axi._rl[0];
axRangeLinearSpan = axi._rl[1] - axRangeLinear0;
axi.range = [
axi.l2r(axRangeLinear[0] + (axRangeLinear[1] - axRangeLinear[0]) * r0Fraction),
axi.l2r(axRangeLinear[0] + (axRangeLinear[1] - axRangeLinear[0]) * r1Fraction)
axi.l2r(axRangeLinear0 + axRangeLinearSpan * r0Fraction),
axi.l2r(axRangeLinear0 + axRangeLinearSpan * r1Fraction)
];
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/plots/plots.js
Original file line number Diff line number Diff line change
Expand Up @@ -1390,7 +1390,7 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) {

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

return d;
}
Expand Down
64 changes: 48 additions & 16 deletions test/jasmine/tests/lib_date_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('dates', function() {

describe('dateTime2ms', function() {
it('should accept valid date strings', function() {
var tzOffset;

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

// 2-digit years get mapped to now-70 -> now+29
[thisYear_2 + '-05', new Date(thisYear, 4, 1)],
Expand All @@ -50,11 +52,16 @@ describe('dates', function() {
['2014-03-04 08:15:34+1200', new Date(2014, 2, 4, 8, 15, 34)],
['2014-03-04 08:15:34.567-05:45', new Date(2014, 2, 4, 8, 15, 34, 567)],
].forEach(function(v) {
expect(Lib.dateTime2ms(v[0])).toBe(+v[1], v[0]);
// just for sub-millisecond precision tests, use timezoneoffset
// from the previous date object
if(v[1].getTimezoneOffset) tzOffset = v[1].getTimezoneOffset();

var expected = +v[1] - (tzOffset * 60000);
expect(Lib.dateTime2ms(v[0])).toBe(expected, v[0]);

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

Expand All @@ -69,7 +76,7 @@ describe('dates', function() {
[
1000, 9999, -1000, -9999
].forEach(function(v) {
expect(Lib.dateTime2ms(v)).toBe(+(new Date(v, 0, 1)), v);
expect(Lib.dateTime2ms(v)).toBe(Date.UTC(v, 0, 1), v);
});

[
Expand All @@ -78,7 +85,7 @@ describe('dates', function() {
[nowMinus70_2, nowMinus70],
[99, 1999]
].forEach(function(v) {
expect(Lib.dateTime2ms(v[0])).toBe(+(new Date(v[1], 0, 1)), v[0]);
expect(Lib.dateTime2ms(v[0])).toBe(Date.UTC(v[1], 0, 1), v[0]);
});
});

Expand All @@ -93,7 +100,7 @@ describe('dates', function() {
d1c,
new Date(2015, 8, 7, 23, 34, 45, 567)
].forEach(function(v) {
expect(Lib.dateTime2ms(v)).toBe(+v);
expect(Lib.dateTime2ms(v)).toBe(+v - v.getTimezoneOffset() * 60000);
});
});

Expand Down Expand Up @@ -124,6 +131,30 @@ describe('dates', function() {
expect(Lib.dateTime2ms(v)).toBeUndefined(v);
});
});

var JULY1MS = 181 * 24 * 3600 * 1000;

it('should use UTC with no timezone offset or daylight saving time', function() {
expect(Lib.dateTime2ms('1970-01-01')).toBe(0);

// 181 days (and no DST hours) between jan 1 and july 1 in a non-leap-year
// 31 + 28 + 31 + 30 + 31 + 30
expect(Lib.dateTime2ms('1970-07-01')).toBe(JULY1MS);
});

it('should interpret JS dates by local time, not by its getTime()', function() {
// not really part of the test, just to make sure the test is meaningful
// the test should NOT be run in a UTC environment
expect([
Number(new Date(1970, 0, 1)),
Number(new Date(1970, 6, 1))
]).not.toEqual([0, JULY1MS]);

// now repeat the previous test and show that we throw away
// timezone info from js dates
expect(Lib.dateTime2ms(new Date(1970, 0, 1))).toBe(0);
expect(Lib.dateTime2ms(new Date(1970, 6, 1))).toBe(JULY1MS);
});
});

describe('ms2DateTime', function() {
Expand Down Expand Up @@ -159,8 +190,8 @@ describe('dates', function() {

it('should not accept Date objects beyond our limits or other objects', function() {
[
+(new Date(10000, 0, 1)),
+(new Date(-10000, 11, 31, 23, 59, 59, 999)),
Date.UTC(10000, 0, 1),
Date.UTC(-10000, 11, 31, 23, 59, 59, 999),
'',
'2016-01-01',
'0',
Expand Down Expand Up @@ -191,19 +222,20 @@ describe('dates', function() {
});

describe('cleanDate', function() {
it('should convert any number or js Date within range to a date string', function() {
it('should convert numbers or js Dates to strings based on local TZ', function() {
[
new Date(0),
new Date(2000),
new Date(2000, 0, 1),
new Date(),
new Date(-9999, 0, 1),
new Date(9999, 11, 31, 23, 59, 59, 999)
new Date(-9999, 0, 3), // we lose one day of range +/- tzoffset this way
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for that comment

new Date(9999, 11, 29, 23, 59, 59, 999)
].forEach(function(v) {
expect(typeof Lib.ms2DateTime(+v)).toBe('string');
expect(Lib.cleanDate(v)).toBe(Lib.ms2DateTime(+v));
expect(Lib.cleanDate(+v)).toBe(Lib.ms2DateTime(+v));
expect(Lib.cleanDate(v, '2000-01-01')).toBe(Lib.ms2DateTime(+v));
var expected = Lib.ms2DateTime(Lib.dateTime2ms(v));
expect(typeof expected).toBe('string');
expect(Lib.cleanDate(v)).toBe(expected);
expect(Lib.cleanDate(+v)).toBe(expected);
expect(Lib.cleanDate(v, '2000-01-01')).toBe(expected);
});
});

Expand Down