diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 9c266949bc8..1e0fee8609e 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -1984,6 +1984,30 @@ axes.drawOne = function(gd, ax, opts) { if(ax.title.text !== fullLayout._dfltTitle[axLetter]) { push[s] += ax.title.font.size; } + + if(axLetter === 'x' && bbox.width > 0) { + var rExtra = bbox.right - (ax._offset + ax._length); + if(rExtra > 0) { + push.x = 1; + push.r = rExtra; + } + var lExtra = ax._offset - bbox.left; + if(lExtra > 0) { + push.x = 0; + push.l = lExtra; + } + } else if(axLetter === 'y' && bbox.height > 0) { + var bExtra = bbox.bottom - (ax._offset + ax._length); + if(bExtra > 0) { + push.y = 0; + push.b = bExtra; + } + var tExtra = ax._offset - bbox.top; + if(tExtra > 0) { + push.y = 1; + push.t = tExtra; + } + } } Plots.autoMargin(gd, axAutoMarginID(ax), push); diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index 941d01de3e7..bc99687a750 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -484,15 +484,14 @@ module.exports = function setConvert(ax, fullLayout) { ax._length = gs.h * (ax.domain[1] - ax.domain[0]); ax._m = ax._length / (rl0 - rl1); ax._b = -ax._m * rl1; - } - else { + } else { ax._offset = gs.l + ax.domain[0] * gs.w; ax._length = gs.w * (ax.domain[1] - ax.domain[0]); ax._m = ax._length / (rl1 - rl0); ax._b = -ax._m * rl0; } - if(!isFinite(ax._m) || !isFinite(ax._b)) { + if(!isFinite(ax._m) || !isFinite(ax._b) || ax._length < 0) { fullLayout._replotting = false; throw new Error('Something went wrong with axis scaling'); } diff --git a/src/plots/plots.js b/src/plots/plots.js index cde651366e8..4028498c4ee 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1841,6 +1841,8 @@ plots.doAutoMargin = function(gd) { var mr = margin.r; var mt = margin.t; var mb = margin.b; + var width = fullLayout.width; + var height = fullLayout.height; var pushMargin = fullLayout._pushmargin; var pushMarginIds = fullLayout._pushmarginIds; @@ -1876,13 +1878,11 @@ plots.doAutoMargin = function(gd) { var pr = pushMargin[k2].r.size; if(fr > fl) { - var newl = (pl * fr + - (pr - fullLayout.width) * fl) / (fr - fl); - var newr = (pr * (1 - fl) + - (pl - fullLayout.width) * (1 - fr)) / (fr - fl); - if(newl >= 0 && newr >= 0 && newl + newr > ml + mr) { - ml = newl; - mr = newr; + var newL = (pl * fr + (pr - width) * fl) / (fr - fl); + var newR = (pr * (1 - fl) + (pl - width) * (1 - fr)) / (fr - fl); + if(newL >= 0 && newR >= 0 && width - (newL + newR) > 0 && newL + newR > ml + mr) { + ml = newL; + mr = newR; } } } @@ -1892,13 +1892,11 @@ plots.doAutoMargin = function(gd) { var pt = pushMargin[k2].t.size; if(ft > fb) { - var newb = (pb * ft + - (pt - fullLayout.height) * fb) / (ft - fb); - var newt = (pt * (1 - fb) + - (pb - fullLayout.height) * (1 - ft)) / (ft - fb); - if(newb >= 0 && newt >= 0 && newb + newt > mb + mt) { - mb = newb; - mt = newt; + var newB = (pb * ft + (pt - height) * fb) / (ft - fb); + var newT = (pt * (1 - fb) + (pb - height) * (1 - ft)) / (ft - fb); + if(newB >= 0 && newT >= 0 && height - (newT + newB) > 0 && newB + newT > mb + mt) { + mb = newB; + mt = newT; } } } @@ -1911,8 +1909,8 @@ plots.doAutoMargin = function(gd) { gs.t = Math.round(mt); gs.b = Math.round(mb); gs.p = Math.round(margin.pad); - gs.w = Math.round(fullLayout.width) - gs.l - gs.r; - gs.h = Math.round(fullLayout.height) - gs.t - gs.b; + gs.w = Math.round(width) - gs.l - gs.r; + gs.h = Math.round(height) - gs.t - gs.b; // if things changed and we're not already redrawing, trigger a redraw if(!fullLayout._replotting && diff --git a/test/image/baselines/automargin-push-x-extra.png b/test/image/baselines/automargin-push-x-extra.png new file mode 100644 index 00000000000..9d7488acec4 Binary files /dev/null and b/test/image/baselines/automargin-push-x-extra.png differ diff --git a/test/image/baselines/automargin-push-y-extra.png b/test/image/baselines/automargin-push-y-extra.png new file mode 100644 index 00000000000..6ad607c65ed Binary files /dev/null and b/test/image/baselines/automargin-push-y-extra.png differ diff --git a/test/image/mocks/automargin-push-x-extra.json b/test/image/mocks/automargin-push-x-extra.json new file mode 100644 index 00000000000..0548611aa1e --- /dev/null +++ b/test/image/mocks/automargin-push-x-extra.json @@ -0,0 +1,109 @@ +{ + "data": [ + { + "x": [ + "12 AM diagonal", + "1 AM diagonal", + "2 AM diagonal", + "3 AM diagonal", + "4 AM diagonal", + "5 AM diagonal", + "6 AM diagonal", + "7 AM diagonal", + "8 AM diagonal", + "9 AM diagonal", + "10 AM diagonal", + "11 AM diagonal" + ], + "y": [ + 59.44, + 68.75, + 87.5, + 100.5, + 95.56, + 92.8, + 85.25, + 77.4, + 76.4, + 73.94, + 74.56, + 81.06 + ] + }, + { + "x": [ + "12 AM diagonal", + "1 AM diagonal", + "2 AM diagonal", + "3 AM diagonal", + "4 AM diagonal", + "5 AM diagonal", + "6 AM diagonal", + "7 AM diagonal", + "8 AM diagonal", + "9 AM diagonal", + "10 AM diagonal", + "11 AM diagonal" + ], + "y": [ + 59.44, + 68.75, + 87.5, + 100.5, + 95.56, + 92.8, + 85.25, + 77.4, + 76.4, + 73.94, + 74.56, + 81.06 + ], + "xaxis": "x2", + "yaxis": "y2" + } + ], + "layout": { + "grid": { + "columns": 2, + "rows": 1, + "pattern": "independent" + }, + "margin": { + "l": 0, + "r": 0, + "t": 0, + "b": 0 + }, + "xaxis": { + "showgrid": false, + "showline": true, + "zeroline": true, + "automargin": true, + "side": "top", + "tickangle": 45 + }, + "yaxis": { + "side": "right", + "showgrid": false, + "showline": true, + "zeroline": true, + "automargin": true + }, + "xaxis2": { + "showgrid": false, + "showline": true, + "zeroline": true, + "automargin": true, + "tickangle": 45 + }, + "yaxis2": { + "side": "left", + "showgrid": false, + "showline": true, + "zeroline": true, + "automargin": true + }, + "showlegend": false + } +} diff --git a/test/image/mocks/automargin-push-y-extra.json b/test/image/mocks/automargin-push-y-extra.json new file mode 100644 index 00000000000..db610ab25f7 --- /dev/null +++ b/test/image/mocks/automargin-push-y-extra.json @@ -0,0 +1,109 @@ +{ + "data": [ + { + "y": [ + "12 AM diagonal", + "1 AM diagonal", + "2 AM diagonal", + "3 AM diagonal", + "4 AM diagonal", + "5 AM diagonal", + "6 AM diagonal", + "7 AM diagonal", + "8 AM diagonal", + "9 AM diagonal", + "10 AM diagonal", + "11 AM diagonal" + ], + "x": [ + 59.44, + 68.75, + 87.5, + 100.5, + 95.56, + 92.8, + 85.25, + 77.4, + 76.4, + 73.94, + 74.56, + 81.06 + ] + }, + { + "y": [ + "12 AM diagonal", + "1 AM diagonal", + "2 AM diagonal", + "3 AM diagonal", + "4 AM diagonal", + "5 AM diagonal", + "6 AM diagonal", + "7 AM diagonal", + "8 AM diagonal", + "9 AM diagonal", + "10 AM diagonal", + "11 AM diagonal" + ], + "x": [ + 81.06, + 74.56, + 73.94, + 76.4, + 77.4, + 85.25, + 92.8, + 95.56, + 100.5, + 87.5, + 68.75, + 59.44 + ], + "xaxis": "x2", + "yaxis": "y2" + } + ], + "layout": { + "grid": { + "columns": 1, + "rows": 2, + "pattern": "independent" + }, + "margin": { + "l": 0, + "r": 0, + "t": 0, + "b": 0 + }, + "xaxis": { + "showgrid": false, + "showline": true, + "zeroline": true, + "automargin": true, + "side": "bottom" + }, + "yaxis": { + "side": "right", + "showgrid": false, + "showline": true, + "zeroline": true, + "automargin": true, + "tickangle": -45 + }, + "xaxis2": { + "showgrid": false, + "showline": true, + "zeroline": true, + "automargin": true, + "side": "top" + }, + "yaxis2": { + "showgrid": false, + "showline": true, + "zeroline": true, + "automargin": true, + "tickangle": -45 + }, + "showlegend": false + } +} diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 2b3f190178c..1e2b528d3e4 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -3235,23 +3235,7 @@ describe('Test axes', function() { }); describe('automargin', function() { - var data = [{ - x: [ - 'short label 1', 'loooooong label 1', - 'short label 2', 'loooooong label 2', - 'short label 3', 'loooooong label 3', - 'short label 4', 'loooooongloooooongloooooong label 4', - 'short label 5', 'loooooong label 5' - ], - y: [ - 'short label 1', 'loooooong label 1', - 'short label 2', 'loooooong label 2', - 'short label 3', 'loooooong label 3', - 'short label 4', 'loooooong label 4', - 'short label 5', 'loooooong label 5' - ] - }]; - var gd, initialSize, previousSize, savedBottom; + var gd; beforeEach(function() { gd = createGraphDiv(); @@ -3260,170 +3244,224 @@ describe('Test axes', function() { afterEach(destroyGraphDiv); it('should grow and shrink margins', function(done) { + var initialSize; + var previousSize; + + function assertSize(msg, actual, exp) { + for(var k in exp) { + var parts = exp[k].split('|'); + var op = parts[0]; + + var method = { + '=': 'toBe', + '~=': 'toBeWithin', + grew: 'toBeGreaterThan', + shrunk: 'toBeLessThan', + initial: 'toBe' + }[op]; + + var val = op === 'initial' ? initialSize[k] : previousSize[k]; + var msgk = msg + ' ' + k + (parts[1] ? ' |' + parts[1] : ''); + var args = op === '~=' ? [val, 1.1, msgk] : [val, msgk, '']; + + expect(actual[k])[method](args[0], args[1], args[2]); + } + } - Plotly.plot(gd, data) - .then(function() { - expect(gd._fullLayout.xaxis._tickAngles.xtick).toBe(30); - - initialSize = previousSize = Lib.extendDeep({}, gd._fullLayout._size); - return Plotly.relayout(gd, {'yaxis.automargin': true}); - }) - .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBeGreaterThan(previousSize.l); - expect(size.r).toBe(previousSize.r); - expect(size.b).toBe(previousSize.b); - expect(size.t).toBe(previousSize.t); + function check(msg, relayoutObj, exp) { + return function() { + return Plotly.relayout(gd, relayoutObj).then(function() { + var gs = Lib.extendDeep({}, gd._fullLayout._size); + assertSize(msg, gs, exp); + previousSize = gs; + }); + }; + } - previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, {'xaxis.automargin': true}); + Plotly.plot(gd, [{ + x: [ + 'short label 1', 'loooooong label 1', + 'short label 2', 'loooooong label 2', + 'short label 3', 'loooooong label 3', + 'short label 4', 'loooooongloooooongloooooong label 4', + 'short label 5', 'loooooong label 5' + ], + y: [ + 'short label 1', 'loooooong label 1', + 'short label 2', 'loooooong label 2', + 'short label 3', 'loooooong label 3', + 'short label 4', 'loooooong label 4', + 'short label 5', 'loooooong label 5' + ] + }], { + margin: {l: 0, r: 0, b: 0, t: 0}, + width: 600, height: 600 }) .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBe(previousSize.l); - expect(size.r).toBe(previousSize.r); - expect(size.b).toBeGreaterThan(previousSize.b); - expect(size.t).toBe(previousSize.t); + expect(gd._fullLayout.xaxis._tickAngles.xtick).toBe(30); - previousSize = Lib.extendDeep({}, size); - savedBottom = previousSize.b; + var gs = gd._fullLayout._size; + initialSize = Lib.extendDeep({}, gs); + previousSize = Lib.extendDeep({}, gs); + }) + .then(check('automargin y', {'yaxis.automargin': true}, { + t: '=', l: 'grew', + b: '=', r: '=' + })) + .then(check('automargin x', {'xaxis.automargin': true}, { + t: '=', l: '=', + b: 'grew', r: 'grew' + })) + .then(check('move all x label off-screen', {'xaxis.range': [-10, -5]}, { + t: '=', l: '=', + b: 'initial', r: 'initial' + })) + .then(check('move all y label off-screen', {'yaxis.range': [-10, -5]}, { + t: '=', l: 'initial', + b: '=', r: '=' + })) + .then(check('back to label for auto ranges', {'xaxis.autorange': true, 'yaxis.autorange': true}, { + t: '=', l: 'grew', + b: 'grew', r: 'grew' + })) + .then(check('tilt x label to 45 degrees', {'xaxis.tickangle': 45}, { + t: '=', l: '=', + b: 'grew', r: 'shrunk' + })) + .then(check('tilt x labels back to 30 degrees', {'xaxis.tickangle': 30}, { + t: '=', l: '=', + b: 'shrunk', r: 'grew' + })) + .then(check('bump y-axis tick length', {'yaxis.ticklen': 30}, { + t: '=', l: 'grew', + b: '=', r: 'grew| as x ticks got shifted right' + })) + .then(check('add y-axis title', {'yaxis.title.text': 'hello'}, { + t: '=', l: 'grew', + b: '=', r: 'grew| as x ticks got shifted right' + })) + .then(check('size up y-axis title', {'yaxis.title.font.size': 30}, { + t: '=', l: 'grew', + b: '=', r: 'grew| as x ticks got shifted right' + })) + .then(check('tilt y labels up 30 degrees', {'yaxis.tickangle': 30}, { + t: 'grew', l: 'shrunk', + b: '=', r: 'shrunk| as x ticks got shifted left' + })) + .then(check('un-tilt y labels', {'yaxis.tickangle': null}, { + t: 'shrunk', l: 'grew', + b: '=', r: 'grew' + })) + .then(check('unanchor y-axis', {'yaxis.anchor': 'free'}, { + t: '=', l: '~=', + b: '=', r: '=' + })) + .then(check('offset y-axis to the left', {'yaxis.position': 0.1}, { + t: '=', l: 'shrunk| as y-axis shifted right', + b: '=', r: 'shrunk| as y-axis shifted right' + })) + .then(check('re-anchor y-axis', {'yaxis.anchor': 'x'}, { + t: '=', l: 'grew', + b: '=', r: 'grew' + })) + .then(check('flip axis side', {'yaxis.side': 'right', 'xaxis.side': 'top'}, { + t: 'grew', l: 'shrunk', + b: 'shrunk', r: 'grew' + })) + .then(check('tilt x labels vertically', {'xaxis.tickangle': 90}, { + t: 'grew', l: 'shrunk', + b: '=', r: '=' + })) + .then(check('tilt y labels down 30 degrees', {'yaxis.tickangle': 30}, { + t: '=', l: '=', + b: 'grew', r: 'shrunk' + })) + .then(check('turn off automargin', {'xaxis.automargin': false, 'yaxis.automargin': false}, { + t: 'initial', l: 'initial', + b: 'initial', r: 'initial' + })) + .catch(failTest) + .then(done); + }); - // move all the long x labels off-screen - return Plotly.relayout(gd, {'xaxis.range': [-10, -5]}); - }) - .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBe(previousSize.l); - expect(size.r).toBe(previousSize.r); - expect(size.t).toBe(previousSize.t); - expect(size.b).toBe(initialSize.b); + it('should not lead to negative plot area heights', function(done) { + function _assert(msg, exp) { + var gs = gd._fullLayout._size; + expect(gs.h).toBeGreaterThan(0, msg + '- positive height'); + expect(gs.b).toBeGreaterThan(exp.bottomLowerBound, msg + ' - margin bottom'); + expect(gs.b + gs.h + gs.t).toBeWithin(exp.totalHeight, 1.5, msg + ' - total height'); + } - // move all the long y labels off-screen - return Plotly.relayout(gd, {'yaxis.range': [-10, -5]}); + Plotly.plot(gd, [{ + x: ['loooooong label 1', 'loooooong label 2'], + y: [1, 2] + }], { + xaxis: {automargin: true, tickangle: 90}, + width: 500, + height: 500 }) .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBe(initialSize.l); - expect(size.r).toBe(previousSize.r); - expect(size.t).toBe(previousSize.t); - expect(size.b).toBe(initialSize.b); - - // bring the long labels back - return Plotly.relayout(gd, { - 'xaxis.autorange': true, - 'yaxis.autorange': true + _assert('base', { + bottomLowerBound: 80, + totalHeight: 500 }); }) + .then(function() { return Plotly.relayout(gd, 'height', 100); }) .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBe(previousSize.l); - expect(size.r).toBe(previousSize.r); - expect(size.t).toBe(previousSize.t); - expect(size.b).toBe(previousSize.b); - - return Plotly.relayout(gd, {'xaxis.tickangle': 45}); - }) - .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBe(previousSize.l); - expect(size.r).toBe(previousSize.r); - expect(size.b).toBeGreaterThan(previousSize.b); - expect(size.t).toBe(previousSize.t); - - previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, {'xaxis.tickangle': 30}); - }) - .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBe(previousSize.l); - expect(size.r).toBe(previousSize.r); - expect(size.b).toBe(savedBottom); - expect(size.t).toBe(previousSize.t); - - previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, {'yaxis.ticklen': 30}); - }) - .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBeGreaterThan(previousSize.l); - expect(size.r).toBe(previousSize.r); - expect(size.b).toBe(previousSize.b); - expect(size.t).toBe(previousSize.t); - - previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, {'yaxis.title.font.size': 30}); - }) - .then(function() { - var size = gd._fullLayout._size; - expect(size).toEqual(previousSize); - - previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, {'yaxis.title.text': 'hello'}); + _assert('after relayout to *small* height', { + bottomLowerBound: 30, + totalHeight: 100 + }); }) + .then(function() { return Plotly.relayout(gd, 'height', 800); }) .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBeGreaterThan(previousSize.l); - expect(size.r).toBe(previousSize.r); - expect(size.b).toBe(previousSize.b); - expect(size.t).toBe(previousSize.t); - - previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, {'yaxis.anchor': 'free'}); + _assert('after relayout to *big* height', { + bottomLowerBound: 80, + totalHeight: 800 + }); }) - .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBeWithin(previousSize.l, 1.1); - expect(size.r).toBe(previousSize.r); - expect(size.b).toBe(previousSize.b); - expect(size.t).toBe(previousSize.t); + .catch(failTest) + .then(done); + }); - previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, {'yaxis.position': 0.1}); - }) - .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBeLessThan(previousSize.l, 'axis moved right'); - expect(size.r).toBe(previousSize.r); - expect(size.b).toBe(previousSize.b); - expect(size.t).toBe(previousSize.t); + it('should not lead to negative plot area widths', function(done) { + function _assert(msg, exp) { + var gs = gd._fullLayout._size; + expect(gs.w).toBeGreaterThan(0, msg + '- positive width'); + expect(gs.l).toBeGreaterThan(exp.leftLowerBound, msg + ' - margin left'); + expect(gs.l + gs.w + gs.r).toBeWithin(exp.totalWidth, 1.5, msg + ' - total width'); + } - previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, {'yaxis.anchor': 'x'}); + Plotly.plot(gd, [{ + y: ['loooooong label 1', 'loooooong label 2'], + x: [1, 2] + }], { + yaxis: {automargin: true}, + width: 500, + height: 500 }) .then(function() { - var size = gd._fullLayout._size; - expect(size.l).toBeGreaterThan(previousSize.l, 'axis snapped back'); - expect(size.r).toBe(previousSize.r); - expect(size.b).toBe(previousSize.b); - expect(size.t).toBe(previousSize.t); - - previousSize = Lib.extendDeep({}, size); - return Plotly.relayout(gd, { - 'yaxis.side': 'right', - 'xaxis.side': 'top' + _assert('base', { + leftLowerBound: 80, + totalWidth: 500 }); }) + .then(function() { return Plotly.relayout(gd, 'width', 100); }) .then(function() { - var size = gd._fullLayout._size; - // left to right and bottom to top - expect(size.l).toBe(initialSize.r); - expect(size.r).toBe(previousSize.l); - expect(size.b).toBe(initialSize.b); - expect(size.t).toBeWithin(previousSize.b, 1.1); - - return Plotly.relayout(gd, { - 'xaxis.automargin': false, - 'yaxis.automargin': false + _assert('after relayout to *small* width', { + leftLowerBound: 30, + totalWidth: 100 }); }) + .then(function() { return Plotly.relayout(gd, 'width', 1000); }) .then(function() { - var size = gd._fullLayout._size; - // back to the defaults - expect(size).toEqual(initialSize); + _assert('after relayout to *big* width', { + leftLowerBound: 80, + totalWidth: 1000 + }); }) .catch(failTest) .then(done); - }); });