Skip to content

Commit 056413d

Browse files
authored
Add extent to Plot.layout configuration (#3552)
* Add corners to Plot.layout configuration * Rename corners -> extent * Backcompat for get_layout_engine * Improve documentation
1 parent 03d703b commit 056413d

File tree

5 files changed

+99
-11
lines changed

5 files changed

+99
-11
lines changed

doc/_docstrings/objects.Plot.layout.ipynb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,28 @@
6969
"p.facet([\"A\", \"B\"], [\"X\", \"Y\"]).layout(engine=\"constrained\")"
7070
]
7171
},
72+
{
73+
"cell_type": "markdown",
74+
"id": "d61054d1-dcef-4e11-9802-394bcc633f9f",
75+
"metadata": {},
76+
"source": [
77+
"With `extent`, you can control the size of the plot relative to the underlying figure. Because the notebook display adapts the figure background to the plot, this appears only to change the plot size in a notebook context. But it can be useful when saving or displaying through a `pyplot` GUI window:"
78+
]
79+
},
80+
{
81+
"cell_type": "code",
82+
"execution_count": null,
83+
"id": "1b5d5969-2925-474f-8e3c-99e4f90a7a2b",
84+
"metadata": {},
85+
"outputs": [],
86+
"source": [
87+
"p.layout(extent=[0, 0, .8, 1]).show()"
88+
]
89+
},
7290
{
7391
"cell_type": "code",
7492
"execution_count": null,
75-
"id": "781ff58c-b805-4e93-8cae-be0442e273ea",
93+
"id": "e5c41b7d-a064-4406-8571-a544b194f3dc",
7694
"metadata": {},
7795
"outputs": [],
7896
"source": []

seaborn/_compat.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
from __future__ import annotations
2+
from typing import Literal
3+
14
import numpy as np
25
import matplotlib as mpl
6+
from matplotlib.figure import Figure
37
from seaborn.utils import _version_predates
48

59

@@ -84,19 +88,31 @@ def register_colormap(name, cmap):
8488
mpl.cm.register_cmap(name, cmap)
8589

8690

87-
def set_layout_engine(fig, engine):
91+
def set_layout_engine(
92+
fig: Figure,
93+
engine: Literal["constrained", "compressed", "tight", "none"],
94+
) -> None:
8895
"""Handle changes to auto layout engine interface in 3.6"""
8996
if hasattr(fig, "set_layout_engine"):
9097
fig.set_layout_engine(engine)
9198
else:
9299
# _version_predates(mpl, 3.6)
93100
if engine == "tight":
94-
fig.set_tight_layout(True)
101+
fig.set_tight_layout(True) # type: ignore # predates typing
95102
elif engine == "constrained":
96-
fig.set_constrained_layout(True)
103+
fig.set_constrained_layout(True) # type: ignore
97104
elif engine == "none":
98-
fig.set_tight_layout(False)
99-
fig.set_constrained_layout(False)
105+
fig.set_tight_layout(False) # type: ignore
106+
fig.set_constrained_layout(False) # type: ignore
107+
108+
109+
def get_layout_engine(fig: Figure) -> mpl.layout_engine.LayoutEngine | None:
110+
"""Handle changes to auto layout engine interface in 3.6"""
111+
if hasattr(fig, "get_layout_engine"):
112+
return fig.get_layout_engine()
113+
else:
114+
# _version_predates(mpl, 3.6)
115+
return None
100116

101117

102118
def share_axis(ax0, ax1, which):

seaborn/_core/plot.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
)
4141
from seaborn._core.exceptions import PlotSpecError
4242
from seaborn._core.rules import categorical_order
43-
from seaborn._compat import set_layout_engine
43+
from seaborn._compat import get_layout_engine, set_layout_engine
4444
from seaborn.rcmod import axes_style, plotting_context
4545
from seaborn.palettes import color_palette
4646

