diff --git a/src/constants/numerical.js b/src/constants/numerical.js index d44f82ae2ce..8d535a9c2d9 100644 --- a/src/constants/numerical.js +++ b/src/constants/numerical.js @@ -32,7 +32,9 @@ module.exports = { * have the same length */ ONEAVGYEAR: 31557600000, // 365.25 days + ONEAVGQUARTER: 7889400000, // 1/4 of ONEAVGYEAR ONEAVGMONTH: 2629800000, // 1/12 of ONEAVGYEAR + ONEWEEK: 604800000, // 7 * ONEDAY ONEDAY: 86400000, ONEHOUR: 3600000, ONEMIN: 60000, diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index ad17fac233e..929831f8e16 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -24,7 +24,9 @@ var cleanTicks = require('./clean_ticks'); var constants = require('../../constants/numerical'); var ONEAVGYEAR = constants.ONEAVGYEAR; +var ONEAVGQUARTER = constants.ONEAVGQUARTER; var ONEAVGMONTH = constants.ONEAVGMONTH; +var ONEWEEK = constants.ONEWEEK; var ONEDAY = constants.ONEDAY; var ONEHOUR = constants.ONEHOUR; var ONEMIN = constants.ONEMIN; @@ -695,23 +697,51 @@ axes.calcTicks = function calcTicks(ax, opts) { var definedDelta; if(isPeriod && ax.tickformat) { - var _has = function(str) { - return ax.tickformat.indexOf(str) !== -1; - }; - if( - !_has('%f') && - !_has('%H') && - !_has('%I') && - !_has('%L') && - !_has('%Q') && - !_has('%S') && - !_has('%s') && - !_has('%X') + !(/%[fLQsSMHIpX]/.test(ax.tickformat)) + // %f: microseconds as a decimal number [000000, 999999] + // %L: milliseconds as a decimal number [000, 999] + // %Q: milliseconds since UNIX epoch + // %s: seconds since UNIX epoch + // %S: second as a decimal number [00,61] + // %M: minute as a decimal number [00,59] + // %H: hour (24-hour clock) as a decimal number [00,23] + // %I: hour (12-hour clock) as a decimal number [01,12] + // %p: either AM or PM + // %X: the locale’s time, such as %-I:%M:%S %p ) { - if(_has('%x') || _has('%d') || _has('%e') || _has('%j')) definedDelta = ONEDAY; - else if(_has('%B') || _has('%b') || _has('%m')) definedDelta = ONEAVGMONTH; - else if(_has('%Y') || _has('%y')) definedDelta = ONEAVGYEAR; + if( + /%[Aadejuwx]/.test(ax.tickformat) + // %A: full weekday name + // %a: abbreviated weekday name + // %d: zero-padded day of the month as a decimal number [01,31] + // %e: space-padded day of the month as a decimal number [ 1,31] + // %j: day of the year as a decimal number [001,366] + // %u: Monday-based (ISO 8601) weekday as a decimal number [1,7] + // %w: Sunday-based weekday as a decimal number [0,6] + // %x: the locale’s date, such as %-m/%-d/%Y + ) definedDelta = ONEDAY; + else if( + /%[UVW]/.test(ax.tickformat) + // %U: Sunday-based week of the year as a decimal number [00,53] + // %V: ISO 8601 week of the year as a decimal number [01, 53] + // %W: Monday-based week of the year as a decimal number [00,53] + ) definedDelta = ONEWEEK; + else if( + /%[Bbm]/.test(ax.tickformat) + // %B: full month name + // %b: abbreviated month name + // %m: month as a decimal number [01,12] + ) definedDelta = ONEAVGMONTH; + else if( + /%[q]/.test(ax.tickformat) + // %q: quarter of the year as a decimal number [1,4] + ) definedDelta = ONEAVGQUARTER; + else if( + /%[Yy]/.test(ax.tickformat) + // %Y: year with century as a decimal number, such as 1999 + // %y: year without century as a decimal number [00,99] + ) definedDelta = ONEAVGYEAR; } } @@ -748,8 +778,12 @@ axes.calcTicks = function calcTicks(ax, opts) { var delta = definedDelta || Math.abs(B - A); if(delta >= ONEDAY * 365) { // Years could have days less than ONEAVGYEAR period v += ONEAVGYEAR / 2; + } else if(delta >= ONEAVGQUARTER) { + v += ONEAVGQUARTER / 2; } else if(delta >= ONEDAY * 28) { // Months could have days less than ONEAVGMONTH period v += ONEAVGMONTH / 2; + } else if(delta >= ONEWEEK) { + v += ONEWEEK / 2; } else if(delta >= ONEDAY) { v += ONEDAY / 2; } @@ -764,7 +798,7 @@ axes.calcTicks = function calcTicks(ax, opts) { } if(removedPreTick0Label) { - for(i = 1; i < ticksOut.length; i++) { + for(i = 0; i < ticksOut.length; i++) { if(ticksOut[i].periodX <= maxRange && ticksOut[i].periodX >= minRange) { // redo first visible tick ax._prevDateHead = ''; @@ -882,6 +916,13 @@ axes.autoTicks = function(ax, roughDTick) { // this will also move the base tick off 2000-01-01 if dtick is // 2 or 3 days... but that's a weird enough case that we'll ignore it. ax.tick0 = Lib.dateTick0(ax.calendar, true); + + if(/%[uVW]/.test(ax.tickformat)) { + // replace Sunday with Monday for ISO and Monday-based formats + var len = ax.tick0.length; + var lastD = +ax.tick0[len - 1]; + ax.tick0 = ax.tick0.substring(0, len - 2) + String(lastD + 1); + } } else if(roughX2 > ONEHOUR) { ax.dtick = roundDTick(roughDTick, ONEHOUR, roundBase24); } else if(roughX2 > ONEMIN) { diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index 7dc1a272114..1100cef9340 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -23,6 +23,7 @@ var numConstants = require('../../constants/numerical'); var FP_SAFE = numConstants.FP_SAFE; var BADNUM = numConstants.BADNUM; var LOG_CLIP = numConstants.LOG_CLIP; +var ONEWEEK = numConstants.ONEWEEK; var ONEDAY = numConstants.ONEDAY; var ONEHOUR = numConstants.ONEHOUR; var ONEMIN = numConstants.ONEMIN; @@ -734,7 +735,7 @@ module.exports = function setConvert(ax, fullLayout) { switch(brk.pattern) { case WEEKDAY_PATTERN: - step = 7 * ONEDAY; + step = ONEWEEK; bndDelta = ( (b1 < b0 ? 7 : 0) + diff --git a/test/image/baselines/date_axes_period.png b/test/image/baselines/date_axes_period.png index c335091cbec..991ce21014b 100644 Binary files a/test/image/baselines/date_axes_period.png and b/test/image/baselines/date_axes_period.png differ diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index f9c7b2a347c..918a8e96f84 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -5196,6 +5196,278 @@ describe('Test axes', function() { }); }); }); + + describe('label positioning using *ticklabelmode*: "period"', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function _assert(msg, expPositions, expLabels) { + var ax = gd._fullLayout.xaxis; + + var positions = ax._vals.map(function(d) { return ax.c2d(d.periodX); }); + expect(positions).withContext(msg).toEqual(expPositions); + + var labels = ax._vals.map(function(d) { return d.text; }); + expect(labels).withContext(msg).toEqual(expLabels); + } + + ['%Y', '%y'].forEach(function(tickformat, i) { + it('should respect yearly tickformat that includes ' + tickformat, function(done) { + Plotly.newPlot(gd, { + data: [{ + x: ['2020-01-01', '2026-01-01'] + }], + layout: { + width: 1000, + xaxis: { + ticklabelmode: 'period', + tickformat: tickformat + } + } + }) + .then(function() { + _assert('', [ + '2019-07-02 15:00', + '2020-07-01 15:00', + '2021-07-02 15:00', + '2022-07-02 15:00', + '2023-07-02 15:00', + '2024-07-01 15:00', + '2025-07-02 15:00', + '2026-07-02 15:00' + ], [ + ['', '2020', '2021', '2022', '2023', '2024', '2025', ''], + ['', '20', '21', '22', '23', '24', '25', ''] + ][i]); + }) + .catch(failTest) + .then(done); + }); + }); + + it('should respect quarters tickformat that includes %q', function(done) { + Plotly.newPlot(gd, { + data: [{ + x: ['2020-01-01', '2022-01-01'] + }], + layout: { + width: 1000, + xaxis: { + ticklabelmode: 'period', + tickformat: '%Y-%q' + } + } + }) + .then(function() { + _assert('', [ + '2019-11-15 15:45', + '2020-02-15 15:45', + '2020-05-16 15:45', + '2020-08-15 15:45', + '2020-11-15 15:45', + '2021-02-15 15:45', + '2021-05-16 15:45', + '2021-08-15 15:45', + '2021-11-15 15:45', + '2022-02-15 15:45' + ], ['', '2020-1', '2020-2', '2020-3', '2020-4', '2021-1', '2021-2', '2021-3', '2021-4', '']); + }) + .catch(failTest) + .then(done); + }); + + ['%B', '%b', '%m'].forEach(function(tickformat, i) { + it('should respect monthly tickformat that includes ' + tickformat, function(done) { + Plotly.newPlot(gd, { + data: [{ + x: ['2020-01-01', '2020-07-01'] + }], + layout: { + width: 1000, + xaxis: { + ticklabelmode: 'period', + tickformat: '%q-' + tickformat + } + } + }) + .then(function() { + _assert('', [ + '2019-12-16 05:15', + '2020-01-16 05:15', + '2020-02-16 05:15', + '2020-03-16 05:15', + '2020-04-16 05:15', + '2020-05-16 05:15', + '2020-06-16 05:15', + '2020-07-16 05:15' + ], [ + ['', '1-January', '1-February', '1-March', '2-April', '2-May', '2-June', ''], + ['', '1-Jan', '1-Feb', '1-Mar', '2-Apr', '2-May', '2-Jun', ''], + ['', '1-01', '1-02', '1-03', '2-04', '2-05', '2-06', ''] + ][i]); + }) + .catch(failTest) + .then(done); + }); + }); + + it('should respect Sunday-based week tickformat that includes %U', function(done) { + Plotly.newPlot(gd, { + data: [{ + x: ['2020-02-01', '2020-04-01'] + }], + layout: { + width: 1000, + xaxis: { + ticklabelmode: 'period', + tickformat: '%b-%U' + } + } + }) + .then(function() { + _assert('', [ + '2020-01-29 12:00', + '2020-02-05 12:00', + '2020-02-12 12:00', + '2020-02-19 12:00', + '2020-02-26 12:00', + '2020-03-04 12:00', + '2020-03-11 12:00', + '2020-03-18 12:00', + '2020-03-25 12:00', + '2020-04-01 12:00' + ], ['Jan-04', 'Feb-05', 'Feb-06', 'Feb-07', 'Feb-08', 'Mar-09', 'Mar-10', 'Mar-11', 'Mar-12', 'Mar-13']); + }) + .catch(failTest) + .then(done); + }); + + ['%V', '%W'].forEach(function(tickformat, i) { + it('should respect Monday-based week tickformat that includes ' + tickformat, function(done) { + Plotly.newPlot(gd, { + data: [{ + x: ['2020-02-01', '2020-04-01'] + }], + layout: { + width: 1000, + xaxis: { + ticklabelmode: 'period', + tickformat: '%b-' + tickformat + } + } + }) + .then(function() { + _assert('', [ + '2020-01-30 12:00', + '2020-02-06 12:00', + '2020-02-13 12:00', + '2020-02-20 12:00', + '2020-02-27 12:00', + '2020-03-05 12:00', + '2020-03-12 12:00', + '2020-03-19 12:00', + '2020-03-26 12:00', + '2020-04-02 12:00' + ], [ + ['Jan-05', 'Feb-06', 'Feb-07', 'Feb-08', 'Feb-09', 'Mar-10', 'Mar-11', 'Mar-12', 'Mar-13', 'Mar-14'], + ['Jan-04', 'Feb-05', 'Feb-06', 'Feb-07', 'Feb-08', 'Mar-09', 'Mar-10', 'Mar-11', 'Mar-12', 'Mar-13'] + ][i]); + }) + .catch(failTest) + .then(done); + }); + }); + + ['%A', '%a', '%d', '%e', '%j', '%u', '%w', '%x'].forEach(function(tickformat, i) { + it('should respect daily tickformat that includes ' + tickformat, function(done) { + Plotly.newPlot(gd, { + data: [{ + x: ['2020-01-01', '2020-01-08'] + }], + layout: { + width: 1000, + xaxis: { + ticklabelmode: 'period', + tickformat: '%b-' + tickformat + } + } + }) + .then(function() { + _assert('', [ + '2019-12-31 12:00', + '2020-01-01 12:00', + '2020-01-02 12:00', + '2020-01-03 12:00', + '2020-01-04 12:00', + '2020-01-05 12:00', + '2020-01-06 12:00', + '2020-01-07 12:00', + '2020-01-08 12:00' + ], [ + ['', 'Jan-Wednesday', 'Jan-Thursday', 'Jan-Friday', 'Jan-Saturday', 'Jan-Sunday', 'Jan-Monday', 'Jan-Tuesday', ''], + ['', 'Jan-Wed', 'Jan-Thu', 'Jan-Fri', 'Jan-Sat', 'Jan-Sun', 'Jan-Mon', 'Jan-Tue', ''], + ['', 'Jan-01', 'Jan-02', 'Jan-03', 'Jan-04', 'Jan-05', 'Jan-06', 'Jan-07', ''], + ['', 'Jan- 1', 'Jan- 2', 'Jan- 3', 'Jan- 4', 'Jan- 5', 'Jan- 6', 'Jan- 7', ''], + ['', 'Jan-001', 'Jan-002', 'Jan-003', 'Jan-004', 'Jan-005', 'Jan-006', 'Jan-007', ''], + ['', 'Jan-3', 'Jan-4', 'Jan-5', 'Jan-6', 'Jan-7', 'Jan-1', 'Jan-2', ''], + ['', 'Jan-3', 'Jan-4', 'Jan-5', 'Jan-6', 'Jan-0', 'Jan-1', 'Jan-2', ''], + ['', 'Jan-01/01/2020', 'Jan-01/02/2020', 'Jan-01/03/2020', 'Jan-01/04/2020', 'Jan-01/05/2020', 'Jan-01/06/2020', 'Jan-01/07/2020', ''] + ][i]); + }) + .catch(failTest) + .then(done); + }); + }); + + ['%f', '%L', '%Q', '%s', '%S', '%M', '%H', '%I', '%p', '%X'].forEach(function(tickformat, i) { + it('should respect daily tickformat that includes ' + tickformat, function(done) { + Plotly.newPlot(gd, { + data: [{ + x: ['2020-01-01', '2020-01-02'] + }], + layout: { + width: 1000, + xaxis: { + ticklabelmode: 'period', + tickformat: '%a-' + tickformat + } + } + }) + .then(function() { + _assert('', [ + '2019-12-31 21:00', + '2020-01-01', + '2020-01-01 03:00', + '2020-01-01 06:00', + '2020-01-01 09:00', + '2020-01-01 12:00', + '2020-01-01 15:00', + '2020-01-01 18:00', + '2020-01-01 21:00', + '2020-01-02' + ], [ + ['', 'Wed-0', 'Wed-0', 'Wed-0', 'Wed-0', 'Wed-0', 'Wed-0', 'Wed-0', 'Wed-0', 'Thu-0'], + ['', 'Wed-000', 'Wed-000', 'Wed-000', 'Wed-000', 'Wed-000', 'Wed-000', 'Wed-000', 'Wed-000', 'Thu-000'], + ['', 'Wed-1577836800000', 'Wed-1577847600000', 'Wed-1577858400000', 'Wed-1577869200000', 'Wed-1577880000000', 'Wed-1577890800000', 'Wed-1577901600000', 'Wed-1577912400000', 'Thu-1577923200000'], + ['', 'Wed-1577836800', 'Wed-1577847600', 'Wed-1577858400', 'Wed-1577869200', 'Wed-1577880000', 'Wed-1577890800', 'Wed-1577901600', 'Wed-1577912400', 'Thu-1577923200'], + ['', 'Wed-00', 'Wed-00', 'Wed-00', 'Wed-00', 'Wed-00', 'Wed-00', 'Wed-00', 'Wed-00', 'Thu-00'], + ['', 'Wed-00', 'Wed-00', 'Wed-00', 'Wed-00', 'Wed-00', 'Wed-00', 'Wed-00', 'Wed-00', 'Thu-00'], + ['', 'Wed-00', 'Wed-03', 'Wed-06', 'Wed-09', 'Wed-12', 'Wed-15', 'Wed-18', 'Wed-21', 'Thu-00'], + ['', 'Wed-12', 'Wed-03', 'Wed-06', 'Wed-09', 'Wed-12', 'Wed-03', 'Wed-06', 'Wed-09', 'Thu-12'], + ['', 'Wed-AM', 'Wed-AM', 'Wed-AM', 'Wed-AM', 'Wed-PM', 'Wed-PM', 'Wed-PM', 'Wed-PM', 'Thu-AM'], + ['', 'Wed-00:00:00', 'Wed-03:00:00', 'Wed-06:00:00', 'Wed-09:00:00', 'Wed-12:00:00', 'Wed-15:00:00', 'Wed-18:00:00', 'Wed-21:00:00', 'Thu-00:00:00'] + ][i]); + }) + .catch(failTest) + .then(done); + }); + }); + }); }); function getZoomInButton(gd) {