diff --git a/doc/users/next_whats_new/legend-figure-outside.rst b/doc/users/next_whats_new/legend-figure-outside.rst new file mode 100644 index 000000000000..a78725b4e2b6 --- /dev/null +++ b/doc/users/next_whats_new/legend-figure-outside.rst @@ -0,0 +1,8 @@ +Figure legends can be placed outside figures using constrained_layout +--------------------------------------------------------------------- +Constrained layout will make space for Figure legends if they are specified +by a *loc* keyword argument that starts with the string "outside". The +codes are unique from axes codes, in that "outside upper right" will +make room at the top of the figure for the legend, whereas +"outside right upper" will make room on the right-hand side of the figure. +See :doc:`/tutorials/intermediate/legend_guide` for details. diff --git a/examples/text_labels_and_annotations/figlegend_demo.py b/examples/text_labels_and_annotations/figlegend_demo.py index 6fc31235f634..c749ae795cd5 100644 --- a/examples/text_labels_and_annotations/figlegend_demo.py +++ b/examples/text_labels_and_annotations/figlegend_demo.py @@ -28,3 +28,26 @@ plt.tight_layout() plt.show() + +############################################################################## +# Sometimes we do not want the legend to overlap the axes. If you use +# constrained_layout you can specify "outside right upper", and +# constrained_layout will make room for the legend. + +fig, axs = plt.subplots(1, 2, layout='constrained') + +x = np.arange(0.0, 2.0, 0.02) +y1 = np.sin(2 * np.pi * x) +y2 = np.exp(-x) +l1, = axs[0].plot(x, y1) +l2, = axs[0].plot(x, y2, marker='o') + +y3 = np.sin(4 * np.pi * x) +y4 = np.exp(-2 * x) +l3, = axs[1].plot(x, y3, color='tab:green') +l4, = axs[1].plot(x, y4, color='tab:red', marker='^') + +fig.legend((l1, l2), ('Line 1', 'Line 2'), loc='upper left') +fig.legend((l3, l4), ('Line 3', 'Line 4'), loc='outside right upper') + +plt.show() diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index 996603300620..9554a156f1ec 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -418,6 +418,25 @@ def make_layout_margins(layoutgrids, fig, renderer, *, w_pad=0, h_pad=0, # pass the new margins down to the layout grid for the solution... layoutgrids[gs].edit_outer_margin_mins(margin, ss) + # make margins for figure-level legends: + for leg in fig.legends: + inv_trans_fig = None + if leg._outside_loc and leg._bbox_to_anchor is None: + if inv_trans_fig is None: + inv_trans_fig = fig.transFigure.inverted().transform_bbox + bbox = inv_trans_fig(leg.get_tightbbox(renderer)) + w = bbox.width + 2 * w_pad + h = bbox.height + 2 * h_pad + legendloc = leg._outside_loc + if legendloc == 'lower': + layoutgrids[fig].edit_margin_min('bottom', h) + elif legendloc == 'upper': + layoutgrids[fig].edit_margin_min('top', h) + if legendloc == 'right': + layoutgrids[fig].edit_margin_min('right', w) + elif legendloc == 'left': + layoutgrids[fig].edit_margin_min('left', w) + def make_margin_suptitles(layoutgrids, fig, renderer, *, w_pad=0, h_pad=0): # Figure out how large the suptitle is and make the diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 58d95912665b..399a79e05cfe 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -294,7 +294,7 @@ def legend(self, *args, **kwargs): Other Parameters ---------------- - %(_legend_kw_doc)s + %(_legend_kw_axes)s See Also -------- diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index b006ca467be1..30e117183176 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -1085,7 +1085,8 @@ def legend(self, *args, **kwargs): Other Parameters ---------------- - %(_legend_kw_doc)s + %(_legend_kw_figure)s + See Also -------- diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 66182c68cb60..e9e0651066b3 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -94,51 +94,7 @@ def _update_bbox_to_anchor(self, loc_in_canvas): self.legend.set_bbox_to_anchor(loc_in_bbox) -_docstring.interpd.update(_legend_kw_doc=""" -loc : str or pair of floats, default: :rc:`legend.loc` ('best' for axes, \ -'upper right' for figures) - The location of the legend. - - The strings - ``'upper left', 'upper right', 'lower left', 'lower right'`` - place the legend at the corresponding corner of the axes/figure. - - The strings - ``'upper center', 'lower center', 'center left', 'center right'`` - place the legend at the center of the corresponding edge of the - axes/figure. - - The string ``'center'`` places the legend at the center of the axes/figure. - - The string ``'best'`` places the legend at the location, among the nine - locations defined so far, with the minimum overlap with other drawn - artists. This option can be quite slow for plots with large amounts of - data; your plotting speed may benefit from providing a specific location. - - The location can also be a 2-tuple giving the coordinates of the lower-left - corner of the legend in axes coordinates (in which case *bbox_to_anchor* - will be ignored). - - For back-compatibility, ``'center right'`` (but no other location) can also - be spelled ``'right'``, and each "string" locations can also be given as a - numeric value: - - =============== ============= - Location String Location Code - =============== ============= - 'best' 0 - 'upper right' 1 - 'upper left' 2 - 'lower left' 3 - 'lower right' 4 - 'right' 5 - 'center left' 6 - 'center right' 7 - 'lower center' 8 - 'upper center' 9 - 'center' 10 - =============== ============= - +_legend_kw_doc_base = """ bbox_to_anchor : `.BboxBase`, 2-tuple, or 4-tuple of floats Box that is used to position the legend in conjunction with *loc*. Defaults to `axes.bbox` (if called as a method to `.Axes.legend`) or @@ -295,7 +251,79 @@ def _update_bbox_to_anchor(self, loc_in_canvas): draggable : bool, default: False Whether the legend can be dragged with the mouse. -""") +""" + +_loc_doc_base = """ +loc : str or pair of floats, {0} + The location of the legend. + + The strings + ``'upper left', 'upper right', 'lower left', 'lower right'`` + place the legend at the corresponding corner of the axes/figure. + + The strings + ``'upper center', 'lower center', 'center left', 'center right'`` + place the legend at the center of the corresponding edge of the + axes/figure. + + The string ``'center'`` places the legend at the center of the axes/figure. + + The string ``'best'`` places the legend at the location, among the nine + locations defined so far, with the minimum overlap with other drawn + artists. This option can be quite slow for plots with large amounts of + data; your plotting speed may benefit from providing a specific location. + + The location can also be a 2-tuple giving the coordinates of the lower-left + corner of the legend in axes coordinates (in which case *bbox_to_anchor* + will be ignored). + + For back-compatibility, ``'center right'`` (but no other location) can also + be spelled ``'right'``, and each "string" locations can also be given as a + numeric value: + + =============== ============= + Location String Location Code + =============== ============= + 'best' 0 + 'upper right' 1 + 'upper left' 2 + 'lower left' 3 + 'lower right' 4 + 'right' 5 + 'center left' 6 + 'center right' 7 + 'lower center' 8 + 'upper center' 9 + 'center' 10 + =============== ============= + {1}""" + +_legend_kw_axes_st = (_loc_doc_base.format("default: :rc:`legend.loc`", '') + + _legend_kw_doc_base) +_docstring.interpd.update(_legend_kw_axes=_legend_kw_axes_st) + +_outside_doc = """ + If a figure is using the constrained layout manager, the string codes + of the *loc* keyword argument can get better layout behaviour using the + prefix 'outside'. There is ambiguity at the corners, so 'outside + upper right' will make space for the legend above the rest of the + axes in the layout, and 'outside right upper' will make space on the + right side of the layout. In addition to the values of *loc* + listed above, we have 'outside right upper', 'outside right lower', + 'outside left upper', and 'outside left lower'. See + :doc:`/tutorials/intermediate/legend_guide` for more details. +""" + +_legend_kw_figure_st = (_loc_doc_base.format("default: 'upper right'", + _outside_doc) + + _legend_kw_doc_base) +_docstring.interpd.update(_legend_kw_figure=_legend_kw_figure_st) + +_legend_kw_both_st = ( + _loc_doc_base.format("default: 'best' for axes, 'upper right' for figures", + _outside_doc) + + _legend_kw_doc_base) +_docstring.interpd.update(_legend_kw_doc=_legend_kw_both_st) class Legend(Artist): @@ -482,13 +510,37 @@ def val_or_rc(val, rc_name): ) self.parent = parent + loc0 = loc self._loc_used_default = loc is None if loc is None: loc = mpl.rcParams["legend.loc"] if not self.isaxes and loc in [0, 'best']: loc = 'upper right' + + # handle outside legends: + self._outside_loc = None if isinstance(loc, str): + if loc.split()[0] == 'outside': + # strip outside: + loc = loc.split('outside ')[1] + # strip "center" at the beginning + self._outside_loc = loc.replace('center ', '') + # strip first + self._outside_loc = self._outside_loc.split()[0] + locs = loc.split() + if len(locs) > 1 and locs[0] in ('right', 'left'): + # locs doesn't accept "left upper", etc, so swap + if locs[0] != 'center': + locs = locs[::-1] + loc = locs[0] + ' ' + locs[1] + # check that loc is in acceptable strings loc = _api.check_getitem(self.codes, loc=loc) + + if self.isaxes and self._outside_loc: + raise ValueError( + f"'outside' option for loc='{loc0}' keyword argument only " + "works for figure legends") + if not self.isaxes and loc == 0: raise ValueError( "Automatic legend placement (loc='best') not implemented for " diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 56794a3428b8..a8d7fd107d8b 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -4,6 +4,7 @@ import warnings import numpy as np +from numpy.testing import assert_allclose import pytest from matplotlib.testing.decorators import check_figures_equal, image_comparison @@ -18,7 +19,6 @@ import matplotlib.legend as mlegend from matplotlib import rc_context from matplotlib.font_manager import FontProperties -from numpy.testing import assert_allclose def test_legend_ordereddict(): @@ -486,6 +486,47 @@ def test_warn_args_kwargs(self): "be discarded.") +def test_figure_legend_outside(): + todos = ['upper ' + pos for pos in ['left', 'center', 'right']] + todos += ['lower ' + pos for pos in ['left', 'center', 'right']] + todos += ['left ' + pos for pos in ['lower', 'center', 'upper']] + todos += ['right ' + pos for pos in ['lower', 'center', 'upper']] + + upperext = [20.347556, 27.722556, 790.583, 545.499] + lowerext = [20.347556, 71.056556, 790.583, 588.833] + leftext = [151.681556, 27.722556, 790.583, 588.833] + rightext = [20.347556, 27.722556, 659.249, 588.833] + axbb = [upperext, upperext, upperext, + lowerext, lowerext, lowerext, + leftext, leftext, leftext, + rightext, rightext, rightext] + + legbb = [[10., 555., 133., 590.], # upper left + [338.5, 555., 461.5, 590.], # upper center + [667, 555., 790., 590.], # upper right + [10., 10., 133., 45.], # lower left + [338.5, 10., 461.5, 45.], # lower center + [667., 10., 790., 45.], # lower right + [10., 10., 133., 45.], # left lower + [10., 282.5, 133., 317.5], # left center + [10., 555., 133., 590.], # left upper + [667, 10., 790., 45.], # right lower + [667., 282.5, 790., 317.5], # right center + [667., 555., 790., 590.]] # right upper + + for nn, todo in enumerate(todos): + print(todo) + fig, axs = plt.subplots(constrained_layout=True, dpi=100) + axs.plot(range(10), label='Boo1') + leg = fig.legend(loc='outside ' + todo) + fig.draw_without_rendering() + + assert_allclose(axs.get_window_extent().extents, + axbb[nn]) + assert_allclose(leg.get_window_extent().extents, + legbb[nn]) + + @image_comparison(['legend_stackplot.png']) def test_legend_stackplot(): """Test legend for PolyCollection using stackplot.""" diff --git a/tutorials/intermediate/legend_guide.py b/tutorials/intermediate/legend_guide.py index 596d6df93b55..338fca72fbf1 100644 --- a/tutorials/intermediate/legend_guide.py +++ b/tutorials/intermediate/legend_guide.py @@ -135,7 +135,54 @@ ax_dict['bottom'].legend(bbox_to_anchor=(1.05, 1), loc='upper left', borderaxespad=0.) -plt.show() +############################################################################## +# Figure legends +# -------------- +# +# Sometimes it makes more sense to place a legend relative to the (sub)figure +# rather than individual Axes. By using ``constrained_layout`` and +# specifying "outside" at the beginning of the *loc* keyword argument, +# the legend is drawn outside the Axes on the (sub)figure. + +fig, axs = plt.subplot_mosaic([['left', 'right']], layout='constrained') + +axs['left'].plot([1, 2, 3], label="test1") +axs['left'].plot([3, 2, 1], label="test2") + +axs['right'].plot([1, 2, 3], 'C2', label="test3") +axs['right'].plot([3, 2, 1], 'C3', label="test4") +# Place a legend to the right of this smaller subplot. +fig.legend(loc='outside upper right') + +############################################################################## +# This accepts a slightly different grammar than the normal *loc* keyword, +# where "outside right upper" is different from "outside upper right". +# +ucl = ['upper', 'center', 'lower'] +lcr = ['left', 'center', 'right'] +fig, ax = plt.subplots(figsize=(6, 4), layout='constrained', facecolor='0.7') + +ax.plot([1, 2], [1, 2], label='TEST') +# Place a legend to the right of this smaller subplot. +for loc in [ + 'outside upper left', + 'outside upper center', + 'outside upper right', + 'outside lower left', + 'outside lower center', + 'outside lower right']: + fig.legend(loc=loc, title=loc) + +fig, ax = plt.subplots(figsize=(6, 4), layout='constrained', facecolor='0.7') +ax.plot([1, 2], [1, 2], label='test') + +for loc in [ + 'outside left upper', + 'outside right upper', + 'outside left lower', + 'outside right lower']: + fig.legend(loc=loc, title=loc) + ############################################################################### # Multiple legends on the same Axes