Skip to content

Commit b5e04ce

Browse files
authored
Merge pull request #7965 from SharadhNaidu/domainpad
domainpad: pixel spacing inside axis domains
2 parents 7d92747 + b60a7eb commit b5e04ce

20 files changed

Lines changed: 1037 additions & 39 deletions

File tree

‎draftlogs/7965_add.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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!

‎src/components/fx/hover.js‎

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2429,6 +2429,45 @@ function cleanPoint(d, hovermode) {
24292429
return d;
24302430
}
24312431

2432+
/**
2433+
* Get the pixel span of a spike with spikemode 'across'.
2434+
*
2435+
* @param gd - the graph div
2436+
* @param ax - the full axis that shows the spike
2437+
* @param gs - the plot size, `fullLayout._size`
2438+
* @returns [start, end] in pixels: left to right for a y axis spike, top to bottom for an x axis spike
2439+
*/
2440+
function getAcrossSpikeSpan(gd, ax, gs) {
2441+
const isY = ax._id.charAt(0) === 'y';
2442+
let start = Infinity;
2443+
let end = -Infinity;
2444+
2445+
for (const counterId of ax._counterAxes || []) {
2446+
const counterAx = Axes.getFromId(gd, counterId);
2447+
if (!counterAx) continue;
2448+
2449+
const padStart = counterAx._padStart || 0;
2450+
const padEnd = counterAx._padEnd || 0;
2451+
2452+
if (isY) {
2453+
start = Math.min(start, gs.l + counterAx.domain[0] * gs.w + padStart);
2454+
end = Math.max(end, gs.l + counterAx.domain[1] * gs.w - padEnd);
2455+
} else {
2456+
start = Math.min(start, gs.t + (1 - counterAx.domain[1]) * gs.h + padStart);
2457+
end = Math.max(end, gs.t + (1 - counterAx.domain[0]) * gs.h - padEnd);
2458+
}
2459+
}
2460+
2461+
// a free axis sits outside the plot areas, and the spike must also reach that axis
2462+
if (ax.anchor === 'free') {
2463+
const position = isY ? gs.l + ax.position * gs.w : gs.t + (1 - ax.position) * gs.h;
2464+
start = Math.min(start, position);
2465+
end = Math.max(end, position);
2466+
}
2467+
2468+
return [start, end];
2469+
}
2470+
24322471
function createSpikelines(gd, closestPoints, opts) {
24332472
var container = opts.container;
24342473
var fullLayout = opts.fullLayout;
@@ -2478,14 +2517,9 @@ function createSpikelines(gd, closestPoints, opts) {
24782517
xEndSpike = hLinePointX;
24792518
}
24802519
if (yMode.indexOf('across') !== -1) {
2481-
var xAcross0 = ya._counterDomainMin;
2482-
var xAcross1 = ya._counterDomainMax;
2483-
if (ya.anchor === 'free') {
2484-
xAcross0 = Math.min(xAcross0, ya.position);
2485-
xAcross1 = Math.max(xAcross1, ya.position);
2486-
}
2487-
xBase = gs.l + xAcross0 * gs.w;
2488-
xEndSpike = gs.l + xAcross1 * gs.w;
2520+
const xSpan = getAcrossSpikeSpan(gd, ya, gs);
2521+
xBase = xSpan[0];
2522+
xEndSpike = xSpan[1];
24892523
}
24902524

24912525
// Foreground horizontal line (to y-axis)
@@ -2563,14 +2597,9 @@ function createSpikelines(gd, closestPoints, opts) {
25632597
yEndSpike = vLinePointY;
25642598
}
25652599
if (xMode.indexOf('across') !== -1) {
2566-
var yAcross0 = xa._counterDomainMin;
2567-
var yAcross1 = xa._counterDomainMax;
2568-
if (xa.anchor === 'free') {
2569-
yAcross0 = Math.min(yAcross0, xa.position);
2570-
yAcross1 = Math.max(yAcross1, xa.position);
2571-
}
2572-
yBase = gs.t + (1 - yAcross1) * gs.h;
2573-
yEndSpike = gs.t + (1 - yAcross0) * gs.h;
2600+
const ySpan = getAcrossSpikeSpan(gd, xa, gs);
2601+
yBase = ySpan[0];
2602+
yEndSpike = ySpan[1];
25742603
}
25752604

25762605
// Foreground vertical line (to x-axis)

‎src/components/rangeslider/draw.js‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,12 @@ module.exports = function(gd) {
100100
// update range slider dimensions
101101

102102
var gs = fullLayout._size;
103-
var domain = axisOpts.domain;
104103

105-
opts._width = gs.w * (domain[1] - domain[0]);
104+
// The slider must line up with the plot area above it. `domainpad` moves the
105+
// plot area but not `domain`, so get the width and position from the axis.
106+
opts._width = axisOpts._length;
106107

107-
var x = Math.round(gs.l + (gs.w * domain[0]));
108+
const x = Math.round(axisOpts._offset);
108109

109110
var y = Math.round(
110111
gs.t + gs.h * (1 - axisOpts._counterDomainMin) +

‎src/plots/cartesian/axes.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4683,7 +4683,8 @@ function swapAxisGroup(gd, xIds, yIds) {
46834683
var allAxKeys = Object.keys(axAttrs);
46844684

46854685
var noSwapAttrs = [
4686-
'anchor', 'domain', 'overlaying', 'position', 'side', 'tickangle', 'editType'
4686+
'anchor', 'domain', 'domainpad', 'overlaying', 'position', 'side',
4687+
'tickangle', 'editType'
46874688
];
46884689
var numericTypes = ['linear', 'log'];
46894690

‎src/plots/cartesian/constraints.js‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ exports.enforce = function enforce(gd) {
565565
var getPadMin = autorange.makePadFn(fullLayout, ax, 0);
566566
var getPadMax = autorange.makePadFn(fullLayout, ax, 1);
567567

568-
updateDomain(ax, factor);
568+
updateDomain(ax, factor, fullLayout);
569569
var m = Math.abs(ax._m);
570570
var extremes = autorange.concatExtremes(gd, ax);
571571
var minArray = extremes.min;
@@ -596,7 +596,7 @@ exports.enforce = function enforce(gd) {
596596
[rangeMin, rangeMax] : [rangeMax, rangeMin];
597597
}
598598

599-
updateDomain(ax, factor);
599+
updateDomain(ax, factor, fullLayout);
600600
}
601601
}
602602
}
@@ -633,14 +633,32 @@ exports.clean = function clean(gd, ax) {
633633
}
634634
};
635635

636-
function updateDomain(ax, factor) {
636+
function domainPadFraction(ax, fullLayout) {
637+
const pad = ax.domainpad;
638+
if(!pad) return 0;
639+
640+
const gs = fullLayout._size;
641+
return ax._id.charAt(0) === 'y' ?
642+
((pad.top || 0) + (pad.bottom || 0)) / gs.h :
643+
((pad.left || 0) + (pad.right || 0)) / gs.w;
644+
}
645+
646+
function updateDomain(ax, factor, fullLayout) {
637647
var inputDomain = ax._inputDomain;
638648
var centerFraction = FROM_BL[ax.constraintoward];
639649
var center = inputDomain[0] + (inputDomain[1] - inputDomain[0]) * centerFraction;
640650

651+
let divisor = factor;
652+
const padFraction = domainPadFraction(ax, fullLayout);
653+
if(padFraction > 0) {
654+
const inputSpan = inputDomain[1] - inputDomain[0];
655+
const drawnSpan = inputSpan - padFraction;
656+
if(drawnSpan > 0) divisor = inputSpan / (drawnSpan / factor + padFraction);
657+
}
658+
641659
ax.domain = ax._input.domain = [
642-
center + (inputDomain[0] - center) / factor,
643-
center + (inputDomain[1] - center) / factor
660+
center + (inputDomain[0] - center) / divisor,
661+
center + (inputDomain[1] - center) / divisor
644662
];
645663
ax.setScale();
646664
}

‎src/plots/cartesian/layout_attributes.js‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,48 @@ module.exports = {
11651165
editType: 'plot',
11661166
description: ['Sets the domain of this axis (in plot fraction).'].join(' ')
11671167
},
1168+
domainpad: {
1169+
left: {
1170+
valType: 'number',
1171+
min: 0,
1172+
dflt: 0,
1173+
editType: 'plot',
1174+
description: 'Sets the space in pixels to reserve inside the left edge of `domain`. Applies to x axes only.'
1175+
},
1176+
right: {
1177+
valType: 'number',
1178+
min: 0,
1179+
dflt: 0,
1180+
editType: 'plot',
1181+
description: 'Sets the space in pixels to reserve inside the right edge of `domain`. Applies to x axes only.'
1182+
},
1183+
top: {
1184+
valType: 'number',
1185+
min: 0,
1186+
dflt: 0,
1187+
editType: 'plot',
1188+
description: 'Sets the space in pixels to reserve inside the top edge of `domain`. Applies to y axes only.'
1189+
},
1190+
bottom: {
1191+
valType: 'number',
1192+
min: 0,
1193+
dflt: 0,
1194+
editType: 'plot',
1195+
description: 'Sets the space in pixels to reserve inside the bottom edge of `domain`. Applies to y axes only.'
1196+
},
1197+
editType: 'plot',
1198+
description: [
1199+
'Sets the space in pixels to reserve inside the edges of `domain`.',
1200+
'Because `domain` is a fraction of the plot area, the gap between subplots',
1201+
'changes with the figure size. `domainpad` keeps the same size in pixels at all figure sizes,',
1202+
'so it can hold content with a fixed size, such as a subplot title.',
1203+
'x axes use `left` and `right`. y axes use `top` and `bottom`.',
1204+
'If the padding needs more room than the domain has, the two pads shrink by the same factor,',
1205+
'and the plot area keeps a length of at least 2 pixels.',
1206+
'An axis that sets `overlaying` ignores its own `domainpad` and uses the `domainpad`',
1207+
'of the axis it overlays, so that both axes share the same plot area.'
1208+
].join(' ')
1209+
},
11681210
position: {
11691211
valType: 'number',
11701212
min: 0,

‎src/plots/cartesian/position_defaults.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ module.exports = function handlePositionDefaults(containerIn, containerOut, coer
8484
if(domain[0] > domain[1] - 1 / 4096) containerOut.domain = dfltDomain;
8585
Lib.noneOrAll(containerIn.domain, containerOut.domain, dfltDomain);
8686

87+
if(letter === 'x') {
88+
coerce('domainpad.left');
89+
coerce('domainpad.right');
90+
} else {
91+
coerce('domainpad.top');
92+
coerce('domainpad.bottom');
93+
}
94+
8795
// tickmode sync needs an overlaying axis, otherwise
8896
// we should default it to 'auto'
8997
if(containerOut.tickmode === 'sync') {

‎src/plots/cartesian/set_convert.js‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ function isValidCategory(v) {
3535
return v !== null && v !== undefined;
3636
}
3737

38+
const MIN_PADDED_LENGTH = 2;
39+
40+
/**
41+
* Get the part of the requested `domainpad` that fits in a domain band.
42+
*
43+
* @param wanted - the total padding of the two edges, in pixels
44+
* @param bandLength - the length of the domain band, in pixels
45+
* @returns the factor from 0 to 1 to multiply each pad by
46+
*/
47+
function padFactor(wanted, bandLength) {
48+
if(wanted <= 0) return 0;
49+
const room = Math.max(0, bandLength - MIN_PADDED_LENGTH);
50+
return wanted > room ? room / wanted : 1;
51+
}
52+
3853
/**
3954
* Define the conversion functions for an axis data is used in 5 ways:
4055
*
@@ -562,6 +577,7 @@ module.exports = function setConvert(ax, fullLayout) {
562577
if(ax.overlaying) {
563578
var ax2 = axisIds.getFromId({ _fullLayout: fullLayout }, ax.overlaying);
564579
ax.domain = ax2.domain;
580+
ax.domainpad = ax2.domainpad;
565581
}
566582

567583
// While transitions are occurring, we get a double-transform
@@ -576,14 +592,31 @@ module.exports = function setConvert(ax, fullLayout) {
576592
var rl1 = ax.r2l(ax[rangeAttr][1], calendar);
577593

578594
var isY = axLetter === 'y';
595+
// the length in pixels of the domain band, before `domainpad`
596+
const bandLength = (isY ? gs.h : gs.w) * (ax.domain[1] - ax.domain[0]);
597+
const pad = ax.domainpad;
598+
let padStart = 0;
599+
let padEnd = 0;
600+
601+
if(pad) {
602+
padStart = (isY ? pad.top : pad.left) || 0;
603+
padEnd = (isY ? pad.bottom : pad.right) || 0;
604+
605+
const fits = padFactor(padStart + padEnd, bandLength);
606+
padStart *= fits;
607+
padEnd *= fits;
608+
}
609+
610+
ax._length = bandLength - padStart - padEnd;
611+
ax._padStart = padStart;
612+
ax._padEnd = padEnd;
613+
579614
if(isY) {
580-
ax._offset = gs.t + (1 - ax.domain[1]) * gs.h;
581-
ax._length = gs.h * (ax.domain[1] - ax.domain[0]);
615+
ax._offset = gs.t + (1 - ax.domain[1]) * gs.h + padStart;
582616
ax._m = ax._length / (rl0 - rl1);
583617
ax._b = -ax._m * rl1;
584618
} else {
585-
ax._offset = gs.l + ax.domain[0] * gs.w;
586-
ax._length = gs.w * (ax.domain[1] - ax.domain[0]);
619+
ax._offset = gs.l + ax.domain[0] * gs.w + padStart;
587620
ax._m = ax._length / (rl1 - rl0);
588621
ax._b = -ax._m * rl0;
589622
}

‎src/plots/plots.js‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -840,7 +840,6 @@ plots.linkSubplots = function(newFullData, newFullLayout, oldFullData, oldFullLa
840840
// find "full" domain span of counter axes,
841841
// this loop can be costly, so only compute it when required
842842
if(ax._counterAxes.length && (
843-
(ax.spikemode && ax.spikemode.indexOf('across') !== -1) ||
844843
(ax.automargin && ax.mirror && ax.anchor !== 'free') ||
845844
Registry.getComponentMethod('rangeslider', 'isVisible')(ax)
846845
)) {

‎src/traces/scattergl/plot.js‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,17 @@ function getViewport(fullLayout, xaxis, yaxis, plotGlPixelRatio) {
2828
var t = gs.t * plotGlPixelRatio;
2929
var w = gs.w * plotGlPixelRatio;
3030
var h = gs.h * plotGlPixelRatio;
31+
32+
const padL = (xaxis._padStart || 0) * plotGlPixelRatio;
33+
const padR = (xaxis._padEnd || 0) * plotGlPixelRatio;
34+
const padT = (yaxis._padStart || 0) * plotGlPixelRatio;
35+
const padB = (yaxis._padEnd || 0) * plotGlPixelRatio;
36+
3137
return [
32-
l + xaxis.domain[0] * w,
33-
b + yaxis.domain[0] * h,
34-
(width - r) - (1 - xaxis.domain[1]) * w,
35-
(height - t) - (1 - yaxis.domain[1]) * h
38+
l + xaxis.domain[0] * w + padL,
39+
b + yaxis.domain[0] * h + padB,
40+
(width - r) - (1 - xaxis.domain[1]) * w - padR,
41+
(height - t) - (1 - yaxis.domain[1]) * h - padT
3642
];
3743
}
3844

0 commit comments

Comments
 (0)