diff --git a/src/components/colorbar/draw.js b/src/components/colorbar/draw.js index a3d1acf6958..a952f78340d 100644 --- a/src/components/colorbar/draw.js +++ b/src/components/colorbar/draw.js @@ -706,6 +706,7 @@ function mockColorBarAxis(gd, opts, zrange) { font: fullLayout.font, noHover: true, noTickson: true, + noTicklabelmode: true, calendar: fullLayout.calendar // not really necessary (yet?) }; diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 8e1aa45eb51..094774715a0 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -620,6 +620,15 @@ axes.calcTicks = function calcTicks(ax, opts) { generateTicks(); + var isPeriod = ax.ticklabelmode === 'period'; + if(isPeriod) { + // add one label to show pre tick0 period + tickVals.unshift({ + minor: false, + value: axes.tickIncrement(tickVals[0].value, ax.dtick, !axrev, ax.caldendar) + }); + } + if(ax.rangebreaks) { // replace ticks inside breaks that would get a tick // and reduce ticks @@ -681,6 +690,32 @@ axes.calcTicks = function calcTicks(ax, opts) { ax._prevDateHead = ''; ax._inCalcTicks = true; + var minRange = Math.min(rng[0], rng[1]); + var maxRange = Math.max(rng[0], rng[1]); + + 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') + ) { + 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; + } + } + + var removedPreTick0Label = false; var ticksOut = new Array(tickVals.length); for(var i = 0; i < tickVals.length; i++) { var _minor = tickVals[i].minor; @@ -692,6 +727,46 @@ axes.calcTicks = function calcTicks(ax, opts) { false, // hover _minor // noSuffixPrefix ); + + if(isPeriod) { + var v = tickVals[i].value; + + var a = i; + var b = i + 1; + if(i < tickVals.length - 1) { + a = i; + b = i + 1; + } else { + a = i - 1; + b = i; + } + + var A = tickVals[a].value; + var B = tickVals[b].value; + + var delta = definedDelta || Math.abs(B - A); + var half = axrev ? -0.5 : 0.5; + if(delta >= ONEDAY * 365) { // Years could have days less than ONEAVGYEAR period + v += half * ONEAVGYEAR; + } else if(delta >= ONEDAY * 28) { // Months could have days less than ONEAVGMONTH period + v += half * ONEAVGMONTH; + } else if(delta >= ONEDAY) { + v += half * ONEDAY; + } + + ticksOut[i].periodX = v; + + if(v > maxRange || v < minRange) { // hide label if outside the range + ticksOut[i].text = ''; + if(i === 0) removedPreTick0Label = true; + } + } + } + + if(removedPreTick0Label && ticksOut.length > 1) { + // redo tick0 text + ax._prevDateHead = ''; + ticksOut[1].text = axes.tickText(ax, tickVals[1].value).text; } ax._inCalcTicks = false; @@ -1785,6 +1860,10 @@ axes.drawOne = function(gd, ax, opts) { if(!ax.visible) return; var transFn = axes.makeTransFn(ax); + var transTickLabelFn = ax.ticklabelmode === 'period' ? + axes.makeTransPeriodFn(ax) : + axes.makeTransFn(ax); + var tickVals; // We remove zero lines, grid lines, and inside ticks if they're within 1px of the end // The key case here is removing zero lines when the axis bound is zero @@ -1903,7 +1982,7 @@ axes.drawOne = function(gd, ax, opts) { return axes.drawLabels(gd, ax, { vals: vals, layer: mainAxLayer, - transFn: transFn, + transFn: transTickLabelFn, labelFns: axes.makeLabelFns(ax, mainLinePosition) }); }); @@ -2213,6 +2292,14 @@ axes.makeTransFn = function(ax) { function(d) { return 'translate(0,' + (offset + ax.l2p(d.x)) + ')'; }; }; +axes.makeTransPeriodFn = function(ax) { + var axLetter = ax._id.charAt(0); + var offset = ax._offset; + return axLetter === 'x' ? + function(d) { return 'translate(' + (offset + ax.l2p(d.periodX)) + ',0)'; } : + function(d) { return 'translate(0,' + (offset + ax.l2p(d.periodX)) + ')'; }; +}; + /** * Make axis tick path string * @@ -2511,6 +2598,7 @@ axes.drawLabels = function(gd, ax, opts) { var axLetter = axId.charAt(0); var cls = opts.cls || axId + 'tick'; var vals = opts.vals; + var labelFns = opts.labelFns; var tickAngle = opts.secondary ? 0 : ax.tickangle; var prevAngle = (ax._prevTickAngles || {})[cls]; diff --git a/src/plots/cartesian/axis_defaults.js b/src/plots/cartesian/axis_defaults.js index c1fbf92f9ea..667ecda5c7f 100644 --- a/src/plots/cartesian/axis_defaults.js +++ b/src/plots/cartesian/axis_defaults.js @@ -126,6 +126,8 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, } if(axType === 'date') { + if(!options.noTicklabelmode) coerce('ticklabelmode'); + handleArrayContainerDefaults(containerIn, containerOut, { name: 'rangebreaks', inclusionAttr: 'enabled', diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 23e6cc2a4ba..3e732dda964 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -478,6 +478,20 @@ module.exports = { 'to the left/bottom of labels.' ].join(' ') }, + ticklabelmode: { + valType: 'enumerated', + values: ['instant', 'period'], + dflt: 'instant', + role: 'info', + editType: 'ticks', + description: [ + 'Determines where tick labels are drawn with respect to their', + 'corresponding ticks and grid lines.', + 'Only has an effect for axes of `type` *date*', + 'When set to *period*, tick labels are drawn in the middle of the period', + 'between ticks.' + ].join(' ') + }, mirror: { valType: 'enumerated', values: [true, 'ticks', false, 'all', 'allticks'], diff --git a/src/plots/gl3d/layout/axis_defaults.js b/src/plots/gl3d/layout/axis_defaults.js index 33beeff8e8b..8bc9d7155ef 100644 --- a/src/plots/gl3d/layout/axis_defaults.js +++ b/src/plots/gl3d/layout/axis_defaults.js @@ -51,6 +51,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, options) { data: options.data, showGrid: true, noTickson: true, + noTicklabelmode: true, bgColor: options.bgColor, calendar: options.calendar }, diff --git a/test/image/baselines/date_axes_period.png b/test/image/baselines/date_axes_period.png new file mode 100644 index 00000000000..c335091cbec Binary files /dev/null and b/test/image/baselines/date_axes_period.png differ diff --git a/test/image/baselines/date_axes_period2.png b/test/image/baselines/date_axes_period2.png new file mode 100644 index 00000000000..3cd2b32722d Binary files /dev/null and b/test/image/baselines/date_axes_period2.png differ diff --git a/test/image/mocks/date_axes_period.json b/test/image/mocks/date_axes_period.json new file mode 100644 index 00000000000..b376aff95ea --- /dev/null +++ b/test/image/mocks/date_axes_period.json @@ -0,0 +1,91 @@ +{ + "data": [ + { + "x": [ + "1900-01-01", + "2000-01-01", + "2100-01-01" + ], + "y": [1, 3, 2] + }, + { + "x": [ + "2013-05-01", + "2013-09-01", + "2014-01-01" + ], + "y": [1, 3, 2], + "xaxis": "x2", + "yaxis": "y2" + }, + { + "x": [ + "2013-11-17", + "2013-12-15", + "2014-01-12" + ], + "y": [1, 3, 2], + "xaxis": "x3", + "yaxis": "y3" + }, + { + "x": [ + "2013-01-01", + "2013-01-02", + "2013-01-03" + ], + "y": [1, 3, 2], + "xaxis": "x4", + "yaxis": "y4" + }, + { + "x": [ + "2013-07-01 18:00", + "2013-07-02 00:00", + "2013-07-02 06:00" + ], + "y": [1, 3, 2], + "xaxis": "x5", + "yaxis": "y5" + }, + { + "x": [ + "2013-01-01 23:59", + "2013-01-02 00:00", + "2013-01-02 00:01" + ], + "y": [1, 3, 2], + "xaxis": "x6", + "yaxis": "y6" + }, + { + "x": [ + "2013-07-01 23:59:59", + "2013-07-02 00:00:00", + "2013-07-02 00:00:01" + ], + "y": [1, 3, 2], + "xaxis": "x7", + "yaxis": "y7" + } + ], + "layout": { + "showlegend": false, + "width": 600, + "height": 500, + "yaxis": {"domain": [0, 0.04]}, + "yaxis2": {"domain": [0.16, 0.2]}, + "yaxis3": {"domain": [0.32, 0.36]}, + "yaxis4": {"domain": [0.48, 0.52]}, + "yaxis5": {"domain": [0.64, 0.68]}, + "yaxis6": {"domain": [0.80, 0.84]}, + "yaxis7": {"domain": [0.96, 1]}, + "xaxis": {"ticklabelmode": "period"}, + "xaxis2": {"ticklabelmode": "period", "anchor": "y2"}, + "xaxis3": {"ticklabelmode": "period", "anchor": "y3"}, + "xaxis4": {"ticklabelmode": "period", "anchor": "y4"}, + "xaxis5": {"ticklabelmode": "period", "anchor": "y5"}, + "xaxis6": {"ticklabelmode": "period", "anchor": "y6"}, + "xaxis7": {"ticklabelmode": "period", "anchor": "y7"} + } +} diff --git a/test/image/mocks/date_axes_period2.json b/test/image/mocks/date_axes_period2.json new file mode 100644 index 00000000000..7bc134ea75e --- /dev/null +++ b/test/image/mocks/date_axes_period2.json @@ -0,0 +1,189 @@ +{ + "data": [ + { + "x": [ + "2017-01-01", + "2023-01-01" + ], + "y": [ + 0, + 1 + ] + }, + { + "x": [ + "2017-01-01", + "2023-01-01" + ], + "y": [ + 0, + 1 + ], + "xaxis": "x2", + "yaxis": "y2" + }, + { + "x": [ + "2017-01-01", + "2023-01-01" + ], + "y": [ + 0, + 1 + ], + "xaxis": "x3", + "yaxis": "y3" + }, + { + "x": [ + "2017-01-01", + "2023-01-01" + ], + "y": [ + 0, + 1 + ], + "xaxis": "x4", + "yaxis": "y4" + }, + { + "x": [ + "2017-01-01", + "2023-01-01" + ], + "y": [ + 0, + 1 + ], + "xaxis": "x5", + "yaxis": "y5" + }, + { + "x": [ + "2017-01-01", + "2023-01-01" + ], + "y": [ + 0, + 1 + ], + "xaxis": "x6", + "yaxis": "y6" + }, + { + "x": [ + "2017-01-01", + "2023-01-01" + ], + "y": [ + 0, + 1 + ], + "xaxis": "x7", + "yaxis": "y7" + } + ], + "layout": { + "showlegend": false, + "width": 600, + "height": 500, + "yaxis": { + "domain": [ + 0, + 0.04 + ] + }, + "yaxis2": { + "domain": [ + 0.16, + 0.2 + ] + }, + "yaxis3": { + "domain": [ + 0.32, + 0.36 + ] + }, + "yaxis4": { + "domain": [ + 0.48, + 0.52 + ] + }, + "yaxis5": { + "domain": [ + 0.64, + 0.68 + ] + }, + "yaxis6": { + "domain": [ + 0.80, + 0.84 + ] + }, + "yaxis7": { + "domain": [ + 0.96, + 1 + ] + }, + "xaxis": { + "range": [ + "2019-12-24", + "2020-01-06" + ], + "ticklabelmode": "period", + "tickformat": "%b %d, %Y" + }, + "xaxis2": { + "range": [ + "2019-12-29", + "2020-01-04" + ], + "ticklabelmode": "period", + "anchor": "y2" + }, + "xaxis3": { + "range": [ + "2020-01-03", + "2019-12-28" + ], + "ticklabelmode": "period", + "anchor": "y3" + }, + "xaxis4": { + "range": [ + "2020-03-01", + "2020-11-01" + ], + "ticklabelmode": "period", + "anchor": "y4" + }, + "xaxis5": { + "range": [ + "2016-09-01", + "2017-06-01" + ], + "ticklabelmode": "period", + "anchor": "y5" + }, + "xaxis6": { + "range": [ + "2016-05-01", + "2019-09-01" + ], + "ticklabelmode": "period", + "anchor": "y6" + }, + "xaxis7": { + "range": [ + "2016-05-01", + "2021-09-01" + ], + "ticklabelmode": "period", + "anchor": "y7" + } + } +} diff --git a/test/jasmine/tests/mock_test.js b/test/jasmine/tests/mock_test.js index eb7abbbfa29..2e9351a2a17 100644 --- a/test/jasmine/tests/mock_test.js +++ b/test/jasmine/tests/mock_test.js @@ -253,6 +253,8 @@ var list = [ 'custom_colorscale', 'custom_size_subplot', 'date_axes', + 'date_axes_period', + 'date_axes_period2', 'date_histogram', 'dendrogram', 'display-text_zero-number', @@ -1296,6 +1298,8 @@ figs['contour-heatmap-coloring-set-contours'] = require('@mocks/contour-heatmap- figs['custom_colorscale'] = require('@mocks/custom_colorscale'); figs['custom_size_subplot'] = require('@mocks/custom_size_subplot'); figs['date_axes'] = require('@mocks/date_axes'); +figs['date_axes_period'] = require('@mocks/date_axes_period'); +figs['date_axes_period2'] = require('@mocks/date_axes_period2'); figs['date_histogram'] = require('@mocks/date_histogram'); // figs['dendrogram'] = require('@mocks/dendrogram'); figs['display-text_zero-number'] = require('@mocks/display-text_zero-number');