From df56e6757cfdc469dfa3da20e2c0c8a112f6fde7 Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Tue, 7 Nov 2023 13:47:39 -0500 Subject: [PATCH 1/4] Add corners to Plot.layout configuration --- seaborn/_core/plot.py | 31 +++++++++++++++++++++++++++++-- seaborn/_core/subplots.py | 2 +- tests/_core/test_plot.py | 18 ++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py index 84f816c1f2..09cd8a02b2 100644 --- a/seaborn/_core/plot.py +++ b/seaborn/_core/plot.py @@ -810,6 +810,7 @@ def layout( *, size: tuple[float, float] | Default = default, engine: str | None | Default = default, + corners: tuple[float, float, float, float] | Default = default, ) -> Plot: """ Control the figure size and layout. @@ -828,6 +829,10 @@ def layout( 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. + corners : (left, bottom, right, top) + Position of the layout corners, in fractions of the figure size. + Corners are inclusive of axis decorations when using a layout engine, + but they are exclusive when `engine=None`. Examples -------- @@ -845,6 +850,8 @@ def layout( new._figure_spec["figsize"] = size if engine is not default: new._layout_spec["engine"] = engine + if corners is not default: + new._layout_spec["corners"] = corners return new @@ -1793,12 +1800,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 (corners := p._layout_spec.get("corners")) is not None: + engine = self._figure.get_layout_engine() + if engine is None: + self._figure.subplots_adjust(*corners) + else: + # Note the different parameterization for the layout engine rect... + left, bottom, right, top = corners + 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..0689298b8c 100644 --- a/tests/_core/test_plot.py +++ b/tests/_core/test_plot.py @@ -1091,6 +1091,24 @@ def test_layout_size(self): p = Plot().layout(size=size).plot() assert tuple(p._figure.get_size_inches()) == size + def test_layout_corners(self): + + p = Plot().layout(corners=(.1, .2, .6, 1)).plot() + assert p._figure.get_layout_engine().get()["rect"] == [.1, .2, .5, .8] + + def test_constrained_layout_corners(self): + + p = Plot().layout(engine="constrained", corners=(.1, .2, .6, 1)).plot() + assert p._figure.get_layout_engine().get()["rect"] == [.1, .2, .5, .8] + + def test_base_layout_corners(self): + + p = Plot().layout(engine=None, corners=(.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() From 258de08a751f09c5771bee937e1ec42f2628b33f Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Tue, 7 Nov 2023 14:06:48 -0500 Subject: [PATCH 2/4] Rename corners -> extent --- seaborn/_core/plot.py | 20 ++++++++++---------- tests/_core/test_plot.py | 12 ++++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/seaborn/_core/plot.py b/seaborn/_core/plot.py index 09cd8a02b2..9b80fa3be5 100644 --- a/seaborn/_core/plot.py +++ b/seaborn/_core/plot.py @@ -810,7 +810,7 @@ def layout( *, size: tuple[float, float] | Default = default, engine: str | None | Default = default, - corners: tuple[float, float, float, float] | Default = default, + extent: tuple[float, float, float, float] | Default = default, ) -> Plot: """ Control the figure size and layout. @@ -829,10 +829,10 @@ def layout( 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. - corners : (left, bottom, right, top) - Position of the layout corners, in fractions of the figure size. - Corners are inclusive of axis decorations when using a layout engine, - but they are exclusive when `engine=None`. + extent : (left, bottom, right, top) + Boundaries of the plot layout, in fractions of the figure size. + Note: the extent includes of axis decorations when using a layout engine, + but it is exclusive when `engine=None`. Examples -------- @@ -850,8 +850,8 @@ def layout( new._figure_spec["figsize"] = size if engine is not default: new._layout_spec["engine"] = engine - if corners is not default: - new._layout_spec["corners"] = corners + if extent is not default: + new._layout_spec["extent"] = extent return new @@ -1810,13 +1810,13 @@ def _finalize_figure(self, p: Plot) -> None: # TODO either way, make configurable set_layout_engine(self._figure, "tight") - if (corners := p._layout_spec.get("corners")) is not None: + if (extent := p._layout_spec.get("extent")) is not None: engine = self._figure.get_layout_engine() if engine is None: - self._figure.subplots_adjust(*corners) + self._figure.subplots_adjust(*extent) else: # Note the different parameterization for the layout engine rect... - left, bottom, right, top = corners + left, bottom, right, top = extent width, height = right - left, top - bottom try: # The base LayoutEngine.set method doesn't have rect= so we need diff --git a/tests/_core/test_plot.py b/tests/_core/test_plot.py index 0689298b8c..e504e5b1a7 100644 --- a/tests/_core/test_plot.py +++ b/tests/_core/test_plot.py @@ -1091,19 +1091,19 @@ def test_layout_size(self): p = Plot().layout(size=size).plot() assert tuple(p._figure.get_size_inches()) == size - def test_layout_corners(self): + def test_layout_extent(self): - p = Plot().layout(corners=(.1, .2, .6, 1)).plot() + p = Plot().layout(extent=(.1, .2, .6, 1)).plot() assert p._figure.get_layout_engine().get()["rect"] == [.1, .2, .5, .8] - def test_constrained_layout_corners(self): + def test_constrained_layout_extent(self): - p = Plot().layout(engine="constrained", corners=(.1, .2, .6, 1)).plot() + 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_corners(self): + def test_base_layout_extent(self): - p = Plot().layout(engine=None, corners=(.1, .2, .6, 1)).plot() + 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 From 094f9c446a1f1c78e08e7618f158ea1dd9a31680 Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Tue, 7 Nov 2023 14:25:38 -0500 Subject: [PATCH 3/4] Backcompat for get_layout_engine --- seaborn/_compat.py | 26 +++++++++++++++++++++----- seaborn/_core/plot.py | 4 ++-- tests/_core/test_plot.py | 8 ++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) 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 9b80fa3be5..2b85f8f9dc 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 @@ -1811,7 +1811,7 @@ def _finalize_figure(self, p: Plot) -> None: set_layout_engine(self._figure, "tight") if (extent := p._layout_spec.get("extent")) is not None: - engine = self._figure.get_layout_engine() + engine = get_layout_engine(self._figure) if engine is None: self._figure.subplots_adjust(*extent) else: diff --git a/tests/_core/test_plot.py b/tests/_core/test_plot.py index e504e5b1a7..97e55e5589 100644 --- a/tests/_core/test_plot.py +++ b/tests/_core/test_plot.py @@ -1091,11 +1091,19 @@ 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() From 596ad4f85f31f39bcedd2fbca935647b1a2fdf84 Mon Sep 17 00:00:00 2001 From: Michael Waskom Date: Tue, 7 Nov 2023 17:00:25 -0500 Subject: [PATCH 4/4] Improve documentation --- doc/_docstrings/objects.Plot.layout.ipynb | 20 +++++++++++++++++++- seaborn/_core/plot.py | 9 +++++---- 2 files changed, 24 insertions(+), 5 deletions(-) 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/_core/plot.py b/seaborn/_core/plot.py index 2b85f8f9dc..39ccd2e0bd 100644 --- a/seaborn/_core/plot.py +++ b/seaborn/_core/plot.py @@ -826,13 +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. - Note: the extent includes of axis decorations when using a layout engine, - but it is exclusive when `engine=None`. + 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 --------