diff --git a/examples/text_labels_and_annotations/figlegendoutside_demo.py b/examples/text_labels_and_annotations/figlegendoutside_demo.py new file mode 100644 index 000000000000..b0134c361275 --- /dev/null +++ b/examples/text_labels_and_annotations/figlegendoutside_demo.py @@ -0,0 +1,78 @@ +""" +========================== +Figure legend outside axes +========================== + +Instead of plotting a legend on each axis, a legend for all the artists on all +the sub-axes of a figure can be plotted instead. If constrained layout is +used (:doc:`/tutorials/intermediate/constrainedlayout_guide`) then room +can be made automatically for the legend by using `~.Figure.legend` with the +``outside=True`` kwarg. + +""" + +import numpy as np +import matplotlib.pyplot as plt + +fig, axs = plt.subplots(1, 2, sharey=True, constrained_layout=True) + +x = np.arange(0.0, 2.0, 0.02) +y1 = np.sin(2 * np.pi * x) +y2 = np.exp(-x) +axs[0].plot(x, y1, 'rs-', label='Line1') +h2, = axs[0].plot(x, y2, 'go', label='Line2') + +axs[0].set_ylabel('DATA') + +y3 = np.sin(4 * np.pi * x) +y4 = np.exp(-2 * x) +axs[1].plot(x, y3, 'yd-', label='Line3') +h4, = axs[1].plot(x, y4, 'k^', label='Line4') + +fig.legend(loc='upper center', outside=True, ncol=2) +fig.legend(ax=[axs[1]], outside=True, loc='lower right') +fig.legend(handles=[h2, h4], labels=['curve2', 'curve4'], + outside=True, loc='center left', borderaxespad=6) +plt.show() + +############################################################################### +# The usual codes for the *loc* kwarg are allowed, however, the corner +# codes have an ambiguity as to whether the legend is stacked +# horizontally (the default) or vertically. To specify the vertical stacking +# the *outside* kwarg can be specified with ``"vertical"`` instead of just +# the boolean *True*: + +fig, axs = plt.subplots(1, 2, sharey=True, constrained_layout=True) +axs[0].plot(x, y1, 'rs-', label='Line1') +h2, = axs[0].plot(x, y2, 'go', label='Line2') + +axs[0].set_ylabel('DATA') +axs[1].plot(x, y3, 'yd-', label='Line3') +h4, = axs[1].plot(x, y4, 'k^', label='Line4') + +fig.legend(loc='upper right', outside='vertical', ncol=2) +plt.show() + +############################################################################### +# Significantly more complicated layouts are possible using the gridspec +# organization of subplots: + +fig = plt.figure(constrained_layout=True) +gs0 = fig.add_gridspec(1, 2) + +gs = gs0[0].subgridspec(1, 1) +for i in range(1): + ax = fig.add_subplot(gs[i, 0]) + ax.plot(range(10), label=f'Boo{i}') +lg = fig.legend(ax=[ax], loc='lower right', outside=True, borderaxespad=4) + +gs2 = gs0[1].subgridspec(3, 1) +axx = [] +for i in range(3): + ax = fig.add_subplot(gs2[i, 0]) + ax.plot(range(10), label=f'Who{i}', color=f'C{i+1}') + if i < 2: + ax.set_xticklabels('') + axx += [ax] +lg2 = fig.legend(ax=axx[:-1], loc='upper right', outside=True, borderaxespad=4) +plt.show() diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index b9fac97d30a8..7bb8a0cb3fe1 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -49,6 +49,7 @@ import numpy as np +import matplotlib.gridspec as gridspec import matplotlib.cbook as cbook import matplotlib._layoutbox as layoutbox @@ -182,6 +183,10 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad, # reserve at top of figure include an h_pad above and below suptitle._layoutbox.edit_height(height + h_pad * 2) + # do layout for any legend_offsets + for gs in gss: + _do_offset_legend_layout(gs._layoutbox) + # OK, the above lines up ax._poslayoutbox with ax._layoutbox # now we need to # 1) arrange the subplotspecs. We do it at this level because @@ -227,11 +232,46 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad, else: if suptitle is not None and suptitle._layoutbox is not None: suptitle._layoutbox.edit_height(0) + # now set the position of any offset legends... + for gs in gss: + _do_offset_legend_position(gs._layoutbox) else: cbook._warn_external('constrained_layout not applied. At least ' 'one axes collapsed to zero width or height.') +def _do_offset_legend_layout(gslayoutbox): + """ + Helper to get the right width and height for an offset legend. + """ + for child in gslayoutbox.children: + if child._is_subplotspec_layoutbox(): + # check for nested gridspecs... + for child2 in child.children: + # check for gridspec children... + if child2._is_gridspec_layoutbox(): + _do_offset_legend_layout(child2) + elif isinstance(child.artist, gridspec.LegendLayout): + child.artist._update_width_height() + + +def _do_offset_legend_position(gslayoutbox): + """ + Helper to properly set the offset box for the offset legends... + """ + for child in gslayoutbox.children: + if child._is_subplotspec_layoutbox(): + # check for nested gridspecs... + for child2 in child.children: + # check for gridspec children... + if child2._is_gridspec_layoutbox(): + _do_offset_legend_position(child2) + elif isinstance(child.artist, gridspec.LegendLayout): + # update position... + child.artist.set_bbox_to_anchor(gslayoutbox.get_rect()) + child.artist._update_width_height() + + def _make_ghost_gridspec_slots(fig, gs): """ Check for unoccupied gridspec slots and make ghost axes for these @@ -477,6 +517,7 @@ def _arrange_subplotspecs(gs, hspace=0, wspace=0): if child2._is_gridspec_layoutbox(): _arrange_subplotspecs(child2, hspace=hspace, wspace=wspace) sschildren += [child] + # now arrange the subplots... for child0 in sschildren: ss0 = child0.artist diff --git a/lib/matplotlib/_layoutbox.py b/lib/matplotlib/_layoutbox.py index 4f31f7bdb95e..ed7c6e24a6ab 100644 --- a/lib/matplotlib/_layoutbox.py +++ b/lib/matplotlib/_layoutbox.py @@ -456,7 +456,7 @@ def layout_from_subplotspec(self, subspec, self.width == parent.width * width, self.height == parent.height * height] for c in cs: - self.solver.addConstraint(c | 'required') + self.solver.addConstraint((c | 'strong')) return lb diff --git a/lib/matplotlib/axes/_subplots.py b/lib/matplotlib/axes/_subplots.py index 6ba9439f6b86..9c9e93757d0e 100644 --- a/lib/matplotlib/axes/_subplots.py +++ b/lib/matplotlib/axes/_subplots.py @@ -68,7 +68,6 @@ def __init__(self, fig, *args, **kwargs): raise ValueError(f'Illegal argument(s) to subplot: {args}') self.update_params() - # _axes_class is set in the subplot_class_factory self._axes_class.__init__(self, fig, self.figbox, **kwargs) # add a layout box to this, for both the full axis, and the poss diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 4e60d82a136f..eb2ff3f4fee7 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -30,7 +30,7 @@ from matplotlib.axes import Axes, SubplotBase, subplot_class_factory from matplotlib.blocking_input import BlockingMouseInput, BlockingKeyMouseInput -from matplotlib.gridspec import GridSpec +from matplotlib.gridspec import GridSpec, GridSpecBase import matplotlib.legend as mlegend from matplotlib.patches import Rectangle from matplotlib.projections import (get_projection_names, @@ -663,6 +663,10 @@ def get_children(self): *self.images, *self.legends] + def get_gridspecs(self): + """Get a list of gridspecs associated with the figure.""" + return self._gridspecs + def contains(self, mouseevent): """ Test whether the mouse event occurred on the figure. @@ -1556,11 +1560,7 @@ def subplots(self, nrows=1, ncols=1, sharex=False, sharey=False, subplot_kw = subplot_kw.copy() gridspec_kw = gridspec_kw.copy() - if self.get_constrained_layout(): - gs = GridSpec(nrows, ncols, figure=self, **gridspec_kw) - else: - # this should turn constrained_layout off if we don't want it - gs = GridSpec(nrows, ncols, figure=None, **gridspec_kw) + gs = GridSpec(nrows, ncols, figure=self, **gridspec_kw) self._gridspecs.append(gs) # Create array to hold all axes. @@ -1755,7 +1755,7 @@ def get_axes(self): # docstring of pyplot.figlegend. @docstring.dedent_interpd - def legend(self, *args, **kwargs): + def legend(self, *args, outside=False, ax=None, **kwargs): """ Place a legend on the figure. @@ -1779,6 +1779,19 @@ def legend(self, *args, **kwargs): Parameters ---------- + + outside: bool or string + If ``constrained_layout=True``, then try and place legend outside + axes listed in *axs*, or highest-level gridspec if axs is empty. + Note, "center" and "best" options to *loc* do not work with + ``outside=True``. The corner values of *loc* (i.e. "upper right") + will default to a horizontal layout of the legend, but this can + be changed by specifying a string + ``outside="vertical", loc="upper right"``. + + ax : sequence of `~.axes.Axes` + axes to gather handles from (if *handles* is empty). + handles : list of `.Artist`, optional A list of Artists (lines, patches) to be added to the legend. Use this together with *labels*, if you need full control on what @@ -1807,24 +1820,32 @@ def legend(self, *args, **kwargs): Not all kinds of artist are supported by the legend command. See :doc:`/tutorials/intermediate/legend_guide` for details. """ + if ax is None: + ax = self.axes handles, labels, extra_args, kwargs = mlegend._parse_legend_args( - self.axes, + ax, *args, **kwargs) - # check for third arg - if len(extra_args): - # cbook.warn_deprecated( - # "2.1", - # message="Figure.legend will accept no more than two " - # "positional arguments in the future. Use " - # "'fig.legend(handles, labels, loc=location)' " - # "instead.") - # kwargs['loc'] = extra_args[0] - # extra_args = extra_args[1:] - pass - l = mlegend.Legend(self, handles, labels, *extra_args, **kwargs) - self.legends.append(l) + if outside and not self.get_constrained_layout(): + cbook._warn_external('legend outside=True method needs ' + 'constrained_layout=True. Setting False') + outside = False + if outside and kwargs.get('bbox_to_anchor') is not None: + cbook._warn_external('legend outside=True ignores bbox_to_anchor ' + 'kwarg') + + if not outside: + l = mlegend.Legend(self, handles, labels, *extra_args, **kwargs) + self.legends.append(l) + else: + loc = kwargs.pop('loc') + if isinstance(ax, GridSpecBase): + gs = ax + else: + gs = ax[0].get_gridspec() + l = gs.legend_outside(loc=loc, align=outside, handles=handles, + labels=labels, **kwargs) l._remove_method = self.legends.remove self.stale = True return l diff --git a/lib/matplotlib/gridspec.py b/lib/matplotlib/gridspec.py index 9fe5a0179ea4..9be2ab0e970c 100644 --- a/lib/matplotlib/gridspec.py +++ b/lib/matplotlib/gridspec.py @@ -14,8 +14,10 @@ import numpy as np +import warnings + import matplotlib as mpl -from matplotlib import _pylab_helpers, cbook, tight_layout, rcParams +from matplotlib import _pylab_helpers, cbook, tight_layout, rcParams, legend from matplotlib.transforms import Bbox import matplotlib._layoutbox as layoutbox @@ -174,6 +176,70 @@ def _normalize(key, size, axis): # Includes last index. return SubplotSpec(self, num1, num2) + def legend_outside(self, handles=None, labels=None, axs=None, + align='horizontal', **kwargs): + """ + legend for this gridspec, offset from all the subplots. + + See the *outside* argument for `.Figure.legend` for details on how to + call. + """ + if not (self.figure and self.figure.get_constrained_layout()): + cbook._warn_external('legend_outside method needs ' + 'constrained_layout') + leg = self.figure.legend(**kwargs) + return leg + + if axs is None: + axs = self.figure.get_axes() + padding = kwargs.pop('borderaxespad', rcParams["legend.borderaxespad"]) + + # convert padding from points to figure relative units.... + + handles, labels, extra_args, kwargs = legend._parse_legend_args( + axs, handles=handles, labels=labels, **kwargs) + leg = LegendLayout(self, self.figure, handles, labels, *extra_args, + **kwargs) + # put to the right of any subplots in this gridspec: + + leg._update_width_height() + + if leg._loc in [5, 7, 4, 1]: + stack = 'right' + elif leg._loc in [6, 2, 3]: + stack = 'left' + elif leg._loc in [8]: + stack = 'bottom' + else: + stack = 'top' + + if align == 'vertical': + if leg._loc in [1, 2]: + stack = 'top' + elif leg._loc in [3, 4]: + stack = 'bottom' + + padding = padding * leg._fontsize / 72.0 + paddingw = padding / self.figure.get_size_inches()[0] + paddingh = padding / self.figure.get_size_inches()[1] + + for child in self._layoutbox.children: + if child._is_subplotspec_layoutbox(): + if stack == 'right': + layoutbox.hstack([child, leg._layoutbox], padding=paddingw) + elif stack == 'left': + # stack to the left... + layoutbox.hstack([leg._layoutbox, child], padding=paddingw) + elif stack == 'bottom': + # stack to the bottom... + layoutbox.vstack([child, leg._layoutbox], padding=paddingh) + elif stack == 'top': + # stack to the top... + layoutbox.vstack([leg._layoutbox, child], padding=paddingh) + self.figure.legends.append(leg) + + return leg + class GridSpec(GridSpecBase): """ @@ -346,6 +412,34 @@ def tight_layout(self, figure, renderer=None, self.update(**kwargs) +class LegendLayout(legend.Legend): + """ + `.Legend` subclass that carries layout information.... + """ + + def __init__(self, parent, parent_figure, handles, labels, *args, + **kwargs): + super().__init__(parent_figure, handles, labels, *args, **kwargs) + self._layoutbox = layoutbox.LayoutBox( + parent=parent._layoutbox, + name=parent._layoutbox.name + 'legend' + layoutbox.seq_id(), + artist=self) + + def _update_width_height(self): + + invTransFig = self.figure.transFigure.inverted().transform_bbox + + bbox = invTransFig( + self.get_window_extent(self.figure.canvas.get_renderer())) + height = bbox.height + h_pad = 0 + w_pad = 0 + + self._layoutbox.edit_height(height+h_pad) + width = bbox.width + self._layoutbox.edit_width(width+w_pad) + + class GridSpecFromSubplotSpec(GridSpecBase): """ GridSpec whose subplot layout parameters are inherited from the @@ -369,6 +463,7 @@ def __init__(self, nrows, ncols, width_ratios=width_ratios, height_ratios=height_ratios) # do the layoutboxes + self.figure = subplot_spec._gridspec.figure subspeclb = subplot_spec._layoutbox if subspeclb is None: self._layoutbox = None @@ -428,11 +523,11 @@ def __init__(self, gridspec, num1, num2=None): self.num1 = num1 self.num2 = num2 if gridspec._layoutbox is not None: - glb = gridspec._layoutbox # So note that here we don't assign any layout yet, # just make the layoutbox that will contain all items # associated w/ this axis. This can include other axes like # a colorbar or a legend. + glb = gridspec._layoutbox self._layoutbox = layoutbox.LayoutBox( parent=glb, name=glb.name + '.ss' + layoutbox.seq_id(), diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index ee754bd37379..3d9bcd349063 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -375,6 +375,7 @@ def __init__(self, parent, handles, labels, bbox_transform=None, # transform for the bbox frameon=None, # draw frame handler_map=None, + outside=False, ): """ Parameters @@ -407,6 +408,7 @@ def __init__(self, parent, handles, labels, """ # local import only to avoid circularity from matplotlib.axes import Axes + from matplotlib.gridspec import GridSpec from matplotlib.figure import Figure Artist.__init__(self) @@ -429,6 +431,7 @@ def __init__(self, parent, handles, labels, self.legendHandles = [] self._legend_title_box = None + self.outside = outside #: A dictionary with the extra handler mappings for this Legend #: instance. self._custom_handler_map = handler_map @@ -482,6 +485,9 @@ def __init__(self, parent, handles, labels, self.isaxes = True self.axes = parent self.set_figure(parent.figure) + elif isinstance(parent, GridSpec): + self.isaxes = False + self.set_figure(parent.figure) elif isinstance(parent, Figure): self.isaxes = False self.set_figure(parent) @@ -993,9 +999,11 @@ def get_window_extent(self, renderer=None): 'Return extent of the legend.' if renderer is None: renderer = self.figure._cachedRenderer - return self._legend_box.get_window_extent(renderer=renderer) + bbox = self._legend_box.get_window_extent(renderer) - def get_tightbbox(self, renderer): + return bbox + + def get_tightbbox(self, renderer=None): """ Like `.Legend.get_window_extent`, but uses the box for the legend. @@ -1009,6 +1017,8 @@ def get_tightbbox(self, renderer): ------- `.BboxBase` : containing the bounding box in figure pixel co-ordinates. """ + if renderer is None: + renderer = self.figure._cachedRenderer return self._legend_box.get_window_extent(renderer) def get_frame_on(self): @@ -1099,8 +1109,37 @@ def _get_anchored_bbox(self, loc, bbox, parentbbox, renderer): c = anchor_coefs[loc] fontsize = renderer.points_to_pixels(self._fontsize) - container = parentbbox.padded(-(self.borderaxespad) * fontsize) - anchored_box = bbox.anchored(c, container=container) + if not self.outside: + container = parentbbox.padded(-(self.borderaxespad) * fontsize) + anchored_box = bbox.anchored(c, container=container) + else: + if c in ['NE', 'SE', 'E']: + stack = 'right' + elif c in ['NW', 'SW', 'W']: + stack = 'left' + elif c in ['N']: + stack = 'top' + else: + stack = 'bottom' + if self.outside == 'vertical': + if c in ['NE', 'NW']: + stack = 'top' + elif c in ['SE', 'SW']: + stack = 'bottom' + anchored_box = bbox.anchored(c, container=parentbbox) + if stack == 'right': + anchored_box.x0 = (anchored_box.x0 + anchored_box.width + + (self.borderaxespad) * fontsize) + elif stack == 'left': + anchored_box.x0 = (anchored_box.x0 - anchored_box.width - + (self.borderaxespad) * fontsize) + elif stack == 'bottom': + anchored_box.y0 = (anchored_box.y0 - anchored_box.height - + (self.borderaxespad) * fontsize) + elif stack == 'top': + anchored_box.y0 = (anchored_box.y0 + anchored_box.height + + (self.borderaxespad) * fontsize) + return anchored_box.x0, anchored_box.y0 def _find_best_position(self, width, height, renderer, consider=None): diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index aa3f51b69a83..18b0abced8f9 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -3,6 +3,8 @@ from unittest import mock import numpy as np +from numpy.testing import ( + assert_allclose, assert_array_equal, assert_array_almost_equal) import pytest from matplotlib.testing.decorators import image_comparison @@ -357,7 +359,41 @@ def test_warn_args_kwargs(self): "be discarded.") -@image_comparison(['legend_stackplot.png']) +def test_figure_legend_outside(): + todos = [1, 2, 3, 4, 5, 6, 7, 8, 9] + axbb = [[20.347556, 27.722556, 657.583, 588.833], # upper right + [153.347556, 27.722556, 790.583, 588.833], # upper left + [153.347556, 27.722556, 790.583, 588.833], # lower left + [20.347556, 27.722556, 657.583, 588.833], # lower right + [20.347556, 27.722556, 657.583, 588.833], # right + [153.347556, 27.722556, 790.583, 588.833], # center left + [20.347556, 27.722556, 657.583, 588.833], # center right + [20.347556, 72.722556, 790.583, 588.833], # lower center + [20.347556, 27.722556, 790.583, 543.833], # upper center + ] + legbb = [[667., 555., 790., 590.], + [10., 555., 133., 590.], + [10., 10., 133., 45.], + [667, 10., 790., 45.], + [667., 282.5, 790., 317.5], + [10., 282.5, 133., 317.5], + [667., 282.5, 790., 317.5], + [338.5, 10., 461.5, 45.], + [338.5, 555., 461.5, 590.], + ] + for nn, todo in enumerate(todos): + fig, axs = plt.subplots(constrained_layout=True, dpi=100) + axs.plot(range(10), label=f'Boo1') + leg = fig.legend(loc=todo, outside=True) + renderer = fig.canvas.get_renderer() + fig.canvas.draw() + assert_allclose(axs.get_window_extent(renderer=renderer).extents, + axbb[nn]) + assert_allclose(leg.get_window_extent(renderer=renderer).extents, + legbb[nn]) + + +@image_comparison(baseline_images=['legend_stackplot'], extensions=['png']) def test_legend_stackplot(): '''test legend for PolyCollection using stackplot''' # related to #1341, #1943, and PR #3303 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy