Skip to content

Commit b9a379b

Browse files
authored
Merge pull request #1194 from plotly/dates-utc-backend
Dates: Accept ISO-8601 format, and use UTC milliseconds as the backend
2 parents 0fc99c6 + 8778648 commit b9a379b

File tree

16 files changed

+274
-166
lines changed

16 files changed

+274
-166
lines changed

circle.yml

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ general:
66
machine:
77
node:
88
version: 6.1.0
9+
timezone:
10+
America/Anchorage
911
services:
1012
- docker
1113

src/components/rangeselector/get_update_object.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@ function getXRange(axisLayout, buttonLayout) {
4242

4343
switch(buttonLayout.stepmode) {
4444
case 'backward':
45-
range0 = Lib.ms2DateTime(+d3.time[step].offset(base, -count));
45+
range0 = Lib.ms2DateTime(+d3.time[step].utc.offset(base, -count));
4646
break;
4747

4848
case 'todate':
49-
var base2 = d3.time[step].offset(base, -count);
49+
var base2 = d3.time[step].utc.offset(base, -count);
5050

51-
range0 = Lib.ms2DateTime(+d3.time[step].ceil(base2));
51+
range0 = Lib.ms2DateTime(+d3.time[step].utc.ceil(base2));
5252
break;
5353
}
5454

src/lib/dates.js

+76-94
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
'use strict';
1111

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

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

@@ -21,6 +20,11 @@ var ONEHOUR = constants.ONEHOUR;
2120
var ONEMIN = constants.ONEMIN;
2221
var ONESEC = constants.ONESEC;
2322

23+
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;
24+
25+
// for 2-digit years, the first year we map them onto
26+
var YFIRST = new Date().getFullYear() - 70;
27+
2428
// is an object a javascript date?
2529
exports.isJSDate = function(v) {
2630
return typeof v === 'object' && v !== null && typeof v.getTime === 'function';
@@ -32,20 +36,33 @@ exports.isJSDate = function(v) {
3236
var MIN_MS, MAX_MS;
3337

3438
/**
35-
* dateTime2ms - turn a date object or string s of the form
36-
* YYYY-mm-dd HH:MM:SS.sss into milliseconds (relative to 1970-01-01,
37-
* per javascript standard)
38-
* may truncate after any full field, and sss can be any length
39-
* even >3 digits, though javascript dates truncate to milliseconds
40-
* returns BADNUM if it doesn't find a date
39+
* dateTime2ms - turn a date object or string s into milliseconds
40+
* (relative to 1970-01-01, per javascript standard)
41+
*
42+
* Returns BADNUM if it doesn't find a date
43+
*
44+
* strings should have the form:
45+
*
46+
* -?YYYY-mm-dd<sep>HH:MM:SS.sss<tzInfo>?
47+
*
48+
* <sep>: space (our normal standard) or T or t (ISO-8601)
49+
* <tzInfo>: Z, z, or [+\-]HH:?MM and we THROW IT AWAY
50+
* this format comes from https://tools.ietf.org/html/rfc3339#section-5.6
51+
* but we allow it even with a space as the separator
52+
*
53+
* May truncate after any full field, and sss can be any length
54+
* even >3 digits, though javascript dates truncate to milliseconds,
55+
* we keep as much as javascript numeric precision can hold, but we only
56+
* report back up to 100 microsecond precision, because most dates support
57+
* this precision (close to 1970 support more, very far away support less)
4158
*
4259
* Expanded to support negative years to -9999 but you must always
4360
* give 4 digits, except for 2-digit positive years which we assume are
4461
* near the present time.
4562
* Note that we follow ISO 8601:2004: there *is* a year 0, which
4663
* is 1BC/BCE, and -1===2BC etc.
4764
*
48-
* 2-digit to 4-digit year conversion, where to cut off?
65+
* Where to cut off 2-digit years between 1900s and 2000s?
4966
* from http://support.microsoft.com/kb/244664:
5067
* 1930-2029 (the most retro of all...)
5168
* but in my mac chrome from eg. d=new Date(Date.parse('8/19/50')):
@@ -70,96 +87,36 @@ var MIN_MS, MAX_MS;
7087
exports.dateTime2ms = function(s) {
7188
// first check if s is a date object
7289
if(exports.isJSDate(s)) {
73-
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;
7493
if(s >= MIN_MS && s <= MAX_MS) return s;
7594
return BADNUM;
7695
}
7796
// otherwise only accept strings and numbers
7897
if(typeof s !== 'string' && typeof s !== 'number') return BADNUM;
7998

80-
var y, m, d, h;
81-
// split date and time parts
82-
// TODO: we strip leading/trailing whitespace but not other
83-
// characters like we do for numbers - do we want to?
84-
var datetime = String(s).trim().split(' ');
85-
if(datetime.length > 2) return BADNUM;
86-
87-
var p = datetime[0].split('-'); // date part
88-
89-
var CE = true; // common era, ie positive year
90-
if(p[0] === '') {
91-
// first part is blank: year starts with a minus sign
92-
CE = false;
93-
p.splice(0, 1);
99+
var match = String(s).match(DATETIME_REGEXP);
100+
if(!match) return BADNUM;
101+
var y = match[1],
102+
m = Number(match[3] || 1),
103+
d = Number(match[5] || 1),
104+
H = Number(match[7] || 0),
105+
M = Number(match[9] || 0),
106+
S = Number(match[11] || 0);
107+
if(y.length === 2) {
108+
y = (Number(y) + 2000 - YFIRST) % 100 + YFIRST;
94109
}
95-
96-
var plen = p.length;
97-
if(plen > 3 || (plen !== 3 && datetime[1]) || !plen) return BADNUM;
98-
99-
// year
100-
if(p[0].length === 4) y = Number(p[0]);
101-
else if(p[0].length === 2) {
102-
if(!CE) return BADNUM;
103-
var yNow = new Date().getFullYear();
104-
y = ((Number(p[0]) - yNow + 70) % 100 + 200) % 100 + yNow - 70;
105-
}
106-
else return BADNUM;
107-
if(!isNumeric(y)) return BADNUM;
110+
else y = Number(y);
108111

109112
// javascript takes new Date(0..99,m,d) to mean 1900-1999, so
110113
// to support years 0-99 we need to use setFullYear explicitly
111-
var baseDate = new Date(0, 0, 1);
112-
baseDate.setFullYear(CE ? y : -y);
113-
if(p.length > 1) {
114-
115-
// month - may be 1 or 2 digits
116-
m = Number(p[1]) - 1; // new Date() uses zero-based months
117-
if(p[1].length > 2 || !(m >= 0 && m <= 11)) return BADNUM;
118-
baseDate.setMonth(m);
119-
120-
if(p.length > 2) {
121-
122-
// day - may be 1 or 2 digits
123-
d = Number(p[2]);
124-
if(p[2].length > 2 || !(d >= 1 && d <= 31)) return BADNUM;
125-
baseDate.setDate(d);
126-
127-
// does that date exist in this month?
128-
if(baseDate.getDate() !== d) return BADNUM;
129-
130-
if(datetime[1]) {
114+
var date = new Date(Date.UTC(2000, m - 1, d, H, M));
115+
date.setUTCFullYear(y);
131116

132-
p = datetime[1].split(':');
133-
if(p.length > 3) return BADNUM;
117+
if(date.getUTCDate() !== d) return BADNUM;
134118

135-
// hour - may be 1 or 2 digits
136-
h = Number(p[0]);
137-
if(p[0].length > 2 || !p[0].length || !(h >= 0 && h <= 23)) return BADNUM;
138-
baseDate.setHours(h);
139-
140-
// does that hour exist in this day? (Daylight time!)
141-
// (TODO: remove this check when we move to UTC)
142-
if(baseDate.getHours() !== h) return BADNUM;
143-
144-
if(p.length > 1) {
145-
d = baseDate.getTime();
146-
147-
// minute - must be 2 digits
148-
m = Number(p[1]);
149-
if(p[1].length !== 2 || !(m >= 0 && m <= 59)) return BADNUM;
150-
d += ONEMIN * m;
151-
if(p.length === 2) return d;
152-
153-
// second (and milliseconds) - must have 2-digit seconds
154-
if(p[2].split('.')[0].length !== 2) return BADNUM;
155-
s = Number(p[2]);
156-
if(!(s >= 0 && s < 60)) return BADNUM;
157-
return d + s * ONESEC;
158-
}
159-
}
160-
}
161-
}
162-
return baseDate.getTime();
119+
return date.getTime() + S * ONESEC;
163120
};
164121

165122
MIN_MS = exports.MIN_MS = exports.dateTime2ms('-9999');
@@ -191,16 +148,41 @@ exports.ms2DateTime = function(ms, r) {
191148

192149
if(!r) r = 0;
193150

194-
var d = new Date(Math.floor(ms)),
195-
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),
196154
// <90 days: add hours and minutes - never *only* add hours
197-
h = (r < NINETYDAYS) ? d.getHours() : 0,
198-
m = (r < NINETYDAYS) ? d.getMinutes() : 0,
155+
h = (r < NINETYDAYS) ? d.getUTCHours() : 0,
156+
m = (r < NINETYDAYS) ? d.getUTCMinutes() : 0,
199157
// <3 hours: add seconds
200-
s = (r < THREEHOURS) ? d.getSeconds() : 0,
158+
s = (r < THREEHOURS) ? d.getUTCSeconds() : 0,
201159
// <5 minutes: add ms (plus one extra digit, this is msec*10)
202-
msec10 = (r < FIVEMIN) ? Math.round((d.getMilliseconds() + (((ms % 1) + 1) % 1)) * 10) : 0;
160+
msec10 = (r < FIVEMIN) ? d.getUTCMilliseconds() * 10 + msecTenths : 0;
203161

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+
};
184+
185+
function includeTime(dateStr, h, m, s, msec10) {
204186
// include each part that has nonzero data in or after it
205187
if(h || m || s || msec10) {
206188
dateStr += ' ' + lpad(h, 2) + ':' + lpad(m, 2);
@@ -217,7 +199,7 @@ exports.ms2DateTime = function(ms, r) {
217199
}
218200
}
219201
return dateStr;
220-
};
202+
}
221203

222204
// normalize date format to date string, in case it starts as
223205
// a Date object or milliseconds
@@ -227,7 +209,7 @@ exports.cleanDate = function(v, dflt) {
227209
// NOTE: if someone puts in a year as a number rather than a string,
228210
// this will mistakenly convert it thinking it's milliseconds from 1970
229211
// that is: '2012' -> Jan. 1, 2012, but 2012 -> 2012 epoch milliseconds
230-
v = exports.ms2DateTime(+v);
212+
v = exports.ms2DateTimeLocal(+v);
231213
if(!v && dflt !== undefined) return dflt;
232214
}
233215
else if(!exports.isDateTime(v)) {

src/lib/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ var datesModule = require('./dates');
2828
lib.dateTime2ms = datesModule.dateTime2ms;
2929
lib.isDateTime = datesModule.isDateTime;
3030
lib.ms2DateTime = datesModule.ms2DateTime;
31+
lib.ms2DateTimeLocal = datesModule.ms2DateTimeLocal;
3132
lib.cleanDate = datesModule.cleanDate;
3233
lib.isJSDate = datesModule.isJSDate;
3334
lib.MIN_MS = datesModule.MIN_MS;

src/plots/cartesian/axes.js

+14-13
Original file line numberDiff line numberDiff line change
@@ -931,9 +931,9 @@ axes.tickIncrement = function(x, dtick, axrev) {
931931
// Dates: months (or years)
932932
if(tType === 'M') {
933933
var y = new Date(x);
934-
// is this browser consistent? setMonth edits a date but
934+
// is this browser consistent? setUTCMonth edits a date but
935935
// returns that date's milliseconds
936-
return y.setMonth(y.getMonth() + dtSigned);
936+
return y.setUTCMonth(y.getUTCMonth() + dtSigned);
937937
}
938938

939939
// Log scales: Linear, Digits
@@ -984,9 +984,9 @@ axes.tickFirst = function(ax) {
984984
if(tType === 'M') {
985985
t0 = new Date(tick0);
986986
r0 = new Date(r0);
987-
mdif = (r0.getFullYear() - t0.getFullYear()) * 12 +
988-
r0.getMonth() - t0.getMonth();
989-
t1 = t0.setMonth(t0.getMonth() +
987+
mdif = (r0.getUTCFullYear() - t0.getUTCFullYear()) * 12 +
988+
r0.getUTCMonth() - t0.getUTCMonth();
989+
t1 = t0.setUTCMonth(t0.getUTCMonth() +
990990
(Math.round(mdif / dtNum) + (axrev ? 1 : -1)) * dtNum);
991991

992992
while(axrev ? t1 > r0 : t1 < r0) {
@@ -1010,12 +1010,13 @@ axes.tickFirst = function(ax) {
10101010
else throw 'unrecognized dtick ' + String(dtick);
10111011
};
10121012

1013-
var yearFormat = d3.time.format('%Y'),
1014-
monthFormat = d3.time.format('%b %Y'),
1015-
dayFormat = d3.time.format('%b %-d'),
1016-
yearMonthDayFormat = d3.time.format('%b %-d, %Y'),
1017-
minuteFormat = d3.time.format('%H:%M'),
1018-
secondFormat = d3.time.format(':%S');
1013+
var utcFormat = d3.time.format.utc,
1014+
yearFormat = utcFormat('%Y'),
1015+
monthFormat = utcFormat('%b %Y'),
1016+
dayFormat = utcFormat('%b %-d'),
1017+
yearMonthDayFormat = utcFormat('%b %-d, %Y'),
1018+
minuteFormat = utcFormat('%H:%M'),
1019+
secondFormat = utcFormat(':%S');
10191020

10201021
// add one item to d3's vocabulary:
10211022
// %{n}f where n is the max number of digits
@@ -1028,10 +1029,10 @@ function modDateFormat(fmt, x) {
10281029
var digits = Math.min(+fm[1] || 6, 6),
10291030
fracSecs = String((x / 1000 % 1) + 2.0000005)
10301031
.substr(2, digits).replace(/0+$/, '') || '0';
1031-
return d3.time.format(fmt.replace(fracMatch, fracSecs))(d);
1032+
return utcFormat(fmt.replace(fracMatch, fracSecs))(d);
10321033
}
10331034
else {
1034-
return d3.time.format(fmt)(d);
1035+
return utcFormat(fmt)(d);
10351036
}
10361037
}
10371038

src/plots/cartesian/dragbox.js

+6-4
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/cartesian/set_convert.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -265,10 +265,10 @@ module.exports = function setConvert(ax) {
265265
// NOTE: Changed this behavior: previously we took any numeric value
266266
// to be a ms, even if it was a string that could be a bare year.
267267
// Now we convert it as a date if at all possible, and only try
268-
// as ms if that fails.
268+
// as (local) ms if that fails.
269269
var ms = Lib.dateTime2ms(v);
270270
if(ms === BADNUM) {
271-
if(isNumeric(v)) ms = Number(v);
271+
if(isNumeric(v)) ms = Lib.dateTime2ms(new Date(v));
272272
else return BADNUM;
273273
}
274274
return Lib.constrain(ms, Lib.MIN_MS, Lib.MAX_MS);

src/plots/plots.js

+1-1
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/image/baselines/date_axes.png

18.7 KB
Loading

0 commit comments

Comments
 (0)