Skip to content
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

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

Merged
merged 12 commits into from
Nov 25, 2016
Merged
170 changes: 76 additions & 94 deletions src/lib/dates.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
'use strict';

var d3 = require('d3');
var isNumeric = require('fast-isnumeric');

var logError = require('./loggers').error;

Expand All @@ -21,6 +20,11 @@ var ONEHOUR = constants.ONEHOUR;
var ONEMIN = constants.ONEMIN;
var ONESEC = constants.ONESEC;

var DATETIME_REGEXP = /^\s*(-?\d\d\d\d|\d\d)(-(0?[1-9]|1[012])(-([0-3]?\d)([ Tt]([01]?\d|2[0-3])(:([0-5]\d)(:([0-5]\d(\.\d+)?))?(Z|z|[+\-]\d\d:?\d\d)?)?)?)?)?\s*$/m;
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you cook this up yourself or find from a google search? If the latter, can you paste the reference above?


// for 2-digit years, the first year we map them onto
var YFIRST = new Date().getFullYear() - 70;

// is an object a javascript date?
exports.isJSDate = function(v) {
return typeof v === 'object' && v !== null && typeof v.getTime === 'function';
Expand All @@ -32,20 +36,33 @@ exports.isJSDate = function(v) {
var MIN_MS, MAX_MS;

/**
* dateTime2ms - turn a date object or string s of the form
* YYYY-mm-dd HH:MM:SS.sss into milliseconds (relative to 1970-01-01,
* per javascript standard)
* may truncate after any full field, and sss can be any length
* even >3 digits, though javascript dates truncate to milliseconds
* returns BADNUM if it doesn't find a date
* dateTime2ms - turn a date object or string s into milliseconds
* (relative to 1970-01-01, per javascript standard)
*
* Returns BADNUM if it doesn't find a date
*
* strings should have the form:
*
* -?YYYY-mm-dd<sep>HH:MM:SS.sss<tzInfo>?
*
* <sep>: space (our normal standard) or T or t (ISO-8601)
* <tzInfo>: Z, z, or [+\-]HH:?MM and we THROW IT AWAY
* this format comes from https://tools.ietf.org/html/rfc3339#section-5.6
* but we allow it even with a space as the separator
*
* May truncate after any full field, and sss can be any length
* even >3 digits, though javascript dates truncate to milliseconds,
* we keep as much as javascript numeric precision can hold, but we only
* report back up to 100 microsecond precision, because most dates support
* this precision (close to 1970 support more, very far away support less)
*
* Expanded to support negative years to -9999 but you must always
* give 4 digits, except for 2-digit positive years which we assume are
* near the present time.
* Note that we follow ISO 8601:2004: there *is* a year 0, which
* is 1BC/BCE, and -1===2BC etc.
*
* 2-digit to 4-digit year conversion, where to cut off?
* Where to cut off 2-digit years between 1900s and 2000s?
* from http://support.microsoft.com/kb/244664:
* 1930-2029 (the most retro of all...)
* but in my mac chrome from eg. d=new Date(Date.parse('8/19/50')):
Expand All @@ -70,96 +87,36 @@ 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;
}
// otherwise only accept strings and numbers
if(typeof s !== 'string' && typeof s !== 'number') return BADNUM;

var y, m, d, h;
// split date and time parts
// TODO: we strip leading/trailing whitespace but not other
// characters like we do for numbers - do we want to?
var datetime = String(s).trim().split(' ');
if(datetime.length > 2) return BADNUM;

var p = datetime[0].split('-'); // date part

var CE = true; // common era, ie positive year
if(p[0] === '') {
// first part is blank: year starts with a minus sign
CE = false;
p.splice(0, 1);
var match = String(s).match(DATETIME_REGEXP);
if(!match) return BADNUM;
var y = match[1],
m = Number(match[3] || 1),
d = Number(match[5] || 1),
H = Number(match[7] || 0),
M = Number(match[9] || 0),
S = Number(match[11] || 0);
if(y.length === 2) {
y = (Number(y) + 2000 - YFIRST) % 100 + YFIRST;
}

var plen = p.length;
if(plen > 3 || (plen !== 3 && datetime[1]) || !plen) return BADNUM;

// year
if(p[0].length === 4) y = Number(p[0]);
else if(p[0].length === 2) {
if(!CE) return BADNUM;
var yNow = new Date().getFullYear();
y = ((Number(p[0]) - yNow + 70) % 100 + 200) % 100 + yNow - 70;
}
else return BADNUM;
if(!isNumeric(y)) return BADNUM;
else y = Number(y);

// 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 baseDate = new Date(0, 0, 1);
baseDate.setFullYear(CE ? y : -y);
if(p.length > 1) {

// month - may be 1 or 2 digits
m = Number(p[1]) - 1; // new Date() uses zero-based months
if(p[1].length > 2 || !(m >= 0 && m <= 11)) return BADNUM;
baseDate.setMonth(m);

if(p.length > 2) {

// day - may be 1 or 2 digits
d = Number(p[2]);
if(p[2].length > 2 || !(d >= 1 && d <= 31)) return BADNUM;
baseDate.setDate(d);

// does that date exist in this month?
if(baseDate.getDate() !== d) return BADNUM;

if(datetime[1]) {
var date = new Date(Date.UTC(2000, m - 1, d, H, M));
date.setUTCFullYear(y);

p = datetime[1].split(':');
if(p.length > 3) return BADNUM;
if(date.getUTCDate() !== d) return BADNUM;

// hour - may be 1 or 2 digits
h = Number(p[0]);
if(p[0].length > 2 || !p[0].length || !(h >= 0 && h <= 23)) return BADNUM;
baseDate.setHours(h);

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

if(p.length > 1) {
d = baseDate.getTime();

// minute - must be 2 digits
m = Number(p[1]);
if(p[1].length !== 2 || !(m >= 0 && m <= 59)) return BADNUM;
d += ONEMIN * m;
if(p.length === 2) return d;

// second (and milliseconds) - must have 2-digit seconds
if(p[2].split('.')[0].length !== 2) return BADNUM;
s = Number(p[2]);
if(!(s >= 0 && s < 60)) return BADNUM;
return d + s * ONESEC;
}
}
}
}
return baseDate.getTime();
return date.getTime() + S * ONESEC;
};

MIN_MS = exports.MIN_MS = exports.dateTime2ms('-9999');
Expand Down Expand Up @@ -191,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 @@ -217,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 @@ -227,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
Loading