diff --git a/doc/_docstrings/objects.Plot.layout.ipynb b/doc/_docstrings/objects.Plot.layout.ipynb index 755d6d3a28..021cf7296c 100644 --- a/doc/_docstrings/objects.Plot.layout.ipynb +++ b/doc/_docstrings/objects.Plot.layout.ipynb @@ -69,10 +69,28 @@ "p.facet([\"A\", \"B\"], [\"X\", \"Y\"]).layout(engine=\"constrained\")" ] }, + { + "cell_type": "markdown", + "id": "d61054d1-dcef-4e11-9802-394bcc633f9f", + "metadata": {}, + "source": [ + "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:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b5d5969-2925-474f-8e3c-99e4f90a7a2b", + "metadata": {}, + "outputs": [], + "source": [ + "p.layout(extent=[0, 0, .8, 1]).show()" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "781ff58c-b805-4e93-8cae-be0442e273ea", + "id": "e5c41b7d-a064-4406-8571-a544b194f3dc", "metadata": {}, "outputs": [], "source": [] diff --git a/seaborn/_compat.py b/seaborn/_compat.py index c3d97ca5f8..190ec6b62b 100644 --- a/seaborn/_compat.py +++ b/seaborn/_compat.py @@ -1,5 +1,9 @@ +from __future__ import annotations +from typing import Literal + import numpy as np import matplotlib as mpl +from matplotlib.figure import Figure from seaborn.utils import _version_predates @@ -84,19 +88,31 @@ def register_colormap(name, cmap): mpl.cm.register_cmap(name, cmap) -def set_layout_engine(fig, engine): +def set_layout_engine( + fig: Figure, + engine: Literal["constrained", "compressed", "tight", "none"], +) -> None: """Handle changes to auto layout engine interface in 3.6""" if hasattr(fig, "set_layout_engine"): fig.set_layout_engine(engine) else: # _version_predates(mpl, 3.6) if engine == "tight": - fig.set_tight_layout(True) + fig.set_tight_layout(True) # type: ignore # predates typing elif engine == "constrained": - fig.set_constrained_layout(True) + fig.set_constrained_layout(True) # type: ignore elif engine == "none": - fig.set_tight_layout(False) - fig.set_constrained_layout(False) + fig.set_tight_layout(False) # type: ignore + fig.set_constrained_layout(False) # type: ignore + + +def get_layout_engine(fig: Figure) -> mpl.layout_engine.LayoutEngine | None: + """Handle changes to auto layout engine interface in 3.6""" + if hasattr(fig, "get_layout_engine"): + return fig.get_layout_engine() + else: + # _version_predates(mpl, 3.6) + return None def share_axis(ax0, ax1, which): diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py index 84f816c1f2..39ccd2e0bd 100644 --- a/seaborn/_core/plot.py +++ b/seaborn/_core/plot.py @@ -40,7 +40,7 @@ ) from seaborn._core.exceptions import PlotSpecError from seaborn._core.rules import categorical_order -from seaborn._compat import set_layout_engine +from seaborn._compat import get_layout_engine, set_layout_engine from seaborn.rcmod import axes_style, plotting_context from seaborn.palettes import color_palette @@ -810,6 +810,7 @@ def layout( *, size: tuple[float, float] | Default = default, engine: str | None | Default = default, + extent: tuple[float, float, float, float] | Default = default, ) -> Plot: """ Control the figure size and layout. @@ -825,9 +826,14 @@ def layout( size : (width, height) Size of the resulting figure, in inches. Size is inclusive of legend when using pyplot, but not otherwise. - engine : {{"tight", "constrained", None}} + engine : {{"tight", "constrained", "none"}} Name of method for automatically adjusting the layout to remove overlap. The default depends on whether :meth:`Plot.on` is used. + extent : (left, bottom, right, top) + Boundaries of the plot layout, in fractions of the figure size. Takes + effect through the layout engine; exact results will vary across engines. + Note: the extent includes axis decorations when using a layout engine, + but it is exclusive of them when `engine="none"`. Examples -------- @@ -845,6 +851,8 @@ def layout( new._figure_spec["figsize"] = size if engine is not default: new._layout_spec["engine"] = engine + if extent is not default: + new._layout_spec["extent"] = extent return new @@ -1793,12 +1801,32 @@ def _finalize_figure(self, p: Plot) -> None: if axis_key in self._scales: # TODO when would it not be? self._scales[axis_key]._finalize(p, axis_obj) - if (engine := p._layout_spec.get("engine", default)) is not default: + if (engine_name := p._layout_spec.get("engine", default)) is not default: # None is a valid arg for Figure.set_layout_engine, hence `default` - set_layout_engine(self._figure, engine) + set_layout_engine(self._figure, engine_name) elif p._target is None: # Don't modify the layout engine if the user supplied their own # matplotlib figure and didn't specify an engine through Plot # TODO switch default to "constrained"? # TODO either way, make configurable set_layout_engine(self._figure, "tight") + + if (extent := p._layout_spec.get("extent")) is not None: + engine = get_layout_engine(self._figure) + if engine is None: + self._figure.subplots_adjust(*extent) + else: + # Note the different parameterization for the layout engine rect... + left, bottom, right, top = extent + width, height = right - left, top - bottom + try: + # The base LayoutEngine.set method doesn't have rect= so we need + # to avoid typechecking this statement. We also catch a TypeError + # as a plugin LayoutEngine may not support it either. + # Alternatively we could guard this with a check on the engine type, + # but that would make later-developed engines would un-useable. + engine.set(rect=[left, bottom, width, height]) # type: ignore + except TypeError: + # Should we warn / raise? Note that we don't expect to get here + # under any normal circumstances. + pass diff --git a/seaborn/_core/subplots.py b/seaborn/_core/subplots.py index 9cd67a5964..287f441670 100644 --- a/seaborn/_core/subplots.py +++ b/seaborn/_core/subplots.py @@ -144,7 +144,7 @@ def init_figure( pair_spec: PairSpec, pyplot: bool = False, figure_kws: dict | None = None, - target: Axes | Figure | SubFigure = None, + target: Axes | Figure | SubFigure | None = None, ) -> Figure: """Initialize matplotlib objects and add seaborn-relevant metadata.""" # TODO reduce need to pass pair_spec here? diff --git a/tests/_core/test_plot.py b/tests/_core/test_plot.py index c787f2275f..97e55e5589 100644 --- a/tests/_core/test_plot.py +++ b/tests/_core/test_plot.py @@ -1091,6 +1091,32 @@ def test_layout_size(self): p = Plot().layout(size=size).plot() assert tuple(p._figure.get_size_inches()) == size + @pytest.mark.skipif( + _version_predates(mpl, "3.6"), + reason="mpl<3.6 does not have get_layout_engine", + ) + def test_layout_extent(self): + + p = Plot().layout(extent=(.1, .2, .6, 1)).plot() + assert p._figure.get_layout_engine().get()["rect"] == [.1, .2, .5, .8] + + @pytest.mark.skipif( + _version_predates(mpl, "3.6"), + reason="mpl<3.6 does not have get_layout_engine", + ) + def test_constrained_layout_extent(self): + + p = Plot().layout(engine="constrained", extent=(.1, .2, .6, 1)).plot() + assert p._figure.get_layout_engine().get()["rect"] == [.1, .2, .5, .8] + + def test_base_layout_extent(self): + + p = Plot().layout(engine=None, extent=(.1, .2, .6, 1)).plot() + assert p._figure.subplotpars.left == 0.1 + assert p._figure.subplotpars.right == 0.6 + assert p._figure.subplotpars.bottom == 0.2 + assert p._figure.subplotpars.top == 1 + def test_on_axes(self): ax = mpl.figure.Figure().subplots()