diff --git a/src/components/colorbar/draw.js b/src/components/colorbar/draw.js index a87982e3628..77b90f8c8c2 100644 --- a/src/components/colorbar/draw.js +++ b/src/components/colorbar/draw.js @@ -198,6 +198,7 @@ module.exports = function draw(gd, id) { letter: 'y', font: fullLayout.font, noHover: true, + noTickson: true, calendar: fullLayout.calendar // not really necessary (yet?) }; diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 3f1464aa9fb..e827667aa72 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -1163,6 +1163,20 @@ function formatCategory(ax, out) { var tt = ax._categories[Math.round(out.x)]; if(tt === undefined) tt = ''; out.text = String(tt); + + // Setup ticks and grid lines boundaries + // at 1/2 a 'category' to the left/bottom + if(ax.tickson === 'boundaries') { + var inbounds = function(v) { + var p = ax.l2p(v); + return p >= 0 && p <= ax._length ? v : null; + }; + + out.xbnd = [ + inbounds(out.x - 0.5), + inbounds(out.x + ax.dtick - 0.5) + ]; + } } function formatLinear(ax, out, hover, extraPrecision, hideexp) { @@ -1610,14 +1624,41 @@ axes.drawOne = function(gd, ax, opts) { var subplotsWithAx = axes.getSubplots(gd, ax); var vals = ax._vals = axes.calcTicks(ax); - // 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 - var valsClipped = ax._valsClipped = axes.clipEnds(ax, vals); if(!ax.visible) return; var transFn = axes.makeTransFn(ax); + // 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 + var valsClipped; + var tickVals; + var gridVals; + + if(ax.tickson === 'boundaries' && vals.length) { + // valsBoundaries is not used for labels; + // no need to worry about the other tickTextObj keys + var valsBoundaries = []; + var _push = function(d, bndIndex) { + var xb = d.xbnd[bndIndex]; + if(xb !== null) { + valsBoundaries.push(Lib.extendFlat({}, d, {x: xb})); + } + }; + for(i = 0; i < vals.length; i++) _push(vals[i], 0); + _push(vals[i - 1], 1); + + valsClipped = axes.clipEnds(ax, valsBoundaries); + tickVals = ax.ticks === 'inside' ? valsClipped : valsBoundaries; + gridVals = valsClipped; + } else { + valsClipped = axes.clipEnds(ax, vals); + tickVals = ax.ticks === 'inside' ? valsClipped : vals; + gridVals = valsClipped; + } + + ax._valsClipped = valsClipped; + if(!fullLayout._hasOnlyLargeSploms) { // keep track of which subplots (by main conteraxis) we've already // drawn grids for, so we don't overdraw overlaying subplots @@ -1637,7 +1678,7 @@ axes.drawOne = function(gd, ax, opts) { 'M' + counterAxis._offset + ',0h' + counterAxis._length; axes.drawGrid(gd, ax, { - vals: valsClipped, + vals: gridVals, layer: plotinfo.gridlayer.select('.' + axId), path: gridPath, transFn: transFn @@ -1652,7 +1693,6 @@ axes.drawOne = function(gd, ax, opts) { } var tickSigns = axes.getTickSigns(ax); - var tickVals = ax.ticks === 'inside' ? valsClipped : vals; var tickSubplots = []; if(ax.ticks) { @@ -1920,8 +1960,9 @@ axes.makeTickPath = function(ax, shift, sgn) { axes.makeLabelFns = function(ax, shift, angle) { var axLetter = ax._id.charAt(0); var pad = (ax.linewidth || 1) / 2; + var ticksOnOutsideLabels = ax.tickson !== 'boundaries' && ax.ticks === 'outside'; - var labelStandoff = ax.ticks === 'outside' ? ax.ticklen : 0; + var labelStandoff = ticksOnOutsideLabels ? ax.ticklen : 0; var labelShift = 0; if(angle && ax.ticks === 'outside') { @@ -1930,7 +1971,7 @@ axes.makeLabelFns = function(ax, shift, angle) { labelShift = ax.ticklen * Math.sin(rad); } - if(ax.showticklabels && (ax.ticks === 'outside' || ax.showline)) { + if(ax.showticklabels && (ticksOnOutsideLabels || ax.showline)) { labelStandoff += 0.2 * ax.tickfont.size; } @@ -2018,7 +2059,6 @@ axes.drawTicks = function(gd, ax, opts) { ticks.attr('transform', opts.transFn); }; - /** * Draw axis grid * @@ -2151,8 +2191,6 @@ axes.drawLabels = function(gd, ax, opts) { var tickLabels = opts.layer.selectAll('g.' + cls) .data(ax.showticklabels ? vals : [], makeDataFn(ax)); - var maxFontSize = 0; - var autoangle = 0; var labelsReady = []; tickLabels.enter().append('g') @@ -2187,10 +2225,6 @@ axes.drawLabels = function(gd, ax, opts) { tickLabels.exit().remove(); - tickLabels.each(function(d) { - maxFontSize = Math.max(maxFontSize, d.fontSize); - }); - ax._tickLabels = tickLabels; // TODO ?? @@ -2273,16 +2307,20 @@ axes.drawLabels = function(gd, ax, opts) { // check for auto-angling if x labels overlap // don't auto-angle at all for log axes with // base and digit format - if(axLetter === 'x' && !isNumeric(ax.tickangle) && + if(vals.length && axLetter === 'x' && !isNumeric(ax.tickangle) && (ax.type !== 'log' || String(ax.dtick).charAt(0) !== 'D') ) { + var maxFontSize = 0; var lbbArray = []; + var i; tickLabels.each(function(d) { var s = d3.select(this); var thisLabel = s.select('.text-math-group'); if(thisLabel.empty()) thisLabel = s.select('text'); + maxFontSize = Math.max(maxFontSize, d.fontSize); + var x = ax.l2p(d.x); var bb = Drawing.bBox(thisLabel.node()); @@ -2298,21 +2336,38 @@ axes.drawLabels = function(gd, ax, opts) { }); }); - for(var i = 0; i < lbbArray.length - 1; i++) { - if(Lib.bBoxIntersect(lbbArray[i], lbbArray[i + 1])) { - // any overlap at all - set 30 degrees - autoangle = 30; - break; + var autoangle = 0; + + if(ax.tickson === 'boundaries') { + var gap = 2; + if(ax.ticks) gap += ax.tickwidth / 2; + + for(i = 0; i < lbbArray.length; i++) { + var xbnd = vals[i].xbnd; + var lbb = lbbArray[i]; + if( + (xbnd[0] !== null && (lbb.left - ax.l2p(xbnd[0])) < gap) || + (xbnd[1] !== null && (ax.l2p(xbnd[1]) - lbb.right) < gap) + ) { + autoangle = 90; + break; + } + } + } else { + var vLen = vals.length; + var tickSpacing = Math.abs((vals[vLen - 1].x - vals[0].x) * ax._m) / (vLen - 1); + var fitBetweenTicks = tickSpacing < maxFontSize * 2.5; + + // any overlap at all - set 30 degrees or 90 degrees + for(i = 0; i < lbbArray.length - 1; i++) { + if(Lib.bBoxIntersect(lbbArray[i], lbbArray[i + 1])) { + autoangle = fitBetweenTicks ? 90 : 30; + break; + } } } if(autoangle) { - var tickspacing = Math.abs( - (vals[vals.length - 1].x - vals[0].x) * ax._m - ) / (vals.length - 1); - if(tickspacing < maxFontSize * 2.5) { - autoangle = 90; - } positionLabels(tickLabels, autoangle); } ax._lastangle = autoangle; diff --git a/src/plots/cartesian/axis_defaults.js b/src/plots/cartesian/axis_defaults.js index f3691bf8003..ac11666026b 100644 --- a/src/plots/cartesian/axis_defaults.js +++ b/src/plots/cartesian/axis_defaults.js @@ -28,6 +28,7 @@ var setConvert = require('./set_convert'); * outerTicks: boolean, should ticks default to outside? * showGrid: boolean, should gridlines be shown by default? * noHover: boolean, this axis doesn't support hover effects? + * noTickson: boolean, this axis doesn't support 'tickson' * data: the plot data, used to manage categories * bgColor: the plot background color, to calculate default gridline colors */ @@ -89,5 +90,10 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, if(options.automargin) coerce('automargin'); + if(!options.noTickson && + containerOut.type === 'category' && (containerOut.ticks || containerOut.showgrid)) { + coerce('tickson'); + } + return containerOut; }; diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 404f023eb71..1e44476b28e 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -304,6 +304,20 @@ module.exports = { 'the axis lines.' ].join(' ') }, + tickson: { + valType: 'enumerated', + values: ['labels', 'boundaries'], + role: 'info', + dflt: 'labels', + editType: 'ticks', + description: [ + 'Determines where ticks and grid lines are drawn with respect to their', + 'corresponding tick labels.', + 'Only has an effect for axes of `type` *category*.', + 'When set to *boundaries*, ticks and grid lines are drawn half a category', + 'to the left/bottom of labels.' + ].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 e28b1a74de4..4a9b7a9c844 100644 --- a/src/plots/gl3d/layout/axis_defaults.js +++ b/src/plots/gl3d/layout/axis_defaults.js @@ -50,6 +50,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, options) { letter: axName[0], data: options.data, showGrid: true, + noTickson: true, bgColor: options.bgColor, calendar: options.calendar }, diff --git a/test/image/baselines/tickson_boundaries.png b/test/image/baselines/tickson_boundaries.png new file mode 100644 index 00000000000..242ee575230 Binary files /dev/null and b/test/image/baselines/tickson_boundaries.png differ diff --git a/test/image/mocks/tickson_boundaries.json b/test/image/mocks/tickson_boundaries.json new file mode 100644 index 00000000000..d9cce6bf2b6 --- /dev/null +++ b/test/image/mocks/tickson_boundaries.json @@ -0,0 +1,104 @@ +{ + "data": [ + { + "type": "box", + "x": ["day 1", "day 1", "day 1", "day 1", "day 1", "day 1", "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"], + "y": [0.2, 0.2, 0.6, 1, 0.5, 0.4, 0.2, 0.7, 0.9, 0.1, 0.5, 0.3] + }, + { + "type": "box", + "x": ["day 1", "day 1", "day 1", "day 1", "day 1", "day 1", "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"], + "y": [0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5] + }, + { + "type": "box", + "x": ["day 1", "day 1", "day 1", "day 1", "day 1", "day 1", "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"], + "y": [0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2] + }, + + { + "type": "bar", + "x": [1, 2, 1], + "y": ["apples", "bananas", "clementines"], + "orientation": "h", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "bar", + "x": [1.3, 2.2, 0.8], + "y": ["apples", "bananas", "clementines"], + "orientation": "h", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "bar", + "x": [3, 3.2, 1.8], + "y": ["apples", "bananas", "clementines"], + "orientation": "h", + "xaxis": "x2", + "yaxis": "y2" + }, + + { + "type": "bar", + "name": "with dtick !== 1", + "x": ["a", "b", "c", "d", "e", "f", "g", "h"], + "y": [1, 2, 1, 2, 1, 3, 4, 1], + "xaxis": "x3", + "yaxis": "y3" + }, + + { + "mode": "markers", + "marker": {"symbol": "square"}, + "name": "with overlapping tick labels", + "x": ["A very long title", "short", "Another very long title"], + "y": [1, 4, 2], + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "boxmode": "group", + "grid": { + "rows": 4, + "columns": 1, + "pattern": "independent", + "ygap": 0.2 + }, + "xaxis": { + "ticks": "outside", + "tickson": "boundaries", + "gridcolor": "white", + "gridwidth": 4 + }, + "yaxis2": { + "ticks": "inside", + "tickson": "boundaries", + "gridcolor": "white", + "gridwidth": 4 + }, + "xaxis3": { + "ticks": "inside", + "tickson": "boundaries", + "gridcolor": "white", + "gridwidth": 4, + "dtick": 2 + }, + "xaxis4": { + "domain": [0.22, 0.78], + "ticks": "outside", + "ticklen": 20, + "tickson": "boundaries", + "gridcolor": "white", + "gridwidth": 4 + }, + "plot_bgcolor": "lightgrey", + "showlegend": false, + "width": 500, + "height": 800, + "margin": {"b": 140} + } +} diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 595b4012fb3..3097f4d4c3e 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -3099,6 +3099,131 @@ describe('Test axes', function() { .then(done); }); }); + + describe('*tickson*:', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should respond to relayout', function(done) { + function getPositions(query) { + var pos = []; + d3.selectAll(query).each(function() { + pos.push(this.getBoundingClientRect().x); + }); + return pos; + } + + function _assert(msg, exp) { + var ticks = getPositions('path.xtick'); + var gridLines = getPositions('path.xgrid'); + var tickLabels = getPositions('.xtick > text'); + + expect(ticks).toBeCloseToArray(exp.ticks, 1, msg + '- ticks'); + expect(gridLines).toBeCloseToArray(exp.gridLines, 1, msg + '- grid lines'); + expect(tickLabels.length).toBe(exp.tickLabels.length, msg + '- # of tick labels'); + tickLabels.forEach(function(tl, i) { + expect(tl).toBeWithin(exp.tickLabels[i], 2, msg + '- tick label ' + i); + }); + } + + Plotly.plot(gd, [{ + x: ['a', 'b', 'c'], + y: [1, 2, 1] + }], { + xaxis: { + ticks: 'inside', + showgrid: true + } + }) + .then(function() { + _assert('on labels (defaults)', { + ticks: [110.75, 350, 589.25], + gridLines: [110.75, 350, 589.25], + tickLabels: [106.421, 345.671, 585.25] + }); + return Plotly.relayout(gd, 'xaxis.tickson', 'boundaries'); + }) + .then(function() { + _assert('inside on boundaries', { + ticks: [230.369, 469.619], // N.B. first and last tick are clipped + gridLines: [230.369, 469.619], + tickLabels: [106.421875, 345.671875, 585.25] + }); + return Plotly.relayout(gd, 'xaxis.ticks', 'outside'); + }) + .then(function() { + _assert('outside on boundaries', { + ticks: [230.369, 469.619], + gridLines: [230.369, 469.619], + tickLabels: [106.421875, 345.671875, 585.25] + }); + return Plotly.restyle(gd, 'x', [[1, 2, 1]]); + }) + .then(function() { + _assert('fallback to *labels* on non-category axes', { + ticks: [110.75, 206.449, 302.149, 397.85, 493.549, 589.25], + gridLines: [110.75, 206.449, 302.149, 397.85, 493.549, 589.25], + tickLabels: [106.421, 197.121, 292.821, 388.521, 484.221, 584.921] + }); + }) + .catch(failTest) + .then(done); + }); + + it('should rotate labels to avoid overlaps', function(done) { + function _assert(msg, exp) { + var tickLabels = d3.selectAll('.xtick > text'); + + expect(tickLabels.size()).toBe(exp.angle.length, msg + ' - # of tick labels'); + + tickLabels.each(function(_, i) { + var t = d3.select(this).attr('transform'); + var rotate = (t.split('rotate(')[1] || '').split(')')[0]; + var angle = rotate.split(',')[0]; + expect(Number(angle)).toBe(exp.angle[i], msg + ' - node ' + i); + }); + } + + Plotly.plot(gd, [{ + x: ['A very long title', 'short', 'Another very long title'], + y: [1, 4, 2] + }], { + xaxis: { + domain: [0.22, 0.78], + tickson: 'boundaries', + ticks: 'outside' + }, + width: 500, + height: 500 + }) + .then(function() { + _assert('base - rotated', { + angle: [90, 90, 90] + }); + + return Plotly.relayout(gd, 'xaxis.range', [-0.5, 1.5]); + }) + .then(function() { + _assert('narrower range - unrotated', { + angle: [0, 0] + }); + + return Plotly.relayout(gd, 'xaxis.tickwidth', 10); + }) + .then(function() { + _assert('narrow range / wide ticks - rotated', { + angle: [90, 90] + }); + }) + .catch(failTest) + .then(done); + }); + }); }); function getZoomInButton(gd) {