Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions draftlogs/7965_add.md
Original file line number Diff line number Diff line change
@@ -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!
66 changes: 50 additions & 16 deletions src/components/fx/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions src/components/rangeslider/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) +
Expand Down
5 changes: 4 additions & 1 deletion src/plots/cartesian/axes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
35 changes: 30 additions & 5 deletions src/plots/cartesian/constraints.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -596,7 +596,7 @@ exports.enforce = function enforce(gd) {
[rangeMin, rangeMax] : [rangeMax, rangeMin];
}

updateDomain(ax, factor);
updateDomain(ax, factor, fullLayout);
}
}
}
Expand Down Expand Up @@ -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();
}
40 changes: 40 additions & 0 deletions src/plots/cartesian/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/plots/cartesian/position_defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
55 changes: 51 additions & 4 deletions src/plots/cartesian/set_convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down
1 change: 0 additions & 1 deletion src/plots/plots.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)) {
Expand Down
17 changes: 13 additions & 4 deletions src/traces/scattergl/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
];
}

Expand Down
4 changes: 3 additions & 1 deletion src/traces/splom/base_plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 10 additions & 4 deletions src/traces/splom/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand All @@ -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);
}
}

Expand Down
Loading
Loading