diff --git a/draftlogs/7965_add.md b/draftlogs/7965_add.md new file mode 100644 index 00000000000..572bd45875c --- /dev/null +++ b/draftlogs/7965_add.md @@ -0,0 +1 @@ +- Add `domainpad` to cartesian axes, reserving space inside the `domain` edges in pixels so spacing between subplots no longer scales with the figure [[#7965](https://github.com/plotly/plotly.js/pull/7965)] diff --git a/src/components/rangeslider/draw.js b/src/components/rangeslider/draw.js index e86fc714c60..8ee8d70a844 100644 --- a/src/components/rangeslider/draw.js +++ b/src/components/rangeslider/draw.js @@ -100,11 +100,13 @@ module.exports = function(gd) { // update range slider dimensions var gs = fullLayout._size; - var domain = axisOpts.domain; - opts._width = gs.w * (domain[1] - domain[0]); + // the slider has to line up with the plot area above it, so take its width + // and position from the axis rather than working them out from `domain` + // again - `domainpad` moves the axis without touching `domain` + opts._width = axisOpts._length; - var x = Math.round(gs.l + (gs.w * domain[0])); + var x = Math.round(axisOpts._offset); var y = Math.round( gs.t + gs.h * (1 - axisOpts._counterDomainMin) + diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index c723a8d009a..6656f2fc0f5 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -4605,7 +4605,10 @@ function swapAxisGroup(gd, xIds, yIds) { var allAxKeys = Object.keys(axAttrs); var noSwapAttrs = [ - 'anchor', 'domain', 'overlaying', 'position', 'side', 'tickangle', 'editType' + // domainpad sits with domain here: its keys are named for screen edges, so + // swapping x and y would have to remap left/right onto top/bottom + 'anchor', 'domain', 'domainpad', 'overlaying', 'position', 'side', + 'tickangle', 'editType' ]; var numericTypes = ['linear', 'log']; diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js index 2dd1e665df1..ecaa543b744 100644 --- a/src/plots/cartesian/constraints.js +++ b/src/plots/cartesian/constraints.js @@ -565,7 +565,7 @@ exports.enforce = function enforce(gd) { var getPadMin = autorange.makePadFn(fullLayout, ax, 0); var getPadMax = autorange.makePadFn(fullLayout, ax, 1); - updateDomain(ax, factor); + updateDomain(ax, factor, fullLayout); var m = Math.abs(ax._m); var extremes = autorange.concatExtremes(gd, ax); var minArray = extremes.min; @@ -596,7 +596,7 @@ exports.enforce = function enforce(gd) { [rangeMin, rangeMax] : [rangeMax, rangeMin]; } - updateDomain(ax, factor); + updateDomain(ax, factor, fullLayout); } } } @@ -633,14 +633,41 @@ exports.clean = function clean(gd, ax) { } }; -function updateDomain(ax, factor) { +// `domainpad` in domain fractions rather than pixels, which is the unit +// everything around the constraint solve is expressed in. +function domainPadFraction(ax, fullLayout) { + var pad = ax.domainpad; + if(!pad) return 0; + + var gs = fullLayout._size; + return ax._id.charAt(0) === 'y' ? + ((pad.top || 0) + (pad.bottom || 0)) / gs.h : + ((pad.left || 0) + (pad.right || 0)) / gs.w; +} + +function updateDomain(ax, factor, fullLayout) { var inputDomain = ax._inputDomain; - var centerFraction = FROM_BL[ax.constraintoward]; - var center = inputDomain[0] + (inputDomain[1] - inputDomain[0]) * centerFraction; + var inputSpan = inputDomain[1] - inputDomain[0]; + var center = inputDomain[0] + inputSpan * FROM_BL[ax.constraintoward]; + + // We have to divide the axis' drawn length by `factor`. With no padding that + // length is the domain span, so dividing the span does it. `domainpad` breaks + // the equivalence by taking a fixed number of pixels off the ends: the drawn + // length is (span - padding), and only that part scales. Divide it and add the + // padding back. If the padding covers the whole domain there is nothing left to + // scale, so fall through to the plain behaviour and let setScale clamp it. + var padFraction = domainPadFraction(ax, fullLayout); + var drawnSpan = inputSpan - padFraction; + var newSpan = drawnSpan > 0 ? + drawnSpan / factor + padFraction : + inputSpan / factor; + + // grow or shrink about whichever edge or centre `constraintoward` asked for + var scale = newSpan / inputSpan; ax.domain = ax._input.domain = [ - center + (inputDomain[0] - center) / factor, - center + (inputDomain[1] - center) / factor + center + (inputDomain[0] - center) * scale, + center + (inputDomain[1] - center) * scale ]; ax.setScale(); } diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 25b60cfa28e..796784c87d4 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -1205,6 +1205,47 @@ module.exports = { 'Sets the domain of this axis (in plot fraction).' ].join(' ') }, + domainpad: { + left: { + valType: 'number', + min: 0, + dflt: 0, + editType: 'plot', + description: 'Pixels of space to reserve inside the left edge of the domain. Ignored on y axes.' + }, + right: { + valType: 'number', + min: 0, + dflt: 0, + editType: 'plot', + description: 'Pixels of space to reserve inside the right edge of the domain. Ignored on y axes.' + }, + top: { + valType: 'number', + min: 0, + dflt: 0, + editType: 'plot', + description: 'Pixels of space to reserve inside the top edge of the domain. Ignored on x axes.' + }, + bottom: { + valType: 'number', + min: 0, + dflt: 0, + editType: 'plot', + description: 'Pixels of space to reserve inside the bottom edge of the domain. Ignored on x axes.' + }, + editType: 'plot', + description: [ + 'Reserves space inside the edges of `domain`, in pixels.', + 'Because `domain` is a plot fraction, the space it leaves between subplots', + 'grows and shrinks with the figure. `domainpad` stays the same size at any', + 'figure height or width, which is what you want for anything sized in pixels', + 'such as a subplot title.', + 'x axes use `left` and `right`, y axes use `top` and `bottom`.', + 'If the padding asks for more room than the domain has, it is scaled down', + 'so the subplot keeps a usable size.' + ].join(' ') + }, position: { valType: 'number', min: 0, diff --git a/src/plots/cartesian/position_defaults.js b/src/plots/cartesian/position_defaults.js index 907501830ce..c72292627a1 100644 --- a/src/plots/cartesian/position_defaults.js +++ b/src/plots/cartesian/position_defaults.js @@ -84,6 +84,17 @@ module.exports = function handlePositionDefaults(containerIn, containerOut, coer if(domain[0] > domain[1] - 1 / 4096) containerOut.domain = dfltDomain; Lib.noneOrAll(containerIn.domain, containerOut.domain, dfltDomain); + // domainpad reserves pixels inside the domain edges. Only the two sides that + // point along this axis mean anything, so we skip the other two rather than + // let people set a value that silently does nothing. + if(letter === 'x') { + coerce('domainpad.left'); + coerce('domainpad.right'); + } else { + coerce('domainpad.top'); + coerce('domainpad.bottom'); + } + // tickmode sync needs an overlaying axis, otherwise // we should default it to 'auto' if(containerOut.tickmode === 'sync') { diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index cc3c6ccf7ee..3190c6a00a7 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -35,6 +35,27 @@ function isValidCategory(v) { return v !== null && v !== undefined; } +// Smallest plot area `domainpad` may leave behind, matching the floor +// `doAutoMargin` keeps for margins. Zero would not do: the guard at the end of +// setScale only rejects lengths below zero, so an exactly-zero length slips +// through with a slope of 0 and the subplot collapses without saying why. +var MIN_PADDED_LENGTH = 2; + +/* + * What fraction of the requested `domainpad` actually fits. + * + * `domain` is a plot fraction, so the band it covers shrinks with the figure while + * the padding does not. Make the figure small enough and the two pads together ask + * for more room than the band has. Back both off by the same factor rather than + * hand setScale a negative length, which throws - the same way `doAutoMargin` + * shrinks margins that no longer fit. + */ +function padFactor(wanted, bandLength) { + if(wanted <= 0) return 0; + var room = Math.max(0, bandLength - MIN_PADDED_LENGTH); + return wanted > room ? room / wanted : 1; +} + /** * Define the conversion functions for an axis data is used in 5 ways: * @@ -562,6 +583,9 @@ module.exports = function setConvert(ax, fullLayout) { if(ax.overlaying) { var ax2 = axisIds.getFromId({ _fullLayout: fullLayout }, ax.overlaying); ax.domain = ax2.domain; + // an overlaying axis has to sit on exactly the same plot area as the axis + // underneath it, so it takes that axis' padding along with its domain + ax.domainpad = ax2.domainpad; } // While transitions are occurring, we get a double-transform @@ -576,14 +600,37 @@ module.exports = function setConvert(ax, fullLayout) { var rl1 = ax.r2l(ax[rangeAttr][1], calendar); var isY = axLetter === 'y'; + // the band `domain` covers, before domainpad takes its share of it + var bandLength = (isY ? gs.h : gs.w) * (ax.domain[1] - ax.domain[0]); + var pad = ax.domainpad; + // padStart is the edge _offset is measured from, the top for y and the left + // for x, so it is the one that pushes the plot area inwards + var padStart = 0; + var padEnd = 0; + + if(pad) { + padStart = (isY ? pad.top : pad.left) || 0; + padEnd = (isY ? pad.bottom : pad.right) || 0; + + var fits = padFactor(padStart + padEnd, bandLength); + padStart *= fits; + padEnd *= fits; + } + + ax._length = bandLength - padStart - padEnd; + // the resolved padding, for the few places that build the plot rect from + // `domain` themselves and would otherwise miss it. They add these instead of + // rebuilding from _offset and _length, which would round trip through pixel + // space and lose a bit or two even when there is no padding at all. + ax._padStart = padStart; + ax._padEnd = padEnd; + if(isY) { - ax._offset = gs.t + (1 - ax.domain[1]) * gs.h; - ax._length = gs.h * (ax.domain[1] - ax.domain[0]); + ax._offset = gs.t + (1 - ax.domain[1]) * gs.h + padStart; ax._m = ax._length / (rl0 - rl1); ax._b = -ax._m * rl1; } else { - ax._offset = gs.l + ax.domain[0] * gs.w; - ax._length = gs.w * (ax.domain[1] - ax.domain[0]); + ax._offset = gs.l + ax.domain[0] * gs.w + padStart; ax._m = ax._length / (rl1 - rl0); ax._b = -ax._m * rl0; } diff --git a/src/traces/scattergl/plot.js b/src/traces/scattergl/plot.js index 9fd954fe5d4..bf1e06bc732 100644 --- a/src/traces/scattergl/plot.js +++ b/src/traces/scattergl/plot.js @@ -27,11 +27,21 @@ function getViewport(fullLayout, xaxis, yaxis, plotGlPixelRatio) { var t = gs.t * plotGlPixelRatio; var w = gs.w * plotGlPixelRatio; var h = gs.h * plotGlPixelRatio; + + // `domainpad` takes pixels off the plot area that `domain` knows nothing about. + // Add it to the expressions below rather than rebuilding the rect from _offset + // and _length: that would round trip through pixel space and shift this rect by + // a fraction of a pixel even on plots with no padding at all. + var padL = (xaxis._padStart || 0) * plotGlPixelRatio; + var padR = (xaxis._padEnd || 0) * plotGlPixelRatio; + var padT = (yaxis._padStart || 0) * plotGlPixelRatio; + var padB = (yaxis._padEnd || 0) * plotGlPixelRatio; + return [ - l + xaxis.domain[0] * w, - b + yaxis.domain[0] * h, - (width - r) - (1 - xaxis.domain[1]) * w, - (height - t) - (1 - yaxis.domain[1]) * h + l + xaxis.domain[0] * w + padL, + b + yaxis.domain[0] * h + padB, + (width - r) - (1 - xaxis.domain[1]) * w - padR, + (height - t) - (1 - yaxis.domain[1]) * h - padT ]; } diff --git a/src/traces/splom/base_plot.js b/src/traces/splom/base_plot.js index a3486e64514..5624ad8f0ff 100644 --- a/src/traces/splom/base_plot.js +++ b/src/traces/splom/base_plot.js @@ -135,8 +135,10 @@ function makeGridData(gd) { var yLength = ya._length; // ya.l2p assumes top-to-bottom coordinate system (a la SVG), - // we need to compute bottom-to-top offsets and slopes: - var yOffset = gs.b + ya.domain[0] * gs.h; + // we need to compute bottom-to-top offsets and slopes. + // `domainpad` is added on rather than folded in by reading _offset, so that + // an unpadded axis lands on exactly the pixel it always did: + var yOffset = gs.b + ya.domain[0] * gs.h + (ya._padEnd || 0); var ym = -ya._m; var yb = -ym * ya.r2l(ya.range[0], ya.calendar); var x, y; diff --git a/src/traces/splom/plot.js b/src/traces/splom/plot.js index 774268ca5e1..d6f585502fe 100644 --- a/src/traces/splom/plot.js +++ b/src/traces/splom/plot.js @@ -41,6 +41,13 @@ function plotOne(gd, cd0) { viewOpts.ranges = new Array(visibleLength); viewOpts.domains = new Array(visibleLength); + // regl-splom places each cell as a fraction of the viewport below, which is the + // whole plot area, so `domainpad` has to be folded in as a fraction too or the + // cells keep their unpadded size while the axes move. Added to `domain` rather + // than recovered from _offset and _length, which would round trip through pixel + // space and nudge every cell a little even with no padding set. + function padFrac(px, total) { return (px || 0) / total; } + for(k = 0; k < visibleDims.length; k++) { i = visibleDims[k]; @@ -51,16 +58,16 @@ function plotOne(gd, cd0) { if(xa) { rng[0] = xa._rl[0]; rng[2] = xa._rl[1]; - dmn[0] = xa.domain[0]; - dmn[2] = xa.domain[1]; + dmn[0] = xa.domain[0] + padFrac(xa._padStart, gs.w); + dmn[2] = xa.domain[1] - padFrac(xa._padEnd, gs.w); } ya = AxisIDs.getFromId(gd, trace._diag[i][1]); if(ya) { rng[1] = ya._rl[0]; rng[3] = ya._rl[1]; - dmn[1] = ya.domain[0]; - dmn[3] = ya.domain[1]; + dmn[1] = ya.domain[0] + padFrac(ya._padEnd, gs.h); + dmn[3] = ya.domain[1] - padFrac(ya._padStart, gs.h); } } diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 15eca358dfe..2028685d742 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -8502,3 +8502,471 @@ describe('test tickmode calculator', function() { }); }); }); + +describe('axis domainpad', function() { + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + afterEach(destroyGraphDiv); + + // two stacked subplots with a small gap between them - the shape that makes + // subplot titles overlap in https://github.com/plotly/plotly.py/issues/5606 + function twoRows(layoutPatch) { + return Lib.extendDeep({ + width: 800, + height: 600, + margin: {l: 40, r: 20, t: 20, b: 40}, + xaxis: {domain: [0, 1], anchor: 'y'}, + yaxis: {domain: [0, 0.49], anchor: 'x'}, + xaxis2: {domain: [0, 1], anchor: 'y2'}, + yaxis2: {domain: [0.51, 1], anchor: 'x2'} + }, layoutPatch || {}); + } + + var twoRowsData = [ + {y: [1, 2, 3], xaxis: 'x', yaxis: 'y'}, + {y: [3, 1, 2], xaxis: 'x2', yaxis: 'y2'} + ]; + + it('should default every side to zero and leave the plot area alone', function(done) { + Plotly.newPlot(gd, twoRowsData, twoRows()) + .then(function() { + var xa = gd._fullLayout.xaxis; + var ya = gd._fullLayout.yaxis; + + expect(xa.domainpad.left).toBe(0); + expect(xa.domainpad.right).toBe(0); + expect(ya.domainpad.top).toBe(0); + expect(ya.domainpad.bottom).toBe(0); + + // gs.h is 540 here, so the [0, 0.49] band is 264.6px tall + expect(ya._length).toBeCloseTo(264.6, 2); + expect(ya._offset).toBeCloseTo(295.4, 2); + }) + .then(done, done.fail); + }); + + it('should only coerce the two sides that point along the axis', function(done) { + Plotly.newPlot(gd, twoRowsData, twoRows({ + xaxis: {domain: [0, 1], anchor: 'y', domainpad: {left: 10, top: 99}}, + yaxis: {domain: [0, 0.49], anchor: 'x', domainpad: {top: 20, left: 99}} + })) + .then(function() { + // the sides that mean something are kept + expect(gd._fullLayout.xaxis.domainpad.left).toBe(10); + expect(gd._fullLayout.yaxis.domainpad.top).toBe(20); + // the ones that do not are never coerced, so they stay undefined + expect(gd._fullLayout.xaxis.domainpad.top).toBeUndefined(); + expect(gd._fullLayout.yaxis.domainpad.left).toBeUndefined(); + }) + .then(done, done.fail); + }); + + it('should take the padding off the plot area, one side at a time', function(done) { + var basicOffset, basicLength; + + Plotly.newPlot(gd, twoRowsData, twoRows()) + .then(function() { + basicOffset = gd._fullLayout.yaxis2._offset; + basicLength = gd._fullLayout.yaxis2._length; + + return Plotly.relayout(gd, {'yaxis2.domainpad.top': 30}); + }) + .then(function() { + var ya2 = gd._fullLayout.yaxis2; + // padding the top pushes the plot area down and makes it shorter + expect(ya2._offset).toBeCloseTo(basicOffset + 30, 2); + expect(ya2._length).toBeCloseTo(basicLength - 30, 2); + + return Plotly.relayout(gd, {'yaxis2.domainpad.top': 0, 'yaxis2.domainpad.bottom': 30}); + }) + .then(function() { + var ya2 = gd._fullLayout.yaxis2; + // padding the bottom leaves the top edge where it was + expect(ya2._offset).toBeCloseTo(basicOffset, 2); + expect(ya2._length).toBeCloseTo(basicLength - 30, 2); + }) + .then(done, done.fail); + }); + + it('should pad x axes from the left and the right', function(done) { + var basicOffset, basicLength; + + Plotly.newPlot(gd, twoRowsData, twoRows()) + .then(function() { + basicOffset = gd._fullLayout.xaxis._offset; + basicLength = gd._fullLayout.xaxis._length; + + return Plotly.relayout(gd, {'xaxis.domainpad.left': 15, 'xaxis.domainpad.right': 25}); + }) + .then(function() { + var xa = gd._fullLayout.xaxis; + expect(xa._offset).toBeCloseTo(basicOffset + 15, 2); + expect(xa._length).toBeCloseTo(basicLength - 40, 2); + }) + .then(done, done.fail); + }); + + it('should reserve the same pixels whatever the figure height is', function(done) { + // this is the point of the attribute - a plot fraction cannot do it + Plotly.newPlot(gd, twoRowsData, twoRows({ + yaxis2: {domain: [0.51, 1], anchor: 'x2', domainpad: {top: 28}} + })) + .then(function() { + expect(gd._fullLayout.yaxis2._offset).toBeCloseTo(48, 2); + return Plotly.relayout(gd, {height: 300}); + }) + .then(function() { + expect(gd._fullLayout.yaxis2._offset).toBeCloseTo(48, 2); + return Plotly.relayout(gd, {height: 1200}); + }) + .then(function() { + expect(gd._fullLayout.yaxis2._offset).toBeCloseTo(48, 2); + }) + .then(done, done.fail); + }); + + it('should scale the padding back rather than run the plot area down to nothing', function(done) { + // six rows each asking for 60px inside a 200px tall figure is far more + // padding than there is room for. setScale throws on a negative length, + // so the padding has to give way instead. + var data = []; + var layout = {width: 800, height: 200, margin: {l: 40, r: 20, t: 20, b: 40}}; + + for(var i = 0; i < 6; i++) { + var num = i === 0 ? '' : (i + 1); + data.push({y: [1, 2, 3], xaxis: 'x' + num, yaxis: 'y' + num}); + layout['xaxis' + (i + 1)] = {domain: [0, 1], anchor: 'y' + num}; + layout['yaxis' + (i + 1)] = { + domain: [i / 6 + 0.005, (i + 1) / 6 - 0.005], + anchor: 'x' + num, + domainpad: {top: 60} + }; + } + + Plotly.newPlot(gd, data, layout) + .then(function() { + for(var j = 1; j <= 6; j++) { + // the first axis is stored as `yaxis`, not `yaxis1` + var ya = gd._fullLayout[j === 1 ? 'yaxis' : 'yaxis' + j]; + expect(ya._length).toBeGreaterThan(0); + expect(isFinite(ya._m)).toBe(true); + } + }) + .then(done, done.fail); + }); + + it('should give an overlaying axis the same padding as the axis underneath', function(done) { + Plotly.newPlot(gd, [ + {y: [1, 2, 3]}, + {y: [4, 5, 6], yaxis: 'y2'} + ], { + width: 800, + height: 600, + margin: {l: 40, r: 20, t: 20, b: 40}, + yaxis: {domain: [0, 1], domainpad: {top: 30}}, + yaxis2: {overlaying: 'y', side: 'right'} + }) + .then(function() { + var ya = gd._fullLayout.yaxis; + var ya2 = gd._fullLayout.yaxis2; + + // the two have to end up on exactly the same plot area + expect(ya2._offset).toBeCloseTo(ya._offset, 2); + expect(ya2._length).toBeCloseTo(ya._length, 2); + }) + .then(done, done.fail); + }); + + it('should let a domain referenced annotation sit in the space it reserved', function(done) { + // this is how a subplot title stops running into the subplot above it + Plotly.newPlot(gd, twoRowsData, twoRows({ + yaxis2: {domain: [0.51, 1], anchor: 'x2', domainpad: {top: 28}}, + annotations: [{ + text: 'subplot title', + xref: 'x2 domain', x: 0.5, + yref: 'y2 domain', y: 1, + xanchor: 'center', yanchor: 'bottom', + showarrow: false, + font: {size: 16} + }] + })) + .then(function() { + var ya2 = gd._fullLayout.yaxis2; + var annBox = d3Select(gd).select('.annotation').node().getBoundingClientRect(); + var gdBox = gd.getBoundingClientRect(); + + var annTop = annBox.top - gdBox.top; + var annBottom = annBox.bottom - gdBox.top; + + // the title ends where the plot area starts ... + expect(annBottom).toBeCloseTo(ya2._offset, 0); + // ... and fits inside the band we set aside for it + expect(annTop).toBeGreaterThan(ya2._offset - 28); + }) + .then(done, done.fail); + }); + + it('should keep a scaleanchor ratio exact when the padded axis is not the one that shrinks', function(done) { + // constrain:'domain' shrinks a domain until the two axes share a scale. + // It works in domain fractions, and a fraction still maps straight onto + // pixels for any axis without padding, so this case comes out exact. + Plotly.newPlot(gd, [{x: [0, 10], y: [0, 10], mode: 'markers'}], { + width: 800, height: 600, margin: {l: 40, r: 20, t: 20, b: 40}, + xaxis: {domain: [0, 1], range: [0, 10], constrain: 'domain'}, + yaxis: { + domain: [0, 1], range: [0, 10], constrain: 'domain', + scaleanchor: 'x', scaleratio: 1, + domainpad: {top: 60} + } + }) + .then(function() { + var xa = gd._fullLayout.xaxis; + var ya = gd._fullLayout.yaxis; + expect(Math.abs(ya._m) / Math.abs(xa._m)).toBeCloseTo(1, 6); + }) + .then(done, done.fail); + }); + + it('should keep a scaleanchor ratio exact when the domain constraint shrinks the padded axis', function(done) { + // The harder direction. The solver shrinks a domain fraction, assuming the + // drawn length rises and falls with it. domainpad takes a fixed number of + // pixels off the end, so only (span - padding) actually scales - which is + // what updateDomain now divides. Before that fix this came out about 2.3% + // wide. See https://github.com/plotly/plotly.js/issues/7835 + Plotly.newPlot(gd, [{x: [0, 10], y: [0, 10], mode: 'markers'}], { + width: 800, height: 600, margin: {l: 40, r: 20, t: 20, b: 40}, + xaxis: { + domain: [0, 1], range: [0, 10], constrain: 'domain', + domainpad: {left: 60} + }, + yaxis: { + domain: [0, 1], range: [0, 10], constrain: 'domain', + scaleanchor: 'x', scaleratio: 1 + } + }) + .then(function() { + var xa = gd._fullLayout.xaxis; + var ya = gd._fullLayout.yaxis; + expect(Math.abs(ya._m) / Math.abs(xa._m)).toBeCloseTo(1, 6); + }) + .then(done, done.fail); + }); + + it('should keep a rangeslider lined up with the padded x axis', function(done) { + // the slider maps to the x range, so it has to sit under the plot area, + // not under the unpadded domain + Plotly.newPlot(gd, [{y: [1, 2, 3]}], { + width: 800, height: 500, margin: {l: 40, r: 20, t: 20, b: 40}, + xaxis: { + domain: [0, 1], + domainpad: {left: 60, right: 30}, + rangeslider: {visible: true} + }, + yaxis: {domain: [0, 1]} + }) + .then(function() { + var xa = gd._fullLayout.xaxis; + expect(xa.rangeslider._width).toBeCloseTo(xa._length, 2); + + var bg = d3Select(gd).select('.rangeslider-bg').node(); + var bgBox = bg.getBoundingClientRect(); + var gdBox = gd.getBoundingClientRect(); + + expect(bgBox.left - gdBox.left).toBeCloseTo(xa._offset, 0); + expect(bgBox.right - gdBox.left).toBeCloseTo(xa._offset + xa._length, 0); + }) + .then(done, done.fail); + }); + + it('should land in the same place whatever kind of axis it is', function(done) { + // domainpad is pure geometry, so it should not care about axis type or how + // the subplot got its domain. Work the expected offset and length out here + // rather than reading them back, so the test is not just echoing setScale. + function expected(ax, gs) { + var isY = ax._id.charAt(0) === 'y'; + var pad = ax.domainpad || {}; + var band = (isY ? gs.h : gs.w) * (ax.domain[1] - ax.domain[0]); + var p0 = (isY ? pad.top : pad.left) || 0; + var p1 = (isY ? pad.bottom : pad.right) || 0; + + // same backing off the clamp does when the pads do not fit + var total = p0 + p1; + var maxTotal = Math.max(0, band - 2); + if(total > maxTotal) { + var k = maxTotal / total; + p0 *= k; + p1 *= k; + } + + return { + offset: (isY ? gs.t + (1 - ax.domain[1]) * gs.h : gs.l + ax.domain[0] * gs.w) + p0, + length: band - p0 - p1 + }; + } + + function check(label) { + var fl = gd._fullLayout; + var gs = fl._size; + + Object.keys(fl).forEach(function(key) { + if(!/^[xy]axis[0-9]*$/.test(key)) return; + var ax = fl[key]; + if(ax._offset === undefined) return; + + var want = expected(ax, gs); + expect(ax._offset).withContext(label + ' ' + key + ' offset').toBeCloseTo(want.offset, 2); + expect(ax._length).withContext(label + ' ' + key + ' length').toBeCloseTo(want.length, 2); + expect(ax._length).withContext(label + ' ' + key + ' length sign').toBeGreaterThan(0); + expect(isFinite(ax._m)).withContext(label + ' ' + key + ' slope').toBe(true); + }); + + // and the subplot background has to sit exactly on its axes + for(var id in fl._plots) { + var sp = fl._plots[id]; + if(!sp.bg) continue; + var node = sp.bg.node(); + expect(+node.getAttribute('x')).withContext(label + ' bg x').toBeCloseTo(sp.xaxis._offset - gs.p, 1); + expect(+node.getAttribute('y')).withContext(label + ' bg y').toBeCloseTo(sp.yaxis._offset - gs.p, 1); + expect(+node.getAttribute('width')).withContext(label + ' bg w').toBeCloseTo(sp.xaxis._length + 2 * gs.p, 1); + expect(+node.getAttribute('height')).withContext(label + ' bg h').toBeCloseTo(sp.yaxis._length + 2 * gs.p, 1); + } + } + + var base = {width: 800, height: 600, margin: {l: 50, r: 25, t: 25, b: 45}}; + + function plot(patch) { + return Plotly.newPlot(gd, patch.data, Lib.extendDeep({}, base, patch.layout)); + } + + plot({ + data: [{y: [1, 10, 100]}], + layout: { + yaxis: {type: 'log', domain: [0, 1], domainpad: {top: 30, bottom: 15}}, + xaxis: {domain: [0, 1], domainpad: {left: 20}} + } + }) + .then(function() { + check('log'); + return plot({ + data: [{x: ['2020-01-01', '2020-06-01', '2021-01-01'], y: [1, 2, 3]}], + layout: { + xaxis: {domain: [0, 1], domainpad: {left: 35, right: 15}}, + yaxis: {domain: [0, 1], autorange: 'reversed', domainpad: {top: 22}} + } + }); + }) + .then(function() { + check('date and reversed'); + return plot({ + data: [{y: [1, 2, 3]}, {y: [3, 2, 1], xaxis: 'x2', yaxis: 'y2'}], + layout: { + xaxis: {domain: [0, 1], anchor: 'y'}, + yaxis: {domain: [0, 1], anchor: 'x', domainpad: {top: 24}}, + xaxis2: {domain: [0.6, 0.95], anchor: 'y2', domainpad: {left: 10, right: 10}}, + yaxis2: {domain: [0.6, 0.95], anchor: 'x2', domainpad: {top: 12, bottom: 6}} + } + }); + }) + .then(function() { + check('inset'); + return plot({ + data: [ + {y: [1, 2, 3]}, + {y: [2, 1, 3], xaxis: 'x2', yaxis: 'y2'}, + {y: [3, 1, 2], xaxis: 'x3', yaxis: 'y3'}, + {y: [1, 3, 2], xaxis: 'x4', yaxis: 'y4'} + ], + layout: { + grid: {rows: 2, columns: 2, pattern: 'independent'}, + yaxis: {domainpad: {top: 20}}, + yaxis2: {domainpad: {top: 20}}, + yaxis3: {domainpad: {top: 20}}, + yaxis4: {domainpad: {top: 20}} + } + }); + }) + .then(function() { + check('layout.grid'); + // pads far bigger than the band they sit in + return plot({ + data: [{y: [1, 2, 3]}], + layout: { + xaxis: {domain: [0, 0.05], domainpad: {left: 400, right: 400}}, + yaxis: {domain: [0, 0.05], domainpad: {top: 400, bottom: 400}} + } + }); + }) + .then(function() { + check('pad bigger than the band'); + }) + .then(done, done.fail); + }); + + it('should leave every number exactly as it was when no padding is set', function(done) { + // The pad has to be a true no-op at its default. Rebuilding the plot rect + // from _offset and _length instead of adding the pad to `domain` shifted + // webgl output by a fraction of a pixel on unpadded plots, which is enough + // to move rasterised markers and fail an image baseline. Compare with ===, + // not toBeCloseTo, because that is the size of the mistake being guarded. + Plotly.newPlot(gd, [ + {y: [1, 2, 3]}, + {y: [2, 1, 3], xaxis: 'x2', yaxis: 'y2'} + ], { + width: 600, height: 500, margin: {l: 80, r: 80, t: 100, b: 80}, + xaxis: {domain: [0, 0.3103448275862069], anchor: 'y'}, + yaxis: {domain: [0.6896551724137931, 1], anchor: 'x'}, + xaxis2: {domain: [0.3448275862068966, 0.6551724137931034], anchor: 'y2'}, + yaxis2: {domain: [0, 0.3103448275862069], anchor: 'x2'} + }) + .then(function() { + var fl = gd._fullLayout; + var gs = fl._size; + + ['xaxis', 'yaxis', 'xaxis2', 'yaxis2'].forEach(function(name) { + var ax = fl[name]; + var isY = name.charAt(0) === 'y'; + var wantOffset = isY ? + gs.t + (1 - ax.domain[1]) * gs.h : + gs.l + ax.domain[0] * gs.w; + var wantLength = (isY ? gs.h : gs.w) * (ax.domain[1] - ax.domain[0]); + + expect(ax._offset).withContext(name + ' offset').toBe(wantOffset); + expect(ax._length).withContext(name + ' length').toBe(wantLength); + expect(ax._padStart).withContext(name + ' padStart').toBe(0); + expect(ax._padEnd).withContext(name + ' padEnd').toBe(0); + }); + }) + .then(done, done.fail); + }); + + it('should not drift when the axis gets rescaled again and again', function(done) { + // setScale runs several times per draw, so the padding has to be read + // fresh each time rather than piled onto the previous result + function offsetAndLength() { + var ya2 = gd._fullLayout.yaxis2; + return [ya2._offset, ya2._length]; + } + var first; + + Plotly.newPlot(gd, twoRowsData, twoRows({ + yaxis2: {domain: [0.51, 1], anchor: 'x2', domainpad: {top: 28}} + })) + .then(function() { + first = offsetAndLength(); + return Plotly.relayout(gd, {'yaxis2.range': [0, 5]}); + }) + .then(function() { + expect(offsetAndLength()).toBeCloseToArray(first, 2); + return Plotly.relayout(gd, {'yaxis2.autorange': true}); + }) + .then(function() { + expect(offsetAndLength()).toBeCloseToArray(first, 2); + return Plotly.restyle(gd, {y: [[5, 1, 9]]}, [1]); + }) + .then(function() { + expect(offsetAndLength()).toBeCloseToArray(first, 2); + }) + .then(done, done.fail); + }); +}); diff --git a/test/plot-schema.json b/test/plot-schema.json index 78614b86693..7e9cb741a01 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -13773,6 +13773,39 @@ ], "valType": "info_array" }, + "domainpad": { + "bottom": { + "description": "Pixels of space to reserve inside the bottom edge of the domain. Ignored on x axes.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + }, + "description": "Reserves space inside the edges of `domain`, in pixels. Because `domain` is a plot fraction, the space it leaves between subplots grows and shrinks with the figure. `domainpad` stays the same size at any figure height or width, which is what you want for anything sized in pixels such as a subplot title. x axes use `left` and `right`, y axes use `top` and `bottom`. If the padding asks for more room than the domain has, it is scaled down so the subplot keeps a usable size.", + "editType": "plot", + "left": { + "description": "Pixels of space to reserve inside the left edge of the domain. Ignored on y axes.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + }, + "right": { + "description": "Pixels of space to reserve inside the right edge of the domain. Ignored on y axes.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + }, + "role": "object", + "top": { + "description": "Pixels of space to reserve inside the top edge of the domain. Ignored on x axes.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + } + }, "dtick": { "description": "Sets the step in-between ticks on this axis. Use with `tick0`. Must be a positive number, or special strings available to *log* and *date* axes. If the axis `type` is *log*, then ticks are set every 10^(n*dtick) where n is the tick number. For example, to set a tick mark at 1, 10, 100, 1000, ... set dtick to 1. To set tick marks at 1, 100, 10000, ... set dtick to 2. To set tick marks at 1, 5, 25, 125, 625, 3125, ... set dtick to log_10(5), or 0.69897000433. *log* has several special values; *L*, where `f` is a positive number, gives ticks linearly spaced in value (but not position). For example `tick0` = 0.1, `dtick` = *L0.5* will put ticks at 0.1, 0.6, 1.1, 1.6 etc. To show powers of 10 plus small digits between, use *D1* (all digits) or *D2* (only 2 and 5). `tick0` is ignored for *D1* and *D2*. If the axis `type` is *date*, then you must convert the time to milliseconds. For example, to set the interval between ticks to one day, set `dtick` to 86400000.0. *date* also has special values *M* gives ticks spaced by a number of months. `n` must be a positive integer. To set ticks on the 15th of every third month, set `tick0` to *2000-01-15* and `dtick` to *M3*. To set ticks every 4 years, set `dtick` to *M48*", "editType": "ticks", @@ -15369,6 +15402,39 @@ ], "valType": "info_array" }, + "domainpad": { + "bottom": { + "description": "Pixels of space to reserve inside the bottom edge of the domain. Ignored on x axes.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + }, + "description": "Reserves space inside the edges of `domain`, in pixels. Because `domain` is a plot fraction, the space it leaves between subplots grows and shrinks with the figure. `domainpad` stays the same size at any figure height or width, which is what you want for anything sized in pixels such as a subplot title. x axes use `left` and `right`, y axes use `top` and `bottom`. If the padding asks for more room than the domain has, it is scaled down so the subplot keeps a usable size.", + "editType": "plot", + "left": { + "description": "Pixels of space to reserve inside the left edge of the domain. Ignored on y axes.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + }, + "right": { + "description": "Pixels of space to reserve inside the right edge of the domain. Ignored on y axes.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + }, + "role": "object", + "top": { + "description": "Pixels of space to reserve inside the top edge of the domain. Ignored on x axes.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + } + }, "dtick": { "description": "Sets the step in-between ticks on this axis. Use with `tick0`. Must be a positive number, or special strings available to *log* and *date* axes. If the axis `type` is *log*, then ticks are set every 10^(n*dtick) where n is the tick number. For example, to set a tick mark at 1, 10, 100, 1000, ... set dtick to 1. To set tick marks at 1, 100, 10000, ... set dtick to 2. To set tick marks at 1, 5, 25, 125, 625, 3125, ... set dtick to log_10(5), or 0.69897000433. *log* has several special values; *L*, where `f` is a positive number, gives ticks linearly spaced in value (but not position). For example `tick0` = 0.1, `dtick` = *L0.5* will put ticks at 0.1, 0.6, 1.1, 1.6 etc. To show powers of 10 plus small digits between, use *D1* (all digits) or *D2* (only 2 and 5). `tick0` is ignored for *D1* and *D2*. If the axis `type` is *date*, then you must convert the time to milliseconds. For example, to set the interval between ticks to one day, set `dtick` to 86400000.0. *date* also has special values *M* gives ticks spaced by a number of months. `n` must be a positive integer. To set ticks on the 15th of every third month, set `tick0` to *2000-01-15* and `dtick` to *M3*. To set ticks every 4 years, set `dtick` to *M48*", "editType": "ticks",