From 33bb9f7cde53863804117b232a2abd6833f4d2ef Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Fri, 14 Aug 2026 10:39:33 +0530 Subject: [PATCH 1/4] add domainpad for pixel spacing inside axis domains `domain` is a plot fraction, so the gap it leaves between subplots grows and shrinks with the figure while a subplot title does not. domainpad reserves a fixed number of pixels inside the domain edges instead, resolved at draw time. Applied in ax.setScale to _offset and _length rather than to ax.domain, so the domain keeps meaning what the user typed and anything referenced to ` domain` follows the padded area without new drawing code. Along with it: - scattergl, splom and the rangeslider rebuilt the plot rect from `domain` and so did not see the pad; they now read _offset and _length - pads that do not fit are backed off together, since setScale already throws on a negative length - updateDomain scales the padded length rather than the raw span, so a scaleanchor ratio stays exact when the constraint shrinks a padded axis Refs #7835 --- src/components/rangeslider/draw.js | 8 +- src/plots/cartesian/constraints.js | 41 ++- src/plots/cartesian/layout_attributes.js | 41 +++ src/plots/cartesian/position_defaults.js | 11 + src/plots/cartesian/set_convert.js | 49 ++- src/traces/scattergl/plot.js | 29 +- src/traces/splom/base_plot.js | 7 +- src/traces/splom/plot.js | 16 +- test/jasmine/tests/axes_test.js | 431 +++++++++++++++++++++++ test/plot-schema.json | 66 ++++ 10 files changed, 664 insertions(+), 35 deletions(-) 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/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..b4a998368ed 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,31 @@ 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; + 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..6d8e8d5e0a2 100644 --- a/src/traces/scattergl/plot.js +++ b/src/traces/scattergl/plot.js @@ -17,21 +17,22 @@ var styleTextSelection = require('./edit_style').styleTextSelection; var reglPrecompiled = {}; function getViewport(fullLayout, xaxis, yaxis, plotGlPixelRatio) { - var gs = fullLayout._size; - var width = fullLayout.width * plotGlPixelRatio; - var height = fullLayout.height * plotGlPixelRatio; - - var l = gs.l * plotGlPixelRatio; - var b = gs.b * plotGlPixelRatio; - var r = gs.r * plotGlPixelRatio; - var t = gs.t * plotGlPixelRatio; - var w = gs.w * plotGlPixelRatio; - var h = gs.h * plotGlPixelRatio; + // This is the same rectangle the svg side draws into, only measured up from the + // bottom of the figure instead of down from the top. Read it off _offset and + // _length rather than working it out from `domain` again, so that anything which + // moves the plot area by a pixel amount - `domainpad`, say - lands here as well. + var height = fullLayout.height; + + var left = xaxis._offset; + var right = xaxis._offset + xaxis._length; + var bottom = height - (yaxis._offset + yaxis._length); + var top = height - yaxis._offset; + 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 + left * plotGlPixelRatio, + bottom * plotGlPixelRatio, + right * plotGlPixelRatio, + top * plotGlPixelRatio ]; } diff --git a/src/traces/splom/base_plot.js b/src/traces/splom/base_plot.js index a3486e64514..610736f318d 100644 --- a/src/traces/splom/base_plot.js +++ b/src/traces/splom/base_plot.js @@ -90,7 +90,6 @@ function updateGrid(gd) { function makeGridData(gd) { var plotGlPixelRatio = gd._context.plotGlPixelRatio; var fullLayout = gd._fullLayout; - var gs = fullLayout._size; var fullView = [ 0, 0, fullLayout.width * plotGlPixelRatio, @@ -135,8 +134,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. + // Flip the axis' own bottom edge rather than rebuilding it from `domain`, + // so pixel adjustments to the plot area such as `domainpad` come along: + var yOffset = fullLayout.height - (ya._offset + ya._length); 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..876094868b7 100644 --- a/src/traces/splom/plot.js +++ b/src/traces/splom/plot.js @@ -41,6 +41,14 @@ 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. Derive those fractions from where the axes actually ended up + // rather than from `domain`, otherwise anything that shifts the plot area in + // pixels - `domainpad` - would move the axes but leave the points behind. + // regl counts y up from the bottom, so the y pair comes back reversed. + function xFraction(px) { return (px - gs.l) / gs.w; } + function yFraction(px) { return (fullLayout.height - px - gs.b) / gs.h; } + for(k = 0; k < visibleDims.length; k++) { i = visibleDims[k]; @@ -51,16 +59,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] = xFraction(xa._offset); + dmn[2] = xFraction(xa._offset + xa._length); } 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] = yFraction(ya._offset + ya._length); + dmn[3] = yFraction(ya._offset); } } diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 15eca358dfe..7c887517af9 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -8502,3 +8502,434 @@ 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 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", From 3065471b9ce5e26dc3c53d15a08e20a2013cb50c Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Fri, 14 Aug 2026 10:41:04 +0530 Subject: [PATCH 2/4] add draftlog for #7965 --- draftlogs/7965_add.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7965_add.md diff --git a/draftlogs/7965_add.md b/draftlogs/7965_add.md new file mode 100644 index 00000000000..7267aa0e0d0 --- /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)], with thanks to @SharadhNaidu for the contribution! From a1d31d1b4969a6619269285e3c65503248b43147 Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Fri, 14 Aug 2026 10:46:41 +0530 Subject: [PATCH 3/4] Update commit message for domainpad feature Removed attribution from the commit message. --- draftlogs/7965_add.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/draftlogs/7965_add.md b/draftlogs/7965_add.md index 7267aa0e0d0..572bd45875c 100644 --- a/draftlogs/7965_add.md +++ b/draftlogs/7965_add.md @@ -1 +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)], with thanks to @SharadhNaidu for the contribution! +- 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)] From 89d011d5edc3317b2c565b41ec45a0622f6b452e Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Fri, 14 Aug 2026 13:32:16 +0530 Subject: [PATCH 4/4] keep domainpad a bit for bit no-op when it is not set The three places that build the plot rect from `domain` were changed to read _offset and _length instead. That round trips through pixel space, and the result is not exactly the value it started from: splom cell domains came back about one ulp out, which moved rasterised markers enough to fail the splom_*-nodiag image baselines even though no padding was involved. Add the resolved padding to the original expressions instead, and expose it as ax._padStart / ax._padEnd for those callers. Adding a literal zero is exact, so an unpadded plot now produces the same numbers it always did. Also list domainpad in swapAxisGroup's noSwapAttrs, next to domain: its keys are named for screen edges, so swapping x and y would need them remapped. --- src/plots/cartesian/axes.js | 5 +++- src/plots/cartesian/set_convert.js | 6 +++++ src/traces/scattergl/plot.js | 37 +++++++++++++++++++----------- src/traces/splom/base_plot.js | 7 +++--- src/traces/splom/plot.js | 19 ++++++++------- test/jasmine/tests/axes_test.js | 37 ++++++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 28 deletions(-) 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/set_convert.js b/src/plots/cartesian/set_convert.js index b4a998368ed..3190c6a00a7 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -618,6 +618,12 @@ module.exports = function setConvert(ax, fullLayout) { } 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 + padStart; diff --git a/src/traces/scattergl/plot.js b/src/traces/scattergl/plot.js index 6d8e8d5e0a2..bf1e06bc732 100644 --- a/src/traces/scattergl/plot.js +++ b/src/traces/scattergl/plot.js @@ -17,22 +17,31 @@ var styleTextSelection = require('./edit_style').styleTextSelection; var reglPrecompiled = {}; function getViewport(fullLayout, xaxis, yaxis, plotGlPixelRatio) { - // This is the same rectangle the svg side draws into, only measured up from the - // bottom of the figure instead of down from the top. Read it off _offset and - // _length rather than working it out from `domain` again, so that anything which - // moves the plot area by a pixel amount - `domainpad`, say - lands here as well. - var height = fullLayout.height; - - var left = xaxis._offset; - var right = xaxis._offset + xaxis._length; - var bottom = height - (yaxis._offset + yaxis._length); - var top = height - yaxis._offset; + var gs = fullLayout._size; + var width = fullLayout.width * plotGlPixelRatio; + var height = fullLayout.height * plotGlPixelRatio; + + var l = gs.l * plotGlPixelRatio; + var b = gs.b * plotGlPixelRatio; + var r = gs.r * 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 [ - left * plotGlPixelRatio, - bottom * plotGlPixelRatio, - right * plotGlPixelRatio, - top * plotGlPixelRatio + 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 610736f318d..5624ad8f0ff 100644 --- a/src/traces/splom/base_plot.js +++ b/src/traces/splom/base_plot.js @@ -90,6 +90,7 @@ function updateGrid(gd) { function makeGridData(gd) { var plotGlPixelRatio = gd._context.plotGlPixelRatio; var fullLayout = gd._fullLayout; + var gs = fullLayout._size; var fullView = [ 0, 0, fullLayout.width * plotGlPixelRatio, @@ -135,9 +136,9 @@ function makeGridData(gd) { // ya.l2p assumes top-to-bottom coordinate system (a la SVG), // we need to compute bottom-to-top offsets and slopes. - // Flip the axis' own bottom edge rather than rebuilding it from `domain`, - // so pixel adjustments to the plot area such as `domainpad` come along: - var yOffset = fullLayout.height - (ya._offset + ya._length); + // `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 876094868b7..d6f585502fe 100644 --- a/src/traces/splom/plot.js +++ b/src/traces/splom/plot.js @@ -42,12 +42,11 @@ function plotOne(gd, cd0) { viewOpts.domains = new Array(visibleLength); // regl-splom places each cell as a fraction of the viewport below, which is the - // whole plot area. Derive those fractions from where the axes actually ended up - // rather than from `domain`, otherwise anything that shifts the plot area in - // pixels - `domainpad` - would move the axes but leave the points behind. - // regl counts y up from the bottom, so the y pair comes back reversed. - function xFraction(px) { return (px - gs.l) / gs.w; } - function yFraction(px) { return (fullLayout.height - px - gs.b) / gs.h; } + // 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]; @@ -59,16 +58,16 @@ function plotOne(gd, cd0) { if(xa) { rng[0] = xa._rl[0]; rng[2] = xa._rl[1]; - dmn[0] = xFraction(xa._offset); - dmn[2] = xFraction(xa._offset + xa._length); + 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] = yFraction(ya._offset + ya._length); - dmn[3] = yFraction(ya._offset); + 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 7c887517af9..2028685d742 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -8903,6 +8903,43 @@ describe('axis domainpad', function() { .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