Skip to content

domainpad: pixel spacing inside axis domains - #7965

Draft
SharadhNaidu wants to merge 4 commits into
plotly:masterfrom
SharadhNaidu:domainpad
Draft

domainpad: pixel spacing inside axis domains#7965
SharadhNaidu wants to merge 4 commits into
plotly:masterfrom
SharadhNaidu:domainpad

Conversation

@SharadhNaidu

Copy link
Copy Markdown
Contributor

draft spike for #7835, following the approach A direction @emilykl picked in
#7835 (comment). not asking for a
merge yet, the api is still open and three things in here are questions rather than
decisions. happy to rework any of it.

this is the plotly.js half of plotly/plotly.py#5606.

what it adds

layout.xaxis.domainpad and layout.yaxis.domainpad, pixels reserved inside the edges of
domain. left and right on x axes, top and bottom on y axes, as sketched in the
issue.

domain is a plot fraction, so the gap it leaves between subplots grows and shrinks with
the figure. a subplot title does not, it is a fixed number of pixels. that mismatch is
#5606. here is the repro from that issue, rendered on this branch before and after:

before, titles overlapping after, titles in reserved space

the only change between them:

yaxis2: {
  domain: [0.548, 0.665],     // untouched, still what make_subplots emits
  domainpad: {top: 26}        // 26px reserved inside the top edge
},
annotations: [{
  text: 'Walking',
  yref: 'y2 domain', y: 1, yanchor: 'bottom',
  xref: 'x2 domain', x: 0.5, xanchor: 'center',
  showarrow: false
}]

the reserved band is the same pixel height at 300px, 600px and 1200px tall, which is the
part plotly.py cannot do from its side.

how it works

the pad is applied in ax.setScale, to _offset and _length, and ax.domain is left
exactly as the user typed it:

         var isY = axLetter === 'y';
+        // the band `domain` covers, before domainpad takes its share of it
+        var bandLength = (isY ? gs.h : gs.w) * (ax.domain[1] - ax.domain[0]);
+        var pad = ax.domainpad;
+        // padStart is the edge _offset is measured from, the top for y and the left
+        // for x, so it is the one that pushes the plot area inwards
+        var padStart = 0;
+        var padEnd = 0;
+
+        if(pad) {
+            padStart = (isY ? pad.top : pad.left) || 0;
+            padEnd = (isY ? pad.bottom : pad.right) || 0;
+
+            var fits = padFactor(padStart + padEnd, bandLength);
+            padStart *= fits;
+            padEnd *= fits;
+        }
+
+        ax._length = bandLength - padStart - padEnd;
+
         if(isY) {
-            ax._offset = gs.t + (1 - ax.domain[1]) * gs.h;
-            ax._length = gs.h * (ax.domain[1] - ax.domain[0]);
+            ax._offset = gs.t + (1 - ax.domain[1]) * gs.h + padStart;
             ax._m = ax._length / (rl0 - rl1);
             ax._b = -ax._m * rl1;
         } else {
-            ax._offset = gs.l + ax.domain[0] * gs.w;
-            ax._length = gs.w * (ax.domain[1] - ax.domain[0]);
+            ax._offset = gs.l + ax.domain[0] * gs.w + padStart;
             ax._m = ax._length / (rl1 - rl0);
             ax._b = -ax._m * rl0;
         }

that is the whole drawing change. everything referenced to <axis> domain already reads
_offset and _length, so annotations, shapes, shape labels and images all land on the
padded area with no extra code. titles are not a special case.

it also keeps out of the way of constrain: 'domain', which already rewrites ax.domain
at draw time and restores it from ax._inputDomain.

three things worth a second opinion

1. scattergl, splom and the rangeslider moved onto _offset and _length. they
were rebuilding the rect from domain, so they did not see the pad. a 40px pad put the gl
rect 40px off the svg one, and the rangeslider came out 90px too wide with
domainpad: {left: 60, right: 30}. the honest cost of this route is that domain stops
being the one place the plot rect comes from. if you would rather the pad rewrote
ax.domain and left those alone, that is a smaller change and i am happy to switch.

2. clamping. six stacked subplots asking for 60px each in a 200px figure throws
Something went wrong with axis scaling, since setScale already errors on a negative
length. the pads are backed off together the way doAutoMargin does with margins, keeping
a 2px floor because that guard is < 0 and a length of exactly 0 would slip past it.

3. a small fix in constraints.js. updateDomain scaled the domain span by the
factor, which assumes drawn length moves with the fraction. the pad takes fixed pixels off
the end, so only (span - pad) scales. with a pad on the axis the constraint shrinks, a
scaleanchor ratio came out 1.0234 instead of 1. it now divides the padded length and adds
the pad back, giving 1 exactly.

scope

cartesian only, deliberately. polar, ternary, geo, map, smith and the domain traces take
their domains from src/plots/domain.js and each converts to pixels in its own file, so
they are a separate and much wider change. #5606 needs nothing beyond cartesian.

tests

13 specs in test/jasmine/tests/axes_test.js under axis domainpad, covering defaults and
coercion, padding on each side, the same pixels held at 300/600/1200px, the clamp,
overlaying inheritance, a domain referenced annotation landing in the band, scaleanchor
with the pad on either side of the constraint, the rangeslider, and geometry holding across
log, date, reversed, category, inset and layout.grid layouts.

npm run lint clean, test/plot-schema.json regenerated, dist/ untouched.

`domain` is a plot fraction, so the gap it leaves between subplots grows and
shrinks with the figure while a subplot title does not. domainpad reserves a
fixed number of pixels inside the domain edges instead, resolved at draw time.

Applied in ax.setScale to _offset and _length rather than to ax.domain, so the
domain keeps meaning what the user typed and anything referenced to
`<axis> domain` follows the padded area without new drawing code.

Along with it:

- scattergl, splom and the rangeslider rebuilt the plot rect from `domain` and
  so did not see the pad; they now read _offset and _length
- pads that do not fit are backed off together, since setScale already throws
  on a negative length
- updateDomain scales the padded length rather than the raw span, so a
  scaleanchor ratio stays exact when the constraint shrinks a padded axis

Refs plotly#7835
Removed attribution from the commit message.
@SharadhNaidu SharadhNaidu changed the title domainpad: pixel spacing inside axis domains (FIX)domainpad: pixel spacing inside axis domains Aug 14, 2026
@SharadhNaidu SharadhNaidu changed the title (FIX)domainpad: pixel spacing inside axis domains domainpad: pixel spacing inside axis domains Aug 14, 2026
The three places that build the plot rect from `domain` were changed to read
_offset and _length instead. That round trips through pixel space, and the
result is not exactly the value it started from: splom cell domains came back
about one ulp out, which moved rasterised markers enough to fail the
splom_*-nodiag image baselines even though no padding was involved.

Add the resolved padding to the original expressions instead, and expose it as
ax._padStart / ax._padEnd for those callers. Adding a literal zero is exact, so
an unpadded plot now produces the same numbers it always did.

Also list domainpad in swapAxisGroup's noSwapAttrs, next to domain: its keys are
named for screen edges, so swapping x and y would need them remapped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant