From 33bb9f7cde53863804117b232a2abd6833f4d2ef Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Fri, 14 Aug 2026 10:39:33 +0530 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 From 15f861b230c446807826d785cf0d72b22e06e4df Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Tue, 20 Oct 2026 12:08:27 +0530 Subject: [PATCH 05/11] keep scaleanchor domains bit-identical when domainpad is not set updateDomain multiplied the domain by (newSpan / inputSpan). Without padding, that ratio is 1 / factor, but the multiply can change the last bit of the result, so a plot with no domainpad could move by a tiny amount. Divide by `factor` as main does, and change the divisor only when the axis has padding. Without domainpad, the result is now the same as on main. --- src/plots/cartesian/constraints.js | 44 ++++++++++++++---------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js index ecaa543b744..d9afff3ff8e 100644 --- a/src/plots/cartesian/constraints.js +++ b/src/plots/cartesian/constraints.js @@ -633,13 +633,13 @@ exports.clean = function clean(gd, ax) { } }; -// `domainpad` in domain fractions rather than pixels, which is the unit -// everything around the constraint solve is expressed in. +// Get the `domainpad` of an axis as a fraction of the plot size. +// The constraint solver works in domain fractions, not in pixels. function domainPadFraction(ax, fullLayout) { - var pad = ax.domainpad; + const pad = ax.domainpad; if(!pad) return 0; - var gs = fullLayout._size; + const 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; @@ -647,27 +647,25 @@ function domainPadFraction(ax, fullLayout) { function updateDomain(ax, factor, fullLayout) { var inputDomain = ax._inputDomain; - 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; + var centerFraction = FROM_BL[ax.constraintoward]; + var center = inputDomain[0] + (inputDomain[1] - inputDomain[0]) * centerFraction; + + // The constraint divides the drawn length of the axis by `factor`. Without padding, + // the drawn length is the domain span. `domainpad` removes a fixed number of pixels + // from the ends, so the drawn length is (span - padding), and only that part scales. + // Thus divide that part, then add the padding back. If the padding covers the full + // domain, nothing remains to scale, so divide the full span and let setScale clamp the padding. + let divisor = factor; + const padFraction = domainPadFraction(ax, fullLayout); + if(padFraction > 0) { + const inputSpan = inputDomain[1] - inputDomain[0]; + const drawnSpan = inputSpan - padFraction; + if(drawnSpan > 0) divisor = inputSpan / (drawnSpan / factor + padFraction); + } ax.domain = ax._input.domain = [ - center + (inputDomain[0] - center) * scale, - center + (inputDomain[1] - center) * scale + center + (inputDomain[0] - center) / divisor, + center + (inputDomain[1] - center) / divisor ]; ax.setScale(); } From 813c14910a6ee31623b69b125b11dd87e96d0e3b Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Tue, 20 Oct 2026 12:08:28 +0530 Subject: [PATCH 06/11] reword domainpad comments and descriptions, and regenerate the schema and types The comments and attribute descriptions now follow the Simplified Technical English rules in AGENTS.md. Lines that this PR touches now use const, let and arrow functions. There is no change in behavior. The regenerated schema.d.ts and plot-schema.json fix the generated-types-drift check, which failed because the types did not include domainpad. --- src/components/rangeslider/draw.js | 7 +- src/plots/cartesian/axes.js | 4 +- src/plots/cartesian/layout_attributes.js | 23 ++- src/plots/cartesian/position_defaults.js | 5 +- src/plots/cartesian/set_convert.js | 52 +++--- src/traces/scattergl/plot.js | 15 +- src/traces/splom/base_plot.js | 8 +- src/traces/splom/plot.js | 11 +- src/types/generated/schema.d.ts | 27 +++ test/jasmine/tests/axes_test.js | 222 +++++++++++------------ test/plot-schema.json | 20 +- 11 files changed, 207 insertions(+), 187 deletions(-) diff --git a/src/components/rangeslider/draw.js b/src/components/rangeslider/draw.js index 8ee8d70a844..5e870145afd 100644 --- a/src/components/rangeslider/draw.js +++ b/src/components/rangeslider/draw.js @@ -101,12 +101,11 @@ module.exports = function(gd) { var gs = fullLayout._size; - // 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` + // The slider must line up with the plot area above it. `domainpad` moves the + // plot area but not `domain`, so get the width and position from the axis. opts._width = axisOpts._length; - var x = Math.round(axisOpts._offset); + const 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 2322933a769..6013f8f2826 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -4683,8 +4683,8 @@ function swapAxisGroup(gd, xIds, yIds) { var allAxKeys = Object.keys(axAttrs); var noSwapAttrs = [ - // 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 + // domainpad stays with domain: its keys name screen edges, so a swap of x and y + // would need to map left and right to top and bottom 'anchor', 'domain', 'domainpad', 'overlaying', 'position', 'side', 'tickangle', 'editType' ]; diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 316670a8935..b64b5804e10 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -1171,39 +1171,38 @@ module.exports = { min: 0, dflt: 0, editType: 'plot', - description: 'Pixels of space to reserve inside the left edge of the domain. Ignored on y axes.' + description: 'Sets the space in pixels to reserve inside the left edge of `domain`. Applies to x axes only.' }, 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.' + description: 'Sets the space in pixels to reserve inside the right edge of `domain`. Applies to x axes only.' }, 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.' + description: 'Sets the space in pixels to reserve inside the top edge of `domain`. Applies to y axes only.' }, 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.' + description: 'Sets the space in pixels to reserve inside the bottom edge of `domain`. Applies to y axes only.' }, 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.' + 'Sets the space in pixels to reserve inside the edges of `domain`.', + 'Because `domain` is a fraction of the plot area, the gap between subplots', + 'changes with the figure size. `domainpad` keeps the same size in pixels at all figure sizes,', + 'so it can hold content with a fixed size, such as a subplot title.', + 'x axes use `left` and `right`. y axes use `top` and `bottom`.', + 'If the padding needs more room than the domain has, the two pads shrink by the same factor,', + 'and the plot area keeps a length of at least 2 pixels.' ].join(' ') }, position: { diff --git a/src/plots/cartesian/position_defaults.js b/src/plots/cartesian/position_defaults.js index c72292627a1..72fbc4e5042 100644 --- a/src/plots/cartesian/position_defaults.js +++ b/src/plots/cartesian/position_defaults.js @@ -84,9 +84,8 @@ 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. + // Only the two sides along this axis have an effect. Do not coerce the other two, + // so that a value with no effect never gets into fullLayout. if(letter === 'x') { coerce('domainpad.left'); coerce('domainpad.right'); diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index 3190c6a00a7..fa072e736f7 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -35,24 +35,25 @@ 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. +// The smallest plot area in pixels that `domainpad` can leave. `doAutoMargin` keeps +// the same floor for margins. A floor of 0 is not sufficient: setScale rejects only +// negative lengths, so a length of 0 gives a slope of 0 and the subplot collapses with no error. +const MIN_PADDED_LENGTH = 2; + +/** + * Get the part of the requested `domainpad` that fits in a domain band. + * + * `domain` is a fraction of the plot area, so its band shrinks with the figure but the + * padding does not. If the two pads need more room than the band has, the two pads + * get the same scale factor. `doAutoMargin` uses the same method for margins. * - * `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. + * @param wanted - the total padding of the two edges, in pixels + * @param bandLength - the length of the domain band, in pixels + * @returns the factor from 0 to 1 to multiply each pad by */ function padFactor(wanted, bandLength) { if(wanted <= 0) return 0; - var room = Math.max(0, bandLength - MIN_PADDED_LENGTH); + const room = Math.max(0, bandLength - MIN_PADDED_LENGTH); return wanted > room ? room / wanted : 1; } @@ -600,28 +601,27 @@ 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; + // the length in pixels of the domain band, before `domainpad` + const bandLength = (isY ? gs.h : gs.w) * (ax.domain[1] - ax.domain[0]); + const pad = ax.domainpad; + // _offset counts from the top for y and from the left for x, + // so padStart moves the plot area in from that edge + let padStart = 0; + let padEnd = 0; if(pad) { padStart = (isY ? pad.top : pad.left) || 0; padEnd = (isY ? pad.bottom : pad.right) || 0; - var fits = padFactor(padStart + padEnd, bandLength); + const 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. + // Keep the padding after the fit. scattergl, splom and hover spikes add these + // values to their `domain` math. A rebuild from _offset and _length would change + // the last bits of the result, also for an axis with no padding. ax._padStart = padStart; ax._padEnd = padEnd; diff --git a/src/traces/scattergl/plot.js b/src/traces/scattergl/plot.js index 44392fcb2a0..005992c4b0b 100644 --- a/src/traces/scattergl/plot.js +++ b/src/traces/scattergl/plot.js @@ -29,14 +29,13 @@ function getViewport(fullLayout, xaxis, yaxis, 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; + // `domain` does not include `domainpad`, so add the padding to the rect here. + // A rebuild from _offset and _length would move the rect by a fraction of a pixel, + // also for an axis with no padding. + const padL = (xaxis._padStart || 0) * plotGlPixelRatio; + const padR = (xaxis._padEnd || 0) * plotGlPixelRatio; + const padT = (yaxis._padStart || 0) * plotGlPixelRatio; + const padB = (yaxis._padEnd || 0) * plotGlPixelRatio; return [ l + xaxis.domain[0] * w + padL, diff --git a/src/traces/splom/base_plot.js b/src/traces/splom/base_plot.js index 2582912383a..c789b758cff 100644 --- a/src/traces/splom/base_plot.js +++ b/src/traces/splom/base_plot.js @@ -136,10 +136,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. - // `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); + // we need to compute bottom-to-top offsets and slopes: + // add the bottom `domainpad` to the domain math, because a read of _offset + // would move an axis with no padding by a fraction of a pixel + const 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 d6f585502fe..4081795e0f2 100644 --- a/src/traces/splom/plot.js +++ b/src/traces/splom/plot.js @@ -41,12 +41,11 @@ 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; } + // regl-splom places each cell as a fraction of the full plot area. Convert `domainpad` + // to the same fractions, or the cells keep their size while the axes move. Add the + // padding to `domain` here, because a rebuild from _offset and _length moves every + // cell a little, also for an axis with no padding. + const padFrac = (px, total) => (px || 0) / total; for(k = 0; k < visibleDims.length; k++) { i = visibleDims[k]; diff --git a/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index 8a855665e73..212575504c4 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -12199,6 +12199,33 @@ export interface LayoutAxis { * @default [0,1] */ domain?: [number, number]; + /** Sets the space in pixels to reserve inside the edges of `domain`. Because `domain` is a fraction of the plot area, the gap between subplots changes with the figure size. `domainpad` keeps the same size in pixels at all figure sizes, so it can hold content with a fixed size, such as a subplot title. x axes use `left` and `right`. y axes use `top` and `bottom`. If the padding needs more room than the domain has, the two pads shrink by the same factor, and the plot area keeps a length of at least 2 pixels. */ + domainpad?: { + /** + * Sets the space in pixels to reserve inside the bottom edge of `domain`. Applies to y axes only. + * @default 0 + * Minimum: 0 + */ + bottom?: number; + /** + * Sets the space in pixels to reserve inside the left edge of `domain`. Applies to x axes only. + * @default 0 + * Minimum: 0 + */ + left?: number; + /** + * Sets the space in pixels to reserve inside the right edge of `domain`. Applies to x axes only. + * @default 0 + * Minimum: 0 + */ + right?: number; + /** + * Sets the space in pixels to reserve inside the top edge of `domain`. Applies to y axes only. + * @default 0 + * Minimum: 0 + */ + top?: number; + }; /** * 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* * Setting this also sets: tickmode = "linear" diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 7882e3c5322..3b3aad46ff4 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -8546,10 +8546,10 @@ describe('test tickmode calculator', function() { }); }); -describe('axis domainpad', function() { - var gd; +describe('axis domainpad', () => { + let gd; - beforeEach(function() { gd = createGraphDiv(); }); + beforeEach(() => { gd = createGraphDiv(); }); afterEach(destroyGraphDiv); // two stacked subplots with a small gap between them - the shape that makes @@ -8566,16 +8566,16 @@ describe('axis domainpad', function() { }, layoutPatch || {}); } - var twoRowsData = [ + const 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) { + it('should default every side to zero and leave the plot area alone', (done) => { Plotly.newPlot(gd, twoRowsData, twoRows()) - .then(function() { - var xa = gd._fullLayout.xaxis; - var ya = gd._fullLayout.yaxis; + .then(() => { + const xa = gd._fullLayout.xaxis; + const ya = gd._fullLayout.yaxis; expect(xa.domainpad.left).toBe(0); expect(xa.domainpad.right).toBe(0); @@ -8589,12 +8589,12 @@ describe('axis domainpad', function() { .then(done, done.fail); }); - it('should only coerce the two sides that point along the axis', function(done) { + it('should only coerce the two sides that point along the axis', (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() { + .then(() => { // the sides that mean something are kept expect(gd._fullLayout.xaxis.domainpad.left).toBe(10); expect(gd._fullLayout.yaxis.domainpad.top).toBe(20); @@ -8605,26 +8605,26 @@ describe('axis domainpad', function() { .then(done, done.fail); }); - it('should take the padding off the plot area, one side at a time', function(done) { - var basicOffset, basicLength; + it('should take the padding off the plot area, one side at a time', (done) => { + let basicOffset, basicLength; Plotly.newPlot(gd, twoRowsData, twoRows()) - .then(function() { + .then(() => { 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; + .then(() => { + const 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; + .then(() => { + const 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); @@ -8632,52 +8632,52 @@ describe('axis domainpad', function() { .then(done, done.fail); }); - it('should pad x axes from the left and the right', function(done) { - var basicOffset, basicLength; + it('should pad x axes from the left and the right', (done) => { + let basicOffset, basicLength; Plotly.newPlot(gd, twoRowsData, twoRows()) - .then(function() { + .then(() => { 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; + .then(() => { + const 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) { + it('should reserve the same pixels whatever the figure height is', (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() { + .then(() => { expect(gd._fullLayout.yaxis2._offset).toBeCloseTo(48, 2); return Plotly.relayout(gd, {height: 300}); }) - .then(function() { + .then(() => { expect(gd._fullLayout.yaxis2._offset).toBeCloseTo(48, 2); return Plotly.relayout(gd, {height: 1200}); }) - .then(function() { + .then(() => { 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) { + it('should scale the padding back rather than run the plot area down to nothing', (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}}; + const data = []; + const 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); + for(let i = 0; i < 6; i++) { + const 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)] = { @@ -8688,10 +8688,10 @@ describe('axis domainpad', function() { } Plotly.newPlot(gd, data, layout) - .then(function() { - for(var j = 1; j <= 6; j++) { + .then(() => { + for(let j = 1; j <= 6; j++) { // the first axis is stored as `yaxis`, not `yaxis1` - var ya = gd._fullLayout[j === 1 ? 'yaxis' : 'yaxis' + j]; + const ya = gd._fullLayout[j === 1 ? 'yaxis' : 'yaxis' + j]; expect(ya._length).toBeGreaterThan(0); expect(isFinite(ya._m)).toBe(true); } @@ -8699,7 +8699,7 @@ describe('axis domainpad', function() { .then(done, done.fail); }); - it('should give an overlaying axis the same padding as the axis underneath', function(done) { + it('should give an overlaying axis the same padding as the axis underneath', (done) => { Plotly.newPlot(gd, [ {y: [1, 2, 3]}, {y: [4, 5, 6], yaxis: 'y2'} @@ -8710,9 +8710,9 @@ describe('axis domainpad', function() { yaxis: {domain: [0, 1], domainpad: {top: 30}}, yaxis2: {overlaying: 'y', side: 'right'} }) - .then(function() { - var ya = gd._fullLayout.yaxis; - var ya2 = gd._fullLayout.yaxis2; + .then(() => { + const ya = gd._fullLayout.yaxis; + const ya2 = gd._fullLayout.yaxis2; // the two have to end up on exactly the same plot area expect(ya2._offset).toBeCloseTo(ya._offset, 2); @@ -8721,7 +8721,7 @@ describe('axis domainpad', function() { .then(done, done.fail); }); - it('should let a domain referenced annotation sit in the space it reserved', function(done) { + it('should let a domain referenced annotation sit in the space it reserved', (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}}, @@ -8734,13 +8734,13 @@ describe('axis domainpad', function() { font: {size: 16} }] })) - .then(function() { - var ya2 = gd._fullLayout.yaxis2; - var annBox = d3Select(gd).select('.annotation').node().getBoundingClientRect(); - var gdBox = gd.getBoundingClientRect(); + .then(() => { + const ya2 = gd._fullLayout.yaxis2; + const annBox = d3Select(gd).select('.annotation').node().getBoundingClientRect(); + const gdBox = gd.getBoundingClientRect(); - var annTop = annBox.top - gdBox.top; - var annBottom = annBox.bottom - gdBox.top; + const annTop = annBox.top - gdBox.top; + const annBottom = annBox.bottom - gdBox.top; // the title ends where the plot area starts ... expect(annBottom).toBeCloseTo(ya2._offset, 0); @@ -8750,7 +8750,7 @@ describe('axis domainpad', function() { .then(done, done.fail); }); - it('should keep a scaleanchor ratio exact when the padded axis is not the one that shrinks', function(done) { + it('should keep a scaleanchor ratio exact when the padded axis is not the one that shrinks', (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. @@ -8763,20 +8763,19 @@ describe('axis domainpad', function() { domainpad: {top: 60} } }) - .then(function() { - var xa = gd._fullLayout.xaxis; - var ya = gd._fullLayout.yaxis; + .then(() => { + const xa = gd._fullLayout.xaxis; + const 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 + it('should keep a scaleanchor ratio exact when the domain constraint shrinks the padded axis', (done) => { + // The solver shrinks a domain fraction, and it expects the drawn length to change + // in the same proportion. domainpad removes a fixed number of pixels from the end, + // so only (span - padding) scales. updateDomain must divide only that part, + // or the ratio comes out about 2.3% too wide. 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: { @@ -8788,15 +8787,15 @@ describe('axis domainpad', function() { scaleanchor: 'x', scaleratio: 1 } }) - .then(function() { - var xa = gd._fullLayout.xaxis; - var ya = gd._fullLayout.yaxis; + .then(() => { + const xa = gd._fullLayout.xaxis; + const 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) { + it('should keep a rangeslider lined up with the padded x axis', (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]}], { @@ -8808,13 +8807,13 @@ describe('axis domainpad', function() { }, yaxis: {domain: [0, 1]} }) - .then(function() { - var xa = gd._fullLayout.xaxis; + .then(() => { + const 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(); + const bg = d3Select(gd).select('.rangeslider-bg').node(); + const bgBox = bg.getBoundingClientRect(); + const gdBox = gd.getBoundingClientRect(); expect(bgBox.left - gdBox.left).toBeCloseTo(xa._offset, 0); expect(bgBox.right - gdBox.left).toBeCloseTo(xa._offset + xa._length, 0); @@ -8822,22 +8821,22 @@ describe('axis domainpad', function() { .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. + it('should land in the same place whatever kind of axis it is', (done) => { + // domainpad is geometry only, so the axis type and the source of the domain must + // not change the result. Calculate the expected offset and length here, and do + // not read them back from the axis, so that the test does not repeat 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; + const isY = ax._id.charAt(0) === 'y'; + const pad = ax.domainpad || {}; + const band = (isY ? gs.h : gs.w) * (ax.domain[1] - ax.domain[0]); + let p0 = (isY ? pad.top : pad.left) || 0; + let 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); + const total = p0 + p1; + const maxTotal = Math.max(0, band - 2); if(total > maxTotal) { - var k = maxTotal / total; + const k = maxTotal / total; p0 *= k; p1 *= k; } @@ -8849,15 +8848,15 @@ describe('axis domainpad', function() { } function check(label) { - var fl = gd._fullLayout; - var gs = fl._size; + const fl = gd._fullLayout; + const gs = fl._size; - Object.keys(fl).forEach(function(key) { + Object.keys(fl).forEach((key) => { if(!/^[xy]axis[0-9]*$/.test(key)) return; - var ax = fl[key]; + const ax = fl[key]; if(ax._offset === undefined) return; - var want = expected(ax, gs); + const 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); @@ -8865,10 +8864,10 @@ describe('axis domainpad', function() { }); // and the subplot background has to sit exactly on its axes - for(var id in fl._plots) { - var sp = fl._plots[id]; + for(const id in fl._plots) { + const sp = fl._plots[id]; if(!sp.bg) continue; - var node = sp.bg.node(); + const 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); @@ -8876,7 +8875,7 @@ describe('axis domainpad', function() { } } - var base = {width: 800, height: 600, margin: {l: 50, r: 25, t: 25, b: 45}}; + const 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)); @@ -8889,7 +8888,7 @@ describe('axis domainpad', function() { xaxis: {domain: [0, 1], domainpad: {left: 20}} } }) - .then(function() { + .then(() => { check('log'); return plot({ data: [{x: ['2020-01-01', '2020-06-01', '2021-01-01'], y: [1, 2, 3]}], @@ -8899,7 +8898,7 @@ describe('axis domainpad', function() { } }); }) - .then(function() { + .then(() => { check('date and reversed'); return plot({ data: [{y: [1, 2, 3]}, {y: [3, 2, 1], xaxis: 'x2', yaxis: 'y2'}], @@ -8911,7 +8910,7 @@ describe('axis domainpad', function() { } }); }) - .then(function() { + .then(() => { check('inset'); return plot({ data: [ @@ -8929,7 +8928,7 @@ describe('axis domainpad', function() { } }); }) - .then(function() { + .then(() => { check('layout.grid'); // pads far bigger than the band they sit in return plot({ @@ -8940,18 +8939,17 @@ describe('axis domainpad', function() { } }); }) - .then(function() { + .then(() => { 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. + it('should leave every number exactly as it was when no padding is set', (done) => { + // At its default, the padding must have no effect at all. A rebuild of the plot rect + // from _offset and _length moves webgl output by a fraction of a pixel, + // and that is enough to move rasterized markers and fail an image baseline. + // Thus compare with ===, not with toBeCloseTo. Plotly.newPlot(gd, [ {y: [1, 2, 3]}, {y: [2, 1, 3], xaxis: 'x2', yaxis: 'y2'} @@ -8962,17 +8960,17 @@ describe('axis domainpad', function() { 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 ? + .then(() => { + const fl = gd._fullLayout; + const gs = fl._size; + + ['xaxis', 'yaxis', 'xaxis2', 'yaxis2'].forEach((name) => { + const ax = fl[name]; + const isY = name.charAt(0) === 'y'; + const 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]); + const 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); @@ -8983,31 +8981,31 @@ describe('axis domainpad', function() { .then(done, done.fail); }); - it('should not drift when the axis gets rescaled again and again', function(done) { + it('should not drift when the axis gets rescaled again and again', (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; + const ya2 = gd._fullLayout.yaxis2; return [ya2._offset, ya2._length]; } - var first; + let first; Plotly.newPlot(gd, twoRowsData, twoRows({ yaxis2: {domain: [0.51, 1], anchor: 'x2', domainpad: {top: 28}} })) - .then(function() { + .then(() => { first = offsetAndLength(); return Plotly.relayout(gd, {'yaxis2.range': [0, 5]}); }) - .then(function() { + .then(() => { expect(offsetAndLength()).toBeCloseToArray(first, 2); return Plotly.relayout(gd, {'yaxis2.autorange': true}); }) - .then(function() { + .then(() => { expect(offsetAndLength()).toBeCloseToArray(first, 2); return Plotly.restyle(gd, {y: [[5, 1, 9]]}, [1]); }) - .then(function() { + .then(() => { expect(offsetAndLength()).toBeCloseToArray(first, 2); }) .then(done, done.fail); diff --git a/test/plot-schema.json b/test/plot-schema.json index 3ced1124d11..af804a589e6 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -13380,23 +13380,23 @@ }, "domainpad": { "bottom": { - "description": "Pixels of space to reserve inside the bottom edge of the domain. Ignored on x axes.", + "description": "Sets the space in pixels to reserve inside the bottom edge of `domain`. Applies to y axes only.", "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.", + "description": "Sets the space in pixels to reserve inside the edges of `domain`. Because `domain` is a fraction of the plot area, the gap between subplots changes with the figure size. `domainpad` keeps the same size in pixels at all figure sizes, so it can hold content with a fixed size, such as a subplot title. x axes use `left` and `right`. y axes use `top` and `bottom`. If the padding needs more room than the domain has, the two pads shrink by the same factor, and the plot area keeps a length of at least 2 pixels.", "editType": "plot", "left": { - "description": "Pixels of space to reserve inside the left edge of the domain. Ignored on y axes.", + "description": "Sets the space in pixels to reserve inside the left edge of `domain`. Applies to x axes only.", "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.", + "description": "Sets the space in pixels to reserve inside the right edge of `domain`. Applies to x axes only.", "dflt": 0, "editType": "plot", "min": 0, @@ -13404,7 +13404,7 @@ }, "role": "object", "top": { - "description": "Pixels of space to reserve inside the top edge of the domain. Ignored on x axes.", + "description": "Sets the space in pixels to reserve inside the top edge of `domain`. Applies to y axes only.", "dflt": 0, "editType": "plot", "min": 0, @@ -14979,23 +14979,23 @@ }, "domainpad": { "bottom": { - "description": "Pixels of space to reserve inside the bottom edge of the domain. Ignored on x axes.", + "description": "Sets the space in pixels to reserve inside the bottom edge of `domain`. Applies to y axes only.", "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.", + "description": "Sets the space in pixels to reserve inside the edges of `domain`. Because `domain` is a fraction of the plot area, the gap between subplots changes with the figure size. `domainpad` keeps the same size in pixels at all figure sizes, so it can hold content with a fixed size, such as a subplot title. x axes use `left` and `right`. y axes use `top` and `bottom`. If the padding needs more room than the domain has, the two pads shrink by the same factor, and the plot area keeps a length of at least 2 pixels.", "editType": "plot", "left": { - "description": "Pixels of space to reserve inside the left edge of the domain. Ignored on y axes.", + "description": "Sets the space in pixels to reserve inside the left edge of `domain`. Applies to x axes only.", "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.", + "description": "Sets the space in pixels to reserve inside the right edge of `domain`. Applies to x axes only.", "dflt": 0, "editType": "plot", "min": 0, @@ -15003,7 +15003,7 @@ }, "role": "object", "top": { - "description": "Pixels of space to reserve inside the top edge of the domain. Ignored on x axes.", + "description": "Sets the space in pixels to reserve inside the top edge of `domain`. Applies to y axes only.", "dflt": 0, "editType": "plot", "min": 0, From 69870f0f0c7c40942fddc982db127b9820da76ac Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Tue, 20 Oct 2026 12:08:28 +0530 Subject: [PATCH 07/11] stop 'across' spikelines at the padded plot area Spikes with spikemode 'across' got their span from the counter axis domains, so with domainpad they crossed the reserved space, for example a subplot title. Get the span from the domain minus the padding of each counter axis. Without domainpad the span is the same as before. A free axis still extends the span to its position. The new test fails without this fix. --- src/components/fx/hover.js | 66 ++++++++++++++++------ src/plots/plots.js | 1 - test/jasmine/tests/hover_spikeline_test.js | 55 ++++++++++++++++++ 3 files changed, 105 insertions(+), 17 deletions(-) diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 9dc2b8f9404..1f1843d2683 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -2414,6 +2414,50 @@ function cleanPoint(d, hovermode) { return d; } +/** + * Get the pixel span of a spike with spikemode 'across'. + * + * The span covers the plot areas of all counter axes of `ax`. Each plot area is the + * domain of the counter axis minus its `domainpad`. Thus the spike stops at the edge + * of the plot area, and the padding stays clear for content such as a subplot title. + * + * @param gd - the graph div + * @param ax - the full axis that shows the spike + * @param gs - the plot size, `fullLayout._size` + * @returns [start, end] in pixels: left to right for a y axis spike, top to bottom for an x axis spike + */ +function getAcrossSpikeSpan(gd, ax, gs) { + const isY = ax._id.charAt(0) === 'y'; + let start = Infinity; + let end = -Infinity; + + for (const counterId of ax._counterAxes || []) { + const counterAx = Axes.getFromId(gd, counterId); + if (!counterAx) continue; + + const padStart = counterAx._padStart || 0; + const padEnd = counterAx._padEnd || 0; + + if (isY) { + start = Math.min(start, gs.l + counterAx.domain[0] * gs.w + padStart); + end = Math.max(end, gs.l + counterAx.domain[1] * gs.w - padEnd); + } else { + // SVG measures y from the top, so the top edge of the domain comes first + start = Math.min(start, gs.t + (1 - counterAx.domain[1]) * gs.h + padStart); + end = Math.max(end, gs.t + (1 - counterAx.domain[0]) * gs.h - padEnd); + } + } + + // a free axis sits outside the plot areas, and the spike must also reach that axis + if (ax.anchor === 'free') { + const position = isY ? gs.l + ax.position * gs.w : gs.t + (1 - ax.position) * gs.h; + start = Math.min(start, position); + end = Math.max(end, position); + } + + return [start, end]; +} + function createSpikelines(gd, closestPoints, opts) { var container = opts.container; var fullLayout = opts.fullLayout; @@ -2463,14 +2507,9 @@ function createSpikelines(gd, closestPoints, opts) { xEndSpike = hLinePointX; } if (yMode.indexOf('across') !== -1) { - var xAcross0 = ya._counterDomainMin; - var xAcross1 = ya._counterDomainMax; - if (ya.anchor === 'free') { - xAcross0 = Math.min(xAcross0, ya.position); - xAcross1 = Math.max(xAcross1, ya.position); - } - xBase = gs.l + xAcross0 * gs.w; - xEndSpike = gs.l + xAcross1 * gs.w; + const xSpan = getAcrossSpikeSpan(gd, ya, gs); + xBase = xSpan[0]; + xEndSpike = xSpan[1]; } // Foreground horizontal line (to y-axis) @@ -2548,14 +2587,9 @@ function createSpikelines(gd, closestPoints, opts) { yEndSpike = vLinePointY; } if (xMode.indexOf('across') !== -1) { - var yAcross0 = xa._counterDomainMin; - var yAcross1 = xa._counterDomainMax; - if (xa.anchor === 'free') { - yAcross0 = Math.min(yAcross0, xa.position); - yAcross1 = Math.max(yAcross1, xa.position); - } - yBase = gs.t + (1 - yAcross1) * gs.h; - yEndSpike = gs.t + (1 - yAcross0) * gs.h; + const ySpan = getAcrossSpikeSpan(gd, xa, gs); + yBase = ySpan[0]; + yEndSpike = ySpan[1]; } // Foreground vertical line (to x-axis) diff --git a/src/plots/plots.js b/src/plots/plots.js index ad53d4dd3e9..9b1247f8800 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -840,7 +840,6 @@ plots.linkSubplots = function(newFullData, newFullLayout, oldFullData, oldFullLa // find "full" domain span of counter axes, // this loop can be costly, so only compute it when required if(ax._counterAxes.length && ( - (ax.spikemode && ax.spikemode.indexOf('across') !== -1) || (ax.automargin && ax.mirror && ax.anchor !== 'free') || Registry.getComponentMethod('rangeslider', 'isVisible')(ax) )) { diff --git a/test/jasmine/tests/hover_spikeline_test.js b/test/jasmine/tests/hover_spikeline_test.js index 311c0e7b503..cd6b1db21c5 100644 --- a/test/jasmine/tests/hover_spikeline_test.js +++ b/test/jasmine/tests/hover_spikeline_test.js @@ -351,6 +351,61 @@ describe('spikeline hover', function() { .then(done, done.fail); }); + it('stops "across" spikes at the padded plot area when the counter axes use domainpad', (done) => { + // Two stacked subplots share one x axis. With this padding, the plot areas are: + // x: 50 + 20 = 70 to 350 - 30 = 320 + // yaxis2 (top): 50 + 40 = 90 to 250 + // yaxis (bottom): 250 to 450 + // Thus the vertical spike must start at 90, not at the top of the domain (50). + // The horizontal spike must run from 70 to 320, not from 50 to 350. + const layout = { + width: 400, + height: 500, + margin: {l: 50, r: 50, t: 50, b: 50}, + // a legend would push out the right margin and change the numbers above + showlegend: false, + hovermode: 'closest', + xaxis: { + showspikes: true, + spikemode: 'across', + spikesnap: 'data', + domainpad: {left: 20, right: 30} + }, + yaxis: { + domain: [0, 0.5], + showspikes: true, + spikemode: 'across', + spikesnap: 'data' + }, + yaxis2: {domain: [0.5, 1], domainpad: {top: 40}} + }; + + const spikeExtents = () => { + const extents = {}; + d3SelectAll('line.spikeline').each(function() { + const line = d3Select(this); + const [x1, x2, y1, y2] = ['x1', 'x2', 'y1', 'y2'].map((attr) => +line.attr(attr)); + + if(x1 === x2) extents.vertical = [Math.min(y1, y2), Math.max(y1, y2)]; + else extents.horizontal = [Math.min(x1, x2), Math.max(x1, x2)]; + }); + return extents; + }; + + Plotly.newPlot(gd, [ + {x: [1, 2, 3], y: [1, 2, 3]}, + {x: [1, 2, 3], y: [3, 2, 1], yaxis: 'y2'} + ], layout) + .then(() => { + _hover({xval: 2, yval: 2}); + + const extents = spikeExtents(); + expect(extents.vertical).toBeCloseToArray([90, 450], 1); + expect(extents.horizontal).toBeCloseToArray([70, 320], 1); + }) + .then(done, done.fail); + }); + it('increase the range of search for points to draw the spikelines on spikedistance change', function(done) { var _mock = makeMock('toaxis', 'closest'); _mock.layout.spikedistance = 20; From 4a681a1312c707f8b5bae6ac4299904660131088 Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Tue, 20 Oct 2026 12:08:28 +0530 Subject: [PATCH 08/11] add domainpad tests for the scattergl and splom webgl paths scattergl and splom build their regl viewports from `domain`, so they add the padding in their own code. These tests make sure that the viewport, the splom cells and the regl grid match the padded plot area. --- test/jasmine/tests/scattergl_test.js | 27 ++++++++++ test/jasmine/tests/splom_test.js | 74 ++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/test/jasmine/tests/scattergl_test.js b/test/jasmine/tests/scattergl_test.js index 8e8f276effc..fb1bd9d0ec2 100644 --- a/test/jasmine/tests/scattergl_test.js +++ b/test/jasmine/tests/scattergl_test.js @@ -744,7 +744,34 @@ describe('end-to-end scattergl tests', function() { .then(done); }); + it('@gl should draw into the plot area that domainpad leaves, not the whole domain', (done) => { + const pixelRatio = 2; + Plotly.newPlot(gd, [{type: 'scattergl', x: [1, 2, 3], y: [1, 3, 2]}], { + width: 500, + height: 400, + margin: {l: 50, r: 50, t: 50, b: 50}, + xaxis: {domainpad: {left: 30, right: 10}}, + yaxis: {domainpad: {top: 40, bottom: 20}} + }, {plotGlPixelRatio: pixelRatio}) + .then(() => { + const fullLayout = gd._fullLayout; + const xa = fullLayout.xaxis; + const ya = fullLayout.yaxis; + + // regl measures in device pixels, from the bottom left corner of the canvas + const viewport = fullLayout._plots.xy._scene.scatter2d.groups[0].viewport; + expect(viewport.x).toBeCloseTo(xa._offset * pixelRatio, 3, 'x'); + expect(viewport.y).toBeCloseTo((fullLayout.height - ya._offset - ya._length) * pixelRatio, 3, 'y'); + expect(viewport.width).toBeCloseTo(xa._length * pixelRatio, 3, 'width'); + expect(viewport.height).toBeCloseTo(ya._length * pixelRatio, 3, 'height'); + + // the viewport starts 30px in from the left margin and 20px up from the bottom margin + expect(viewport.x).toBeCloseTo((50 + 30) * pixelRatio, 3, 'left padding'); + expect(viewport.y).toBeCloseTo((50 + 20) * pixelRatio, 3, 'bottom padding'); + }) + .then(done, done.fail); + }); }); describe('Test scattergl autorange:', function() { diff --git a/test/jasmine/tests/splom_test.js b/test/jasmine/tests/splom_test.js index d2d1928d21a..531c43006ea 100644 --- a/test/jasmine/tests/splom_test.js +++ b/test/jasmine/tests/splom_test.js @@ -730,6 +730,80 @@ describe('Test splom interactions:', function() { .then(done, done.fail); }); + it('@gl should place the splom cells in the plot areas that domainpad leaves', (done) => { + _newPlot(gd, [{ + type: 'splom', + dimensions: [ + {values: [1, 2, 3]}, + {values: [2, 3, 1]} + ] + }], { + width: 500, + height: 500, + xaxis: {domainpad: {left: 25, right: 5}}, + yaxis2: {domainpad: {top: 30, bottom: 10}} + }) + .then(() => { + const fullLayout = gd._fullLayout; + const gs = fullLayout._size; + const scene = fullLayout._splomScenes[gd._fullData[0].uid]; + const bottom = gs.t + gs.h; + + // regl-splom takes each cell as [x0, y0, x1, y1], in fractions of the plot area + // from the bottom left corner. Convert the padded plot area of each axis pair to that form. + ['', '2'].forEach((id, k) => { + const xa = fullLayout['xaxis' + id]; + const ya = fullLayout['yaxis' + id]; + + expect(scene.viewOpts.domains[k]).toBeCloseToArray([ + (xa._offset - gs.l) / gs.w, + (bottom - ya._offset - ya._length) / gs.h, + (xa._offset + xa._length - gs.l) / gs.w, + (bottom - ya._offset) / gs.h + ], 6, `cell ${k}`); + }); + }) + .then(done, done.fail); + }); + + it('@gl when hasOnlyLargeSploms, should keep the regl grid inside the padded plot areas', (done) => { + const fig = Lib.extendDeep({}, require('../../image/mocks/splom_large.json')); + const ids = fig.data[0].dimensions.map((_, i) => (i === 0 ? '' : String(i + 1))); + + // Pad every axis. The outermost grid lines must then stop short of the domain edges. + ids.forEach((id) => { + fig.layout['xaxis' + id] = Lib.extendFlat({}, fig.layout['xaxis' + id], {domainpad: {left: 4, right: 3}}); + fig.layout['yaxis' + id] = Lib.extendFlat({}, fig.layout['yaxis' + id], {domainpad: {top: 5, bottom: 2}}); + }); + + _newPlot(gd, fig).then(() => { + const fullLayout = gd._fullLayout; + const pixelRatio = gd._context.plotGlPixelRatio; + const xAxes = ids.map((id) => fullLayout['xaxis' + id]); + const yAxes = ids.map((id) => fullLayout['yaxis' + id]); + + // regl-line2d draws the grid in device pixels from the bottom left corner, + // so these values are the outer edges of the padded plot areas in that frame + const want = [ + Math.min(...xAxes.map((ax) => ax._offset)) * pixelRatio, + Math.min(...yAxes.map((ax) => fullLayout.height - ax._offset - ax._length)) * pixelRatio, + Math.max(...xAxes.map((ax) => ax._offset + ax._length)) * pixelRatio, + Math.max(...yAxes.map((ax) => fullLayout.height - ax._offset)) * pixelRatio + ]; + + const passes = fullLayout._splomGrid.passes; + const got = [ + Math.min(...passes.map((pass) => pass.bounds[0])), + Math.min(...passes.map((pass) => pass.bounds[1])), + Math.max(...passes.map((pass) => pass.bounds[2])), + Math.max(...passes.map((pass) => pass.bounds[3])) + ]; + + expect(got).toBeCloseToArray(want, 3); + }) + .then(done, done.fail); + }); + it('@gl when hasOnlyLargeSploms, should convert modern CSS grid colors for regl-line2d', (done) => { const fig = Lib.extendDeep({}, require('../../image/mocks/splom_large.json')); From 26a5fc3da08ea2817f4d1ffdc9a3a041736569e5 Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Tue, 20 Oct 2026 12:08:28 +0530 Subject: [PATCH 09/11] add the axes_domainpad image mock The mock shows four subplots with different domainpad sides, so a change in the padded plot area shows in the image tests. The baseline image must come from the baselines-default-diff CI artifact. --- test/image/mocks/axes_domainpad.json | 148 +++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 test/image/mocks/axes_domainpad.json diff --git a/test/image/mocks/axes_domainpad.json b/test/image/mocks/axes_domainpad.json new file mode 100644 index 00000000000..6ec6df1b298 --- /dev/null +++ b/test/image/mocks/axes_domainpad.json @@ -0,0 +1,148 @@ +{ + "data": [ + { + "type": "scatter", + "x": [1, 2, 3, 4, 5], + "y": [2, 4, 3, 5, 4], + "xaxis": "x", + "yaxis": "y" + }, + { + "type": "bar", + "x": [1, 2, 3, 4, 5], + "y": [3, 1, 4, 2, 5], + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "scatter", + "mode": "lines+markers", + "x": [1, 2, 3, 4, 5], + "y": [5, 3, 4, 1, 2], + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "scatter", + "mode": "markers", + "x": [1, 2, 3, 4, 5], + "y": [1, 3, 2, 4, 3], + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "width": 600, + "height": 500, + "showlegend": false, + "plot_bgcolor": "#eef2f7", + "xaxis": { + "domain": [0, 0.48], + "anchor": "y", + "showline": true, + "mirror": true + }, + "yaxis": { + "domain": [0.52, 1], + "anchor": "x", + "showline": true, + "mirror": true, + "domainpad": { "top": 26 } + }, + "xaxis2": { + "domain": [0.52, 1], + "anchor": "y2", + "showline": true, + "mirror": true, + "domainpad": { "left": 40 } + }, + "yaxis2": { + "domain": [0.52, 1], + "anchor": "x2", + "showline": true, + "mirror": true, + "domainpad": { "top": 26 } + }, + "xaxis3": { + "domain": [0, 0.48], + "anchor": "y3", + "showline": true, + "mirror": true + }, + "yaxis3": { + "domain": [0, 0.48], + "anchor": "x3", + "showline": true, + "mirror": true, + "domainpad": { "top": 26, "bottom": 10 } + }, + "xaxis4": { + "domain": [0.52, 1], + "anchor": "y4", + "showline": true, + "mirror": true, + "domainpad": { "left": 40, "right": 20 } + }, + "yaxis4": { + "domain": [0, 0.48], + "anchor": "x4", + "showline": true, + "mirror": true, + "domainpad": { "top": 26, "bottom": 10 } + }, + "annotations": [ + { + "text": "top 26", + "xref": "x domain", + "yref": "y domain", + "x": 0.5, + "y": 1, + "xanchor": "center", + "yanchor": "bottom", + "showarrow": false + }, + { + "text": "top 26, left 40", + "xref": "x2 domain", + "yref": "y2 domain", + "x": 0.5, + "y": 1, + "xanchor": "center", + "yanchor": "bottom", + "showarrow": false + }, + { + "text": "top 26, bottom 10", + "xref": "x3 domain", + "yref": "y3 domain", + "x": 0.5, + "y": 1, + "xanchor": "center", + "yanchor": "bottom", + "showarrow": false + }, + { + "text": "top 26, bottom 10, left 40, right 20", + "xref": "x4 domain", + "yref": "y4 domain", + "x": 0.5, + "y": 1, + "xanchor": "center", + "yanchor": "bottom", + "showarrow": false + } + ], + "shapes": [ + { + "type": "rect", + "xref": "x4 domain", + "yref": "y4 domain", + "x0": 0, + "x1": 1, + "y0": 0, + "y1": 1, + "line": { "color": "#d62728", "dash": "dot", "width": 1 } + } + ] + } +} From 904946e4eacd8584cff7b8cca6a0f490082f561e Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Tue, 20 Oct 2026 12:08:29 +0530 Subject: [PATCH 10/11] reword the domainpad draftlog and restore the contributor credit An earlier commit removed the "with thanks to" credit by mistake. The draftlog convention includes it. --- 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 572bd45875c..c97759dae77 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)] +- Add `domainpad` to cartesian axes to reserve space in pixels inside the edges of `domain`, so the gap between subplots stays the same at all figure sizes [[#7965](https://github.com/plotly/plotly.js/pull/7965)], with thanks to @SharadhNaidu for the contribution! From 3c9ef34e0b73f8ce7a1df8c2725540ed46890188 Mon Sep 17 00:00:00 2001 From: SharadhNaidu Date: Tue, 20 Oct 2026 12:21:36 +0530 Subject: [PATCH 11/11] add the axes_domainpad baseline from the ci artifact --- test/image/baselines/axes_domainpad.png | Bin 0 -> 31890 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/image/baselines/axes_domainpad.png diff --git a/test/image/baselines/axes_domainpad.png b/test/image/baselines/axes_domainpad.png new file mode 100644 index 0000000000000000000000000000000000000000..d9bd896fc0cc68a90738b8508071db0889335640 GIT binary patch literal 31890 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