Skip to content

Commit a2f7920

Browse files
committedMay 3, 2012
Complete re-implementation of JulianDate.fromIso8601
1. Takes into account all formats defined in the ISO8601 standard, including those not supported by Date.parse. 2. Supports leap seconds and sub-millisecond times. 3. Break out isLeapYear into a seperate helper function. 4. Remove toYearFraction as it has problems and is unused. 5. Write a bunch of new tests. This does NOT add support for more than 4 digit years, as that is a variant of the standard that requires an agreed upon number of digits that we have not settled on yet. This means that we can't yet support times before 1 B.C. or after 9999 AD. This will be added in the future.
1 parent a607cb7 commit a2f7920

File tree

4 files changed

+746
-148
lines changed

4 files changed

+746
-148
lines changed
 

‎Source/Core/JulianDate.js

+237-78
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,19 @@
11
/*global define*/
2-
define(['./DeveloperError', './binarySearch', './TimeConstants', './LeapSecond', './TimeStandard'], function(DeveloperError, binarySearch, TimeConstants, LeapSecond, TimeStandard) {
2+
define(['Core/DeveloperError', 'Core/binarySearch', 'Core/TimeConstants', 'Core/LeapSecond', 'Core/TimeStandard', 'Core/isLeapYear'],
3+
function(DeveloperError, binarySearch, TimeConstants, LeapSecond, TimeStandard, isLeapYear) {
34
"use strict";
45

5-
function computeJulianDateComponents(date) {
6+
var daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
7+
var daysInLeapFeburary = 29;
8+
9+
function computeJulianDateComponents(year, month, day, hour, minute, second, millisecond) {
610
// Algorithm from page 604 of the Explanatory Supplement to the
711
// Astronomical Almanac (Seidelmann 1992).
812

9-
var month = date.getUTCMonth() + 1; // getUTCMonth returns a value 0-11.
10-
var day = date.getUTCDate();
11-
var year = date.getUTCFullYear();
12-
1313
var a = ((month - 14) / 12) | 0;
1414
var b = (year + 4800 + a) | 0;
15-
1615
var dayNumber = ((((1461 * b) / 4) | 0) + (((367 * (month - 2 - 12 * a)) / 12) | 0) - (((3 * ((b + 100) / 100)) / 4) | 0) + day - 32075) | 0;
1716

18-
var hour = date.getUTCHours();
19-
var minute = date.getUTCMinutes();
20-
var second = date.getUTCSeconds();
21-
var millisecond = date.getUTCMilliseconds();
22-
2317
// JulianDates are noon-based
2418
hour = hour - 12;
2519
if (hour < 0) {
@@ -35,6 +29,32 @@ define(['./DeveloperError', './binarySearch', './TimeConstants', './LeapSecond',
3529
return [dayNumber, secondsOfDay];
3630
}
3731

32+
function computeJulianDateComponentsFromDate(date) {
33+
return computeJulianDateComponents(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date
34+
.getUTCMilliseconds());
35+
}
36+
37+
//Regular expressions used for ISO8601 date parsing.
38+
39+
//YYYY
40+
var matchCalendarYear = /^(\d{4})$/;
41+
//YYYY-MM (YYYYMM is invalid)
42+
var matchCalendarMonth = /^(\d{4})-(\d{2})$/;
43+
//YYYY-DDD or YYYYDDD
44+
var matchOrdinalDate = /^(\d{4})-*(\d{3})$/;
45+
//YYYY-Www or YYYYWww or YYYY-Www-D or YYYYWwwD
46+
var matchWeekDate = /^(\d{4})-*W(\d{2})-*(\d{1})*$/;
47+
//YYYY-MM-DD or YYYYMMDD
48+
var matchCalendarDate = /^(\d{4})-*(\d{2})-*(\d{2})$/;
49+
// Match utc offset
50+
var utcOffset = /([Z+\-])*(\d{2})*:*(\d{2})*$/;
51+
// Match hours HH or HH.xxxxx
52+
var matchHours = /^(\d{2})(\.\d+)*/.source + utcOffset.source;
53+
// Match hours/minutes HH:MM HHMM.xxxxx
54+
var matchHoursMinutes = /^(\d{2}):*(\d{2})(\.\d+)*/.source + utcOffset.source;
55+
// Match hours/minutes HH:MM:SS HHMMSS.xxxxx
56+
var matchHoursMinutesSeconds = /^(\d{2}):*(\d{2}):*(\d{2})(\.\d+)*/.source + utcOffset.source;
57+
3858
/**
3959
* <p>Constructs an immutable JulianDate instance from a Julian day number and the number of seconds elapsed
4060
* into that day as arguments (along with an optional time standard). Passing no parameters will
@@ -102,7 +122,7 @@ define(['./DeveloperError', './binarySearch', './TimeConstants', './LeapSecond',
102122
} else {
103123
//Create a new date from the current time.
104124
var date = new Date();
105-
var components = computeJulianDateComponents(date);
125+
var components = computeJulianDateComponentsFromDate(date);
106126
wholeDays = components[0];
107127
secondsOfDay = components[1];
108128
timeStandard = TimeStandard.UTC;
@@ -157,22 +177,22 @@ define(['./DeveloperError', './binarySearch', './TimeConstants', './LeapSecond',
157177
throw new DeveloperError("Valid JavaScript Date required.", "date");
158178
}
159179

160-
var components = computeJulianDateComponents(date);
161-
var wholeDays = components[0];
162-
var secondsOfDay = components[1];
163-
var result = new JulianDate(wholeDays, secondsOfDay, timeStandard);
180+
var components = computeJulianDateComponentsFromDate(date);
181+
var result = new JulianDate(components[0], components[1], timeStandard);
164182
result._date = date;
165183
return result;
166184
};
167185

168186
/**
169-
* Creates an immutable JulianDate instance from a ISO 8601 date string.
170-
* <br/>
187+
* <p>
188+
* Creates an immutable JulianDate instance from an ISO 8601 date string. Unlike Date.parse,
189+
* this method will properly account for all valid formats defined by the ISO 8601
190+
* specification. It will also properly handle leap seconds and sub-millisecond times.
191+
* <p/>
171192
*
172193
* @memberof JulianDate
173194
*
174195
* @param {String} iso8601String The ISO 8601 date string representing the time to be converted to a Julian date.
175-
* @param {TimeStandard} [timeStandard = TimeStandard.UTC] Indicates the time standard in which this Julian date is represented.
176196
*
177197
* @return {JulianDate} The new {@Link JulianDate} instance.
178198
*
@@ -181,23 +201,212 @@ define(['./DeveloperError', './binarySearch', './TimeConstants', './LeapSecond',
181201
* @see JulianDate
182202
* @see JulianDate.fromTotalDays
183203
* @see JulianDate.fromDate
184-
* @see TimeStandard
185204
* @see LeapSecond
186205
* @see <a href="http://en.wikipedia.org/wiki/ISO_8601">ISO 8601 on Wikipedia</a>.
187206
*
188207
* @example
189-
* // Example 1. Construct a Julian date using the default UTC TimeStandard.
208+
* // Example 1. Construct a Julian date in UTC at April 24th, 2012 6:08PM UTC
190209
* var julianDate = JulianDate.fromIso8601("2012-04-24T18:08Z");
210+
* // Example 2. Construct a Julian date in local time April 24th, 2012 12:00 AM
211+
* var localDay = JulianDate.fromIso8601("2012-04-24");
212+
* // Example 3. Construct a Julian date 5 hours behind UTC April 24th, 2012 5:00 pm UTC
213+
* var localDay = JulianDate.fromIso8601("2012-04-24T12:00-05:00");
191214
*/
192-
JulianDate.fromIso8601 = function(iso8601String, timeStandard) {
193-
//FIXME Date.parse is only accurate to the millisecond and fails
194-
//completely on leap seconds. We should parse the string directly.
215+
JulianDate.fromIso8601 = function(iso8601String) {
216+
if (typeof iso8601String !== 'string') {
217+
throw new DeveloperError("Valid ISO 8601 date string required.", "iso8601String");
218+
}
219+
220+
//Comma and decimal point both indicate a fractional number according to ISO 8601,
221+
//start out by blanket replacing , with . which is the only valid such symbol in JS.
222+
iso8601String = iso8601String.replace(',', '.');
223+
224+
//Split the string into it's date and time components, denoted by a mandatory T
225+
var tokens = iso8601String.split('T'), year, month = 1, day = 1, hours = 0, minutes = 0, seconds = 0, milliseconds = 0;
226+
227+
//Lacking a time is okay, but a missing date is illegal.
228+
if (typeof tokens[0] === 'undefined') {
229+
throw new DeveloperError("Valid ISO 8601 date string required.", "iso8601String");
230+
}
231+
232+
var date = tokens[0];
233+
var time = tokens[1];
234+
var leapYear;
235+
if (typeof date === 'undefined') {
236+
throw new DeveloperError("Valid ISO 8601 date string required.", "iso8601String");
237+
}
238+
239+
tokens = date.match(matchCalendarDate);
240+
if (tokens !== null) {
241+
year = +tokens[1];
242+
month = +tokens[2];
243+
day = +tokens[3];
244+
} else {
245+
tokens = date.match(matchCalendarMonth);
246+
if (tokens !== null) {
247+
year = +tokens[1];
248+
month = +tokens[2];
249+
} else {
250+
tokens = date.match(matchCalendarYear);
251+
if (tokens !== null) {
252+
year = +tokens[1];
253+
} else {
254+
tokens = date.match(matchOrdinalDate);
255+
if (tokens !== null) {
256+
year = +tokens[1];
257+
var dayOfYear = +tokens[2];
258+
leapYear = isLeapYear(year);
259+
260+
if (dayOfYear < 1 || (leapYear && dayOfYear > 366) || (!leapYear && dayOfYear > 365)) {
261+
throw new DeveloperError("Valid ISO 8601 date string required.", "iso8601String");
262+
}
263+
264+
var jsDate = new Date(Date.UTC(year, 0, 1));
265+
jsDate.setUTCDate(dayOfYear);
266+
month = jsDate.getUTCMonth() + 1;
267+
day = jsDate.getUTCDate();
268+
} else {
269+
tokens = date.match(matchWeekDate);
270+
if (tokens !== null) {
271+
year = +tokens[1];
272+
var weekNumber = +tokens[2];
273+
var dayOfWeek = +tokens[3] || 0;
274+
275+
var january4 = new Date(Date.UTC(year, 0, 4));
276+
var utcDay = january4.getUTCDay();
277+
var ordinalDate = (weekNumber * 7) + dayOfWeek - utcDay - 3;
278+
279+
var sdf = new Date(Date.UTC(year, 0, 1));
280+
sdf.setUTCDate(ordinalDate);
281+
month = sdf.getUTCMonth() + 1;
282+
day = sdf.getUTCDate();
283+
} else {
284+
throw new DeveloperError("Valid ISO 8601 date string required.", "iso8601String");
285+
}
286+
}
287+
}
288+
}
289+
}
195290

196-
var totalMilliseconds = Date.parse(iso8601String);
197-
if (totalMilliseconds === null || isNaN(totalMilliseconds)) {
291+
leapYear = isLeapYear(year);
292+
if (month < 1 || month > 12 || day < 1 || ((month !== 2 || !leapYear) && day > daysInMonth[month - 1]) || (leapYear && month === 2 && day > daysInLeapFeburary)) {
198293
throw new DeveloperError("Valid ISO 8601 date string required.", "iso8601String");
199294
}
200-
return JulianDate.fromDate(new Date(totalMilliseconds), timeStandard);
295+
296+
var offsetIndex;
297+
if (typeof time !== 'undefined') {
298+
tokens = time.match(matchHoursMinutesSeconds);
299+
if (tokens !== null) {
300+
hours = +tokens[1];
301+
minutes = +tokens[2];
302+
seconds = +tokens[3];
303+
milliseconds = +(tokens[4] || 0) * 1000.0;
304+
offsetIndex = 5;
305+
} else {
306+
tokens = time.match(matchHoursMinutes);
307+
if (tokens !== null) {
308+
hours = +tokens[1];
309+
minutes = +tokens[2];
310+
seconds = +(tokens[3] || 0) * 60.0;
311+
offsetIndex = 4;
312+
} else {
313+
tokens = time.match(matchHours);
314+
if (tokens !== null) {
315+
hours = +tokens[1];
316+
minutes = +(tokens[2] || 0) * 60.0;
317+
offsetIndex = 3;
318+
} else {
319+
throw new DeveloperError("Valid ISO 8601 date string required.", "iso8601String");
320+
}
321+
}
322+
}
323+
324+
if (minutes >= 60 || seconds > 60 || hours > 24 || (hours === 24 && (minutes > 0 || seconds > 0 || milliseconds > 0))) {
325+
throw new DeveloperError("Valid ISO 8601 date string required.", "iso8601String");
326+
}
327+
328+
var offset = tokens[offsetIndex];
329+
var offsetHours = +(tokens[offsetIndex + 1]);
330+
var offsetMinutes = +(tokens[offsetIndex + 2] || 0);
331+
switch (offset) {
332+
case '+':
333+
hours = hours - offsetHours;
334+
minutes = minutes - offsetMinutes;
335+
break;
336+
case '-':
337+
hours = hours + offsetHours;
338+
minutes = minutes + offsetMinutes;
339+
break;
340+
case 'Z':
341+
break;
342+
default:
343+
minutes = minutes + new Date(Date.UTC(year, month - 1, day, hours, minutes)).getTimezoneOffset();
344+
break;
345+
}
346+
} else {
347+
//If no time is specified, we it is considered the beginning of the day, local time.
348+
minutes = minutes + new Date(Date.UTC(year, month - 1, day)).getTimezoneOffset();
349+
}
350+
351+
//ISO8601 denotes a leap second by any time having a seconds component of exactly 60 seconds.
352+
//If that's the case, we need to temporarily subtract a second in order to build a UTC date.
353+
//Then we add it back in after converting to TAI.
354+
var isLeapSecond = seconds === 60;
355+
if (isLeapSecond) {
356+
seconds--;
357+
}
358+
359+
//Even if we successfully parsed the string into it's components, after applying UTC offset or
360+
//special cases like 24:00:00 denoting midnight, we need to normalize the data appropriately.
361+
362+
//milliseconds can never be greater than 1000, so we start with seconds
363+
while (seconds >= 60) {
364+
seconds -= 60;
365+
hours++;
366+
}
367+
while (hours >= 24) {
368+
hours -= 24;
369+
day++;
370+
}
371+
if (leapYear && month === 2) {
372+
while (day >= daysInLeapFeburary) {
373+
day -= daysInLeapFeburary;
374+
month++;
375+
}
376+
} else {
377+
var monthCount = daysInMonth[month - 1];
378+
while (day >= monthCount) {
379+
day -= monthCount;
380+
month++;
381+
}
382+
}
383+
while (month >= 12) {
384+
month -= 12;
385+
year++;
386+
}
387+
388+
//If UTC offset is at the beginning/end of the day, hours can be negative.
389+
while (hours < 0) {
390+
hours += 24;
391+
day--;
392+
}
393+
while (day < 0) {
394+
day += daysInMonth[month - 1];
395+
month--;
396+
}
397+
while (month < 0) {
398+
month += 12;
399+
year--;
400+
}
401+
402+
var components = computeJulianDateComponents(year, month, day, hours, minutes, seconds, milliseconds);
403+
var result = new JulianDate(components[0], components[1], TimeStandard.UTC);
404+
405+
if (isLeapSecond) {
406+
result = TimeStandard.convertUtcToTai(result).addSeconds(1);
407+
}
408+
409+
return result;
201410
};
202411

203412
/**
@@ -642,56 +851,6 @@ define(['./DeveloperError', './binarySearch', './TimeConstants', './LeapSecond',
642851
return new JulianDate(newJulianDayNumber, this._secondsOfDay, this._timeStandard);
643852
};
644853

645-
/**
646-
* Computes the fraction of the year corresponding to this Julian date. Leap years
647-
* are taken into account.
648-
*
649-
* @memberof JulianDate
650-
*
651-
* @return {Number} The fraction of the current year that has passed.
652-
*
653-
* @example
654-
* var date = new Date(2011, 0, 2); // January 2, 2011 @ 0:00
655-
* date.setUTCHours(0, 0, 0, 0);
656-
* var julianDate = JulianDate.fromDate(date);
657-
* var yearFraction = julianDate.toYearFraction(); //1.0/365.0
658-
*/
659-
JulianDate.prototype.toYearFraction = function() {
660-
var commonYearCumulativeMonthTable = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
661-
var leapYearCumulativeMonthTable = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
662-
var dayInYear;
663-
var fractionOfDay;
664-
665-
function isLeapYear(year) {
666-
return ((year % 4 === 0) && (year % 100 !== 0)) || (year % 400 === 0);
667-
}
668-
669-
function dayOfYear(date) {
670-
var day = date.getDate();
671-
var month = date.getMonth();
672-
if (isLeapYear(date.getFullYear())) {
673-
return day + leapYearCumulativeMonthTable[month];
674-
}
675-
return day + commonYearCumulativeMonthTable[month];
676-
}
677-
678-
var date = this.toDate();
679-
if (this._secondsOfDay / TimeConstants.SECONDS_PER_DAY < 0.5) {
680-
dayInYear = dayOfYear(date) - 1;
681-
fractionOfDay = (this._secondsOfDay / TimeConstants.SECONDS_PER_DAY) + 0.5;
682-
} else {
683-
date.setDate(date.getDate() + 1);
684-
dayInYear = dayOfYear(date) - 1;
685-
fractionOfDay = (this._secondsOfDay / TimeConstants.SECONDS_PER_DAY) - 0.5;
686-
}
687-
688-
if (isLeapYear(date.getFullYear())) {
689-
return (dayInYear + fractionOfDay) / 366.0;
690-
}
691-
692-
return (dayInYear + fractionOfDay) / 365.0;
693-
};
694-
695854
/**
696855
* Returns true if <code>other</code> occurs after this Julian date.
697856
*

0 commit comments

Comments
 (0)
Please sign in to comment.