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