Skip to content

ENH: allow fig.legend outside axes... #19743

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/users/next_whats_new/legend-figure-outside.rst
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions examples/text_labels_and_annotations/figlegend_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
19 changes: 19 additions & 0 deletions lib/matplotlib/_constrained_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ def legend(self, *args, **kwargs):

Other Parameters
----------------
%(_legend_kw_doc)s
%(_legend_kw_axes)s

See Also
--------
Expand Down
3 changes: 2 additions & 1 deletion lib/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,8 @@ def legend(self, *args, **kwargs):

Other Parameters
----------------
%(_legend_kw_doc)s
%(_legend_kw_figure)s


See Also
--------
Expand Down
144 changes: 98 additions & 46 deletions lib/matplotlib/legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 "
Expand Down
43 changes: 42 additions & 1 deletion lib/matplotlib/tests/test_legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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."""
Expand Down
49 changes: 48 additions & 1 deletion tutorials/intermediate/legend_guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down