diff --git a/draftlogs/7965_add.md b/draftlogs/7965_add.md new file mode 100644 index 00000000000..c97759dae77 --- /dev/null +++ b/draftlogs/7965_add.md @@ -0,0 +1 @@ +- 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! diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 342676cf654..fdf818736c3 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -2429,6 +2429,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; @@ -2478,14 +2522,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) @@ -2563,14 +2602,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/components/rangeslider/draw.js b/src/components/rangeslider/draw.js index e86fc714c60..5e870145afd 100644 --- a/src/components/rangeslider/draw.js +++ b/src/components/rangeslider/draw.js @@ -100,11 +100,12 @@ 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 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(gs.l + (gs.w * domain[0])); + 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 1661d8c115c..6013f8f2826 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -4683,7 +4683,10 @@ function swapAxisGroup(gd, xIds, yIds) { var allAxKeys = Object.keys(axAttrs); var noSwapAttrs = [ - 'anchor', 'domain', 'overlaying', 'position', 'side', 'tickangle', 'editType' + // 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' ]; var numericTypes = ['linear', 'log']; diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js index 2dd1e665df1..d9afff3ff8e 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,39 @@ exports.clean = function clean(gd, ax) { } }; -function updateDomain(ax, factor) { +// 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) { + const pad = ax.domainpad; + if(!pad) return 0; + + 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; +} + +function updateDomain(ax, factor, fullLayout) { var inputDomain = ax._inputDomain; 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) / factor, - center + (inputDomain[1] - center) / factor + center + (inputDomain[0] - center) / divisor, + center + (inputDomain[1] - center) / divisor ]; ax.setScale(); } diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index cf7b849dcae..b64b5804e10 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -1165,6 +1165,46 @@ module.exports = { editType: 'plot', description: ['Sets the domain of this axis (in plot fraction).'].join(' ') }, + domainpad: { + left: { + valType: 'number', + min: 0, + dflt: 0, + editType: 'plot', + 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: '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: '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: 'Sets the space in pixels to reserve inside the bottom edge of `domain`. Applies to y axes only.' + }, + editType: 'plot', + 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.' + ].join(' ') + }, position: { valType: 'number', min: 0, diff --git a/src/plots/cartesian/position_defaults.js b/src/plots/cartesian/position_defaults.js index 907501830ce..72fbc4e5042 100644 --- a/src/plots/cartesian/position_defaults.js +++ b/src/plots/cartesian/position_defaults.js @@ -84,6 +84,16 @@ module.exports = function handlePositionDefaults(containerIn, containerOut, coer if(domain[0] > domain[1] - 1 / 4096) containerOut.domain = dfltDomain; Lib.noneOrAll(containerIn.domain, containerOut.domain, dfltDomain); + // 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'); + } 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..fa072e736f7 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -35,6 +35,28 @@ function isValidCategory(v) { return v !== null && v !== undefined; } +// 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. + * + * @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; + const 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 +584,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 +601,36 @@ module.exports = function setConvert(ax, fullLayout) { var rl1 = ax.r2l(ax[rangeAttr][1], calendar); var isY = axLetter === 'y'; + // 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; + + const fits = padFactor(padStart + padEnd, bandLength); + padStart *= fits; + padEnd *= fits; + } + + ax._length = bandLength - padStart - padEnd; + // 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; + 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/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/src/traces/scattergl/plot.js b/src/traces/scattergl/plot.js index 5ba6087cea3..005992c4b0b 100644 --- a/src/traces/scattergl/plot.js +++ b/src/traces/scattergl/plot.js @@ -28,11 +28,20 @@ function getViewport(fullLayout, xaxis, yaxis, plotGlPixelRatio) { var t = gs.t * plotGlPixelRatio; var w = gs.w * plotGlPixelRatio; var h = gs.h * 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, - b + yaxis.domain[0] * h, - (width - r) - (1 - xaxis.domain[1]) * w, - (height - t) - (1 - yaxis.domain[1]) * h + l + xaxis.domain[0] * w + padL, + b + yaxis.domain[0] * h + padB, + (width - r) - (1 - xaxis.domain[1]) * w - padR, + (height - t) - (1 - yaxis.domain[1]) * h - padT ]; } diff --git a/src/traces/splom/base_plot.js b/src/traces/splom/base_plot.js index b9f7ed67085..c789b758cff 100644 --- a/src/traces/splom/base_plot.js +++ b/src/traces/splom/base_plot.js @@ -137,7 +137,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: - var yOffset = gs.b + ya.domain[0] * gs.h; + // 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 774268ca5e1..4081795e0f2 100644 --- a/src/traces/splom/plot.js +++ b/src/traces/splom/plot.js @@ -41,6 +41,12 @@ function plotOne(gd, cd0) { viewOpts.ranges = new Array(visibleLength); viewOpts.domains = new Array(visibleLength); + // 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]; @@ -51,16 +57,16 @@ function plotOne(gd, cd0) { if(xa) { rng[0] = xa._rl[0]; rng[2] = xa._rl[1]; - dmn[0] = xa.domain[0]; - dmn[2] = xa.domain[1]; + dmn[0] = xa.domain[0] + padFrac(xa._padStart, gs.w); + dmn[2] = xa.domain[1] - padFrac(xa._padEnd, gs.w); } ya = AxisIDs.getFromId(gd, trace._diag[i][1]); if(ya) { rng[1] = ya._rl[0]; rng[3] = ya._rl[1]; - dmn[1] = ya.domain[0]; - dmn[3] = ya.domain[1]; + dmn[1] = ya.domain[0] + padFrac(ya._padEnd, gs.h); + dmn[3] = ya.domain[1] - padFrac(ya._padStart, gs.h); } } diff --git a/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/image/baselines/axes_domainpad.png b/test/image/baselines/axes_domainpad.png new file mode 100644 index 00000000000..d9bd896fc0c Binary files /dev/null and b/test/image/baselines/axes_domainpad.png differ 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 } + } + ] + } +} diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index e964c3c33da..3b3aad46ff4 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -8545,3 +8545,469 @@ describe('test tickmode calculator', function() { }); }); }); + +describe('axis domainpad', () => { + let gd; + + beforeEach(() => { 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 || {}); + } + + 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', (done) => { + Plotly.newPlot(gd, twoRowsData, twoRows()) + .then(() => { + const xa = gd._fullLayout.xaxis; + const 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', (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(() => { + // 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', (done) => { + let basicOffset, basicLength; + + Plotly.newPlot(gd, twoRowsData, twoRows()) + .then(() => { + basicOffset = gd._fullLayout.yaxis2._offset; + basicLength = gd._fullLayout.yaxis2._length; + + return Plotly.relayout(gd, {'yaxis2.domainpad.top': 30}); + }) + .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(() => { + 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); + }) + .then(done, done.fail); + }); + + it('should pad x axes from the left and the right', (done) => { + let basicOffset, basicLength; + + Plotly.newPlot(gd, twoRowsData, twoRows()) + .then(() => { + basicOffset = gd._fullLayout.xaxis._offset; + basicLength = gd._fullLayout.xaxis._length; + + return Plotly.relayout(gd, {'xaxis.domainpad.left': 15, 'xaxis.domainpad.right': 25}); + }) + .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', (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(() => { + expect(gd._fullLayout.yaxis2._offset).toBeCloseTo(48, 2); + return Plotly.relayout(gd, {height: 300}); + }) + .then(() => { + expect(gd._fullLayout.yaxis2._offset).toBeCloseTo(48, 2); + return Plotly.relayout(gd, {height: 1200}); + }) + .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', (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. + const data = []; + const layout = {width: 800, height: 200, margin: {l: 40, r: 20, t: 20, b: 40}}; + + 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)] = { + domain: [i / 6 + 0.005, (i + 1) / 6 - 0.005], + anchor: 'x' + num, + domainpad: {top: 60} + }; + } + + Plotly.newPlot(gd, data, layout) + .then(() => { + for(let j = 1; j <= 6; j++) { + // the first axis is stored as `yaxis`, not `yaxis1` + const 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', (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(() => { + 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); + expect(ya2._length).toBeCloseTo(ya._length, 2); + }) + .then(done, done.fail); + }); + + 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}}, + 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(() => { + const ya2 = gd._fullLayout.yaxis2; + const annBox = d3Select(gd).select('.annotation').node().getBoundingClientRect(); + const gdBox = gd.getBoundingClientRect(); + + 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); + // ... 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', (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(() => { + 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', (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: { + 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(() => { + 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', (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(() => { + const xa = gd._fullLayout.xaxis; + expect(xa.rangeslider._width).toBeCloseTo(xa._length, 2); + + 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); + }) + .then(done, done.fail); + }); + + 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) { + 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 + const total = p0 + p1; + const maxTotal = Math.max(0, band - 2); + if(total > maxTotal) { + const 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) { + const fl = gd._fullLayout; + const gs = fl._size; + + Object.keys(fl).forEach((key) => { + if(!/^[xy]axis[0-9]*$/.test(key)) return; + const ax = fl[key]; + if(ax._offset === undefined) return; + + 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); + expect(isFinite(ax._m)).withContext(label + ' ' + key + ' slope').toBe(true); + }); + + // and the subplot background has to sit exactly on its axes + for(const id in fl._plots) { + const sp = fl._plots[id]; + if(!sp.bg) continue; + 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); + expect(+node.getAttribute('height')).withContext(label + ' bg h').toBeCloseTo(sp.yaxis._length + 2 * gs.p, 1); + } + } + + 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)); + } + + 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(() => { + 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(() => { + 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(() => { + 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(() => { + 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(() => { + check('pad bigger than the band'); + }) + .then(done, done.fail); + }); + + 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'} + ], { + 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(() => { + 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; + 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); + 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', (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() { + const ya2 = gd._fullLayout.yaxis2; + return [ya2._offset, ya2._length]; + } + let first; + + Plotly.newPlot(gd, twoRowsData, twoRows({ + yaxis2: {domain: [0.51, 1], anchor: 'x2', domainpad: {top: 28}} + })) + .then(() => { + first = offsetAndLength(); + return Plotly.relayout(gd, {'yaxis2.range': [0, 5]}); + }) + .then(() => { + expect(offsetAndLength()).toBeCloseToArray(first, 2); + return Plotly.relayout(gd, {'yaxis2.autorange': true}); + }) + .then(() => { + expect(offsetAndLength()).toBeCloseToArray(first, 2); + return Plotly.restyle(gd, {y: [[5, 1, 9]]}, [1]); + }) + .then(() => { + expect(offsetAndLength()).toBeCloseToArray(first, 2); + }) + .then(done, done.fail); + }); +}); diff --git a/test/jasmine/tests/hover_spikeline_test.js b/test/jasmine/tests/hover_spikeline_test.js index 8aa46461c32..935d994091f 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; 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')); diff --git a/test/plot-schema.json b/test/plot-schema.json index 4d51312e82d..af804a589e6 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -13378,6 +13378,39 @@ ], "valType": "info_array" }, + "domainpad": { + "bottom": { + "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": "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": "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": "Sets the space in pixels to reserve inside the right edge of `domain`. Applies to x axes only.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + }, + "role": "object", + "top": { + "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, + "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", @@ -14944,6 +14977,39 @@ ], "valType": "info_array" }, + "domainpad": { + "bottom": { + "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": "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": "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": "Sets the space in pixels to reserve inside the right edge of `domain`. Applies to x axes only.", + "dflt": 0, + "editType": "plot", + "min": 0, + "valType": "number" + }, + "role": "object", + "top": { + "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, + "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",