Skip to content
Draft
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, 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)]
8 changes: 5 additions & 3 deletions src/components/rangeslider/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) +
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 @@ -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'];

Expand Down
41 changes: 34 additions & 7 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,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();
}
41 changes: 41 additions & 0 deletions src/plots/cartesian/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/plots/cartesian/position_defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
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,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:
*
Expand Down Expand Up @@ -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
Expand All @@ -576,14 +600,37 @@ module.exports = function setConvert(ax, fullLayout) {
var rl1 = ax.r2l(ax[rangeAttr][1], calendar);

var isY = axLetter === 'y';
// the band `domain` covers, before domainpad takes its share of it
var bandLength = (isY ? gs.h : gs.w) * (ax.domain[1] - ax.domain[0]);
var pad = ax.domainpad;
// padStart is the edge _offset is measured from, the top for y and the left
// for x, so it is the one that pushes the plot area inwards
var padStart = 0;
var padEnd = 0;

if(pad) {
padStart = (isY ? pad.top : pad.left) || 0;
padEnd = (isY ? pad.bottom : pad.right) || 0;

var fits = padFactor(padStart + padEnd, bandLength);
padStart *= fits;
padEnd *= fits;
}

ax._length = bandLength - padStart - padEnd;
// the resolved padding, for the few places that build the plot rect from
// `domain` themselves and would otherwise miss it. They add these instead of
// rebuilding from _offset and _length, which would round trip through pixel
// space and lose a bit or two even when there is no padding at all.
ax._padStart = padStart;
ax._padEnd = padEnd;

if(isY) {
ax._offset = gs.t + (1 - ax.domain[1]) * gs.h;
ax._length = gs.h * (ax.domain[1] - ax.domain[0]);
ax._offset = gs.t + (1 - ax.domain[1]) * gs.h + padStart;
ax._m = ax._length / (rl0 - rl1);
ax._b = -ax._m * rl1;
} else {
ax._offset = gs.l + ax.domain[0] * gs.w;
ax._length = gs.w * (ax.domain[1] - ax.domain[0]);
ax._offset = gs.l + ax.domain[0] * gs.w + padStart;
ax._m = ax._length / (rl1 - rl0);
ax._b = -ax._m * rl0;
}
Expand Down
18 changes: 14 additions & 4 deletions src/traces/scattergl/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,21 @@ function getViewport(fullLayout, xaxis, yaxis, plotGlPixelRatio) {
var t = gs.t * plotGlPixelRatio;
var w = gs.w * plotGlPixelRatio;
var h = gs.h * plotGlPixelRatio;

// `domainpad` takes pixels off the plot area that `domain` knows nothing about.
// Add it to the expressions below rather than rebuilding the rect from _offset
// and _length: that would round trip through pixel space and shift this rect by
// a fraction of a pixel even on plots with no padding at all.
var padL = (xaxis._padStart || 0) * plotGlPixelRatio;
var padR = (xaxis._padEnd || 0) * plotGlPixelRatio;
var padT = (yaxis._padStart || 0) * plotGlPixelRatio;
var padB = (yaxis._padEnd || 0) * plotGlPixelRatio;

return [
l + xaxis.domain[0] * w,
b + yaxis.domain[0] * h,
(width - r) - (1 - xaxis.domain[1]) * w,
(height - t) - (1 - yaxis.domain[1]) * h
l + xaxis.domain[0] * w + padL,
b + yaxis.domain[0] * h + padB,
(width - r) - (1 - xaxis.domain[1]) * w - padR,
(height - t) - (1 - yaxis.domain[1]) * h - padT
];
}

Expand Down
6 changes: 4 additions & 2 deletions src/traces/splom/base_plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,10 @@ function makeGridData(gd) {
var yLength = ya._length;

// ya.l2p assumes top-to-bottom coordinate system (a la SVG),
// we need to compute bottom-to-top offsets and slopes:
var yOffset = gs.b + ya.domain[0] * gs.h;
// we need to compute bottom-to-top offsets and slopes.
// `domainpad` is added on rather than folded in by reading _offset, so that
// an unpadded axis lands on exactly the pixel it always did:
var yOffset = gs.b + ya.domain[0] * gs.h + (ya._padEnd || 0);
var ym = -ya._m;
var yb = -ym * ya.r2l(ya.range[0], ya.calendar);
var x, y;
Expand Down
15 changes: 11 additions & 4 deletions src/traces/splom/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ function plotOne(gd, cd0) {
viewOpts.ranges = new Array(visibleLength);
viewOpts.domains = new Array(visibleLength);

// regl-splom places each cell as a fraction of the viewport below, which is the
// whole plot area, so `domainpad` has to be folded in as a fraction too or the
// cells keep their unpadded size while the axes move. Added to `domain` rather
// than recovered from _offset and _length, which would round trip through pixel
// space and nudge every cell a little even with no padding set.
function padFrac(px, total) { return (px || 0) / total; }

for(k = 0; k < visibleDims.length; k++) {
i = visibleDims[k];

Expand All @@ -51,16 +58,16 @@ function plotOne(gd, cd0) {
if(xa) {
rng[0] = xa._rl[0];
rng[2] = xa._rl[1];
dmn[0] = xa.domain[0];
dmn[2] = xa.domain[1];
dmn[0] = xa.domain[0] + padFrac(xa._padStart, gs.w);
dmn[2] = xa.domain[1] - padFrac(xa._padEnd, gs.w);
}

ya = AxisIDs.getFromId(gd, trace._diag[i][1]);
if(ya) {
rng[1] = ya._rl[0];
rng[3] = ya._rl[1];
dmn[1] = ya.domain[0];
dmn[3] = ya.domain[1];
dmn[1] = ya.domain[0] + padFrac(ya._padEnd, gs.h);
dmn[3] = ya.domain[1] - padFrac(ya._padStart, gs.h);
}
}

Expand Down
Loading
Loading