diff --git a/package.json b/package.json index 706033f49f3..232b6f3dd6c 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "tinycolor2": "^1.3.0", "topojson-client": "^2.1.0", "webgl-context": "^2.2.0", - "world-calendars": "^1.0.0" + "world-calendars": "^1.0.3" }, "devDependencies": { "brfs": "^1.4.3", diff --git a/src/components/calendars/index.js b/src/components/calendars/index.js index 076411f4739..f475ed62932 100644 --- a/src/components/calendars/index.js +++ b/src/components/calendars/index.js @@ -106,6 +106,7 @@ var DFLTRANGE = { var UNKNOWN = '##'; var d3ToWorldCalendars = { 'd': {'0': 'dd', '-': 'd'}, // 2-digit or unpadded day of month + 'e': {'0': 'd', '-': 'd'}, // alternate, always unpadded day of month 'a': {'0': 'D', '-': 'D'}, // short weekday name 'A': {'0': 'DD', '-': 'DD'}, // full weekday name 'j': {'0': 'oo', '-': 'o'}, // 3-digit or unpadded day of the year @@ -119,12 +120,12 @@ var d3ToWorldCalendars = { 'w': UNKNOWN, // day of the week [0(sunday),6] // combined format, we replace the date part with the world-calendar version // and the %X stays there for d3 to handle with time parts - '%c': {'0': 'D M m %X yyyy', '-': 'D M m %X yyyy'}, - '%x': {'0': 'mm/dd/yyyy', '-': 'mm/dd/yyyy'} + 'c': {'0': 'D M d %X yyyy', '-': 'D M d %X yyyy'}, + 'x': {'0': 'mm/dd/yyyy', '-': 'mm/dd/yyyy'} }; function worldCalFmt(fmt, x, calendar) { - var dateJD = Math.floor(x + 0.05 / ONEDAY) + EPOCHJD, + var dateJD = Math.floor((x + 0.05) / ONEDAY) + EPOCHJD, cDate = getCal(calendar).fromJD(dateJD), i = 0, modifier, directive, directiveLen, directiveObj, replacementPart; @@ -132,7 +133,7 @@ function worldCalFmt(fmt, x, calendar) { modifier = fmt.charAt(i + 1); if(modifier === '0' || modifier === '-' || modifier === '_') { directiveLen = 3; - directive = fmt.charAt(i + 1); + directive = fmt.charAt(i + 2); if(modifier === '_') modifier = '-'; } else { diff --git a/src/lib/dates.js b/src/lib/dates.js index 503acd6ef48..d73c5bec82c 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -366,16 +366,19 @@ exports.cleanDate = function(v, dflt, calendar) { * d3's vocabulary: * %{n}f where n is the max number of digits of fractional seconds */ -var fracMatch = /%(\d?)f/g; +var fracMatch = /%\d?f/g; function modDateFormat(fmt, x, calendar) { - var fm = fmt.match(fracMatch), - d = new Date(x); - if(fm) { - var digits = Math.min(+fm[1] || 6, 6), - fracSecs = String((x / 1000 % 1) + 2.0000005) - .substr(2, digits).replace(/0+$/, '') || '0'; - fmt = fmt.replace(fracMatch, fracSecs); - } + + fmt = fmt.replace(fracMatch, function(match) { + var digits = Math.min(+(match.charAt(1)) || 6, 6), + fracSecs = ((x / 1000 % 1) + 2) + .toFixed(digits) + .substr(2).replace(/0+$/, '') || '0'; + return fracSecs; + }); + + var d = new Date(Math.floor(x + 0.05)); + if(isWorldCalendar(calendar)) { try { fmt = Registry.getComponentMethod('calendars', 'worldCalFmt')(fmt, x, calendar); @@ -393,15 +396,39 @@ function modDateFormat(fmt, x, calendar) { * tr: tickround ('M', 'S', or # digits) * only supports UTC times (where every day is 24 hours and 0 is at midnight) */ +var MAXSECONDS = [59, 59.9, 59.99, 59.999, 59.9999]; function formatTime(x, tr) { - var timePart = mod(x, ONEDAY); + var timePart = mod(x + 0.05, ONEDAY); var timeStr = lpad(Math.floor(timePart / ONEHOUR), 2) + ':' + lpad(mod(Math.floor(timePart / ONEMIN), 60), 2); if(tr !== 'M') { if(!isNumeric(tr)) tr = 0; // should only be 'S' - timeStr += ':' + String(100 + d3.round(mod(x / ONESEC, 60), tr)).substr(1); + + /* + * this is a weird one - and shouldn't come up unless people + * monkey with tick0 in weird ways, but we need to do something! + * IN PARTICULAR we had better not display garbage (see below) + * for numbers we always round to the nearest increment of the + * precision we're showing, and this seems like the right way to + * handle seconds and milliseconds, as they have a decimal point + * and people will interpret that to mean rounding like numbers. + * but for larger increments we floor the value: it's always + * 2013 until the ball drops on the new year. We could argue about + * which field it is where we start rounding (should 12:08:59 + * round to 12:09 if we're stopping at minutes?) but for now I'll + * say we round seconds but floor everything else. BUT that means + * we need to never round up to 60 seconds, ie 23:59:60 + */ + var sec = Math.min(mod(x / ONESEC, 60), MAXSECONDS[tr]); + + var secStr = (100 + sec).toFixed(tr).substr(1); + if(tr > 0) { + secStr = secStr.replace(/0+$/, '').replace(/[\.]$/, ''); + } + + timeStr += ':' + secStr; } return timeStr; } @@ -459,7 +486,7 @@ exports.formatDate = function(x, fmt, tr, calendar) { catch(e) { return 'Invalid'; } } else { - var d = new Date(x); + var d = new Date(Math.floor(x + 0.05)); if(tr === 'y') dateStr = yearFormat(d); else if(tr === 'm') dateStr = monthFormat(d); diff --git a/test/image/baselines/gl3d_world-cals.png b/test/image/baselines/gl3d_world-cals.png index ffbc6fc552e..2c192891d1d 100644 Binary files a/test/image/baselines/gl3d_world-cals.png and b/test/image/baselines/gl3d_world-cals.png differ diff --git a/test/jasmine/tests/lib_date_test.js b/test/jasmine/tests/lib_date_test.js index 9df1ecfd99a..2c5cd93e615 100644 --- a/test/jasmine/tests/lib_date_test.js +++ b/test/jasmine/tests/lib_date_test.js @@ -468,4 +468,138 @@ describe('dates', function() { }); }); }); + + describe('formatDate', function() { + function assertFormatRounds(ms, calendar, results) { + ['y', 'm', 'd', 'M', 'S', 1, 2, 3, 4].forEach(function(tr, i) { + expect(Lib.formatDate(ms, '', tr, calendar)) + .toBe(results[i], calendar); + }); + } + + it('should pick a format based on tickround if no format is provided', function() { + var ms = Lib.dateTime2ms('2012-08-13 06:19:34.5678'); + assertFormatRounds(ms, 'gregorian', [ + '2012', + 'Aug 2012', + 'Aug 13\n2012', + '06:19\nAug 13, 2012', + '06:19:35\nAug 13, 2012', + '06:19:34.6\nAug 13, 2012', + '06:19:34.57\nAug 13, 2012', + '06:19:34.568\nAug 13, 2012', + '06:19:34.5678\nAug 13, 2012' + ]); + + // and for world calendars - in coptic this is 1728-12-07 (month=Meso) + assertFormatRounds(ms, 'coptic', [ + '1728', + 'Meso 1728', + 'Meso 7\n1728', + '06:19\nMeso 7, 1728', + '06:19:35\nMeso 7, 1728', + '06:19:34.6\nMeso 7, 1728', + '06:19:34.57\nMeso 7, 1728', + '06:19:34.568\nMeso 7, 1728', + '06:19:34.5678\nMeso 7, 1728' + ]); + }); + + it('should accept custom formats using d3 specs even for world cals', function() { + var ms = Lib.dateTime2ms('2012-08-13 06:19:34.5678'); + [ + // some common formats (plotly workspace options) + ['%Y-%m-%d', '2012-08-13', '1728-12-07'], + ['%H:%M:%S', '06:19:34', '06:19:34'], + ['%Y-%m-%e %H:%M:%S', '2012-08-13 06:19:34', '1728-12-7 06:19:34'], + ['%A, %b %e', 'Monday, Aug 13', 'Pesnau, Meso 7'], + + // test padding behavior + // world doesn't support space-padded (yet?) + ['%Y-%_m-%_d', '2012- 8-13', '1728-12-7'], + ['%Y-%-m-%-d', '2012-8-13', '1728-12-7'], + + // and some strange ones to cover all fields + ['%a%j!%-j', 'Mon226!226', 'Pes337!337'], + [ + '%W or un or space padded-> %-W,%_W', + '33 or un or space padded-> 33,33', + '48 or un or space padded-> 48,48' + ], + [ + '%B \'%y WOY:%U DOW:%w', + 'August \'12 WOY:32 DOW:1', + 'Mesori \'28 WOY:## DOW:##' // world-cals doesn't support U or w + ], + [ + '%c && %x && .%2f .%f', // %f is our addition + 'Mon Aug 13 06:19:34 2012 && 08/13/2012 && .57 .5678', + 'Pes Meso 7 06:19:34 1728 && 12/07/1728 && .57 .5678' + ] + + ].forEach(function(v) { + var fmt = v[0], + expectedGregorian = v[1], + expectedCoptic = v[2]; + + // tickround is irrelevant here... + expect(Lib.formatDate(ms, fmt, 'y')) + .toBe(expectedGregorian, fmt); + expect(Lib.formatDate(ms, fmt, 4, 'gregorian')) + .toBe(expectedGregorian, fmt); + expect(Lib.formatDate(ms, fmt, 'y', 'coptic')) + .toBe(expectedCoptic, fmt); + }); + }); + + it('should not round up to 60 seconds', function() { + // see note in dates.js -> formatTime about this rounding + assertFormatRounds(-0.1, 'gregorian', [ + '1969', + 'Dec 1969', + 'Dec 31\n1969', + '23:59\nDec 31, 1969', + '23:59:59\nDec 31, 1969', + '23:59:59.9\nDec 31, 1969', + '23:59:59.99\nDec 31, 1969', + '23:59:59.999\nDec 31, 1969', + '23:59:59.9999\nDec 31, 1969' + ]); + + // in coptic this is Koi 22, 1686 + assertFormatRounds(-0.1, 'coptic', [ + '1686', + 'Koi 1686', + 'Koi 22\n1686', + '23:59\nKoi 22, 1686', + '23:59:59\nKoi 22, 1686', + '23:59:59.9\nKoi 22, 1686', + '23:59:59.99\nKoi 22, 1686', + '23:59:59.999\nKoi 22, 1686', + '23:59:59.9999\nKoi 22, 1686' + ]); + + // and using the custom format machinery + expect(Lib.formatDate(-0.1, '%Y-%m-%d %H:%M:%S.%f')) + .toBe('1969-12-31 23:59:59.9999'); + expect(Lib.formatDate(-0.1, '%Y-%m-%d %H:%M:%S.%f', null, 'coptic')) + .toBe('1686-04-22 23:59:59.9999'); + + }); + + it('should remove extra fractional second zeros', function() { + expect(Lib.formatDate(0.1, '', 4)).toBe('00:00:00.0001\nJan 1, 1970'); + expect(Lib.formatDate(0.1, '', 3)).toBe('00:00:00\nJan 1, 1970'); + expect(Lib.formatDate(0.1, '', 0)).toBe('00:00:00\nJan 1, 1970'); + expect(Lib.formatDate(0.1, '', 'S')).toBe('00:00:00\nJan 1, 1970'); + expect(Lib.formatDate(0.1, '', 3, 'coptic')) + .toBe('00:00:00\nKoi 23, 1686'); + + // because the decimal point is explicitly part of the format + // string here, we can't remove it OR the very first zero after it. + expect(Lib.formatDate(0.1, '%S.%f')).toBe('00.0001'); + expect(Lib.formatDate(0.1, '%S.%3f')).toBe('00.0'); + }); + + }); });