@@ -810,6 +810,7 @@ def layout(
810810
*,
811811
size: tuple[float, float] | Default = default,
812812
engine: str | None | Default = default,
813+
extent: tuple[float, float, float, float] | Default = default,
813814
) -> Plot:
814815
"""
815816
Control the figure size and layout.
@@ -825,9 +826,14 @@ def layout(
825826
size : (width, height)
826827
Size of the resulting figure, in inches. Size is inclusive of legend when
827828
using pyplot, but not otherwise.
828-
engine : {{"tight", "constrained", None}}
829+
engine : {{"tight", "constrained", "none"}}
829830
Name of method for automatically adjusting the layout to remove overlap.
830831
The default depends on whether :meth:`Plot.on` is used.
832+
extent : (left, bottom, right, top)
833+
Boundaries of the plot layout, in fractions of the figure size. Takes
834+
effect through the layout engine; exact results will vary across engines.
835+
Note: the extent includes axis decorations when using a layout engine,
836+
but it is exclusive of them when `engine="none"`.
831837
832838
Examples
833839
--------
@@ -845,6 +851,8 @@ def layout(
845851
new._figure_spec["figsize"] = size
846852
if engine is not default:
847853
new._layout_spec["engine"] = engine
854+
if extent is not default:
855+
new._layout_spec["extent"] = extent
848856

849857
return new
850858

@@ -1793,12 +1801,32 @@ def _finalize_figure(self, p: Plot) -> None:
17931801
if axis_key in self._scales: # TODO when would it not be?
17941802
self._scales[axis_key]._finalize(p, axis_obj)
17951803

1796-
if (engine := p._layout_spec.get("engine", default)) is not default:
1804+
if (engine_name := p._layout_spec.get("engine", default)) is not default:
17971805
# None is a valid arg for Figure.set_layout_engine, hence `default`
1798-
set_layout_engine(self._figure, engine)
1806+
set_layout_engine(self._figure, engine_name)
17991807
elif p._target is None:
18001808
# Don't modify the layout engine if the user supplied their own
18011809
# matplotlib figure and didn't specify an engine through Plot
18021810
# TODO switch default to "constrained"?
18031811
# TODO either way, make configurable
18041812
set_layout_engine(self._figure, "tight")
1813+
1814+
if (extent := p._layout_spec.get("extent")) is not None:
1815+
engine = get_layout_engine(self._figure)
1816+
if engine is None:
1817+
self._figure.subplots_adjust(*extent)
1818+
else:
1819+
# Note the different parameterization for the layout engine rect...
1820+
left, bottom, right, top = extent
1821+
width, height = right - left, top - bottom
1822+
try:
1823+
# The base LayoutEngine.set method doesn't have rect= so we need
1824+
# to avoid typechecking this statement. We also catch a TypeError
1825+
# as a plugin LayoutEngine may not support it either.
1826+
# Alternatively we could guard this with a check on the engine type,
1827+
# but that would make later-developed engines would un-useable.
1828+
engine.set(rect=[left, bottom, width, height]) # type: ignore
1829+
except TypeError:
1830+
# Should we warn / raise? Note that we don't expect to get here
1831+
# under any normal circumstances.
1832+
pass

seaborn/_core/subplots.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def init_figure(
144144
pair_spec: PairSpec,
145145
pyplot: bool = False,
146146
figure_kws: dict | None = None,
147-
target: Axes | Figure | SubFigure = None,
147+
target: Axes | Figure | SubFigure | None = None,
148148
) -> Figure:
149149
"""Initialize matplotlib objects and add seaborn-relevant metadata."""
150150
# TODO reduce need to pass pair_spec here?

tests/_core/test_plot.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,32 @@ def test_layout_size(self):
10911091
p = Plot().layout(size=size).plot()
10921092
assert tuple(p._figure.get_size_inches()) == size
10931093

1094+
@pytest.mark.skipif(
1095+
_version_predates(mpl, "3.6"),
1096+
reason="mpl<3.6 does not have get_layout_engine",
1097+
)
1098+
def test_layout_extent(self):
1099+
1100+
p = Plot().layout(extent=(.1, .2, .6, 1)).plot()
1101+
assert p._figure.get_layout_engine().get()["rect"] == [.1, .2, .5, .8]
1102+
1103+
@pytest.mark.skipif(
1104+
_version_predates(mpl, "3.6"),
1105+
reason="mpl<3.6 does not have get_layout_engine",
1106+
)
1107+
def test_constrained_layout_extent(self):
1108+
1109+
p = Plot().layout(engine="constrained", extent=(.1, .2, .6, 1)).plot()
1110+
assert p._figure.get_layout_engine().get()["rect"] == [.1, .2, .5, .8]
1111+
1112+
def test_base_layout_extent(self):
1113+
1114+
p = Plot().layout(engine=None, extent=(.1, .2, .6, 1)).plot()
1115+
assert p._figure.subplotpars.left == 0.1
1116+
assert p._figure.subplotpars.right == 0.6
1117+
assert p._figure.subplotpars.bottom == 0.2
1118+
assert p._figure.subplotpars.top == 1
1119+
10941120
def test_on_axes(self):
10951121

10961122
ax = mpl.figure.Figure().subplots()

0 commit comments

Comments
 (0)