8000 ENH: add outside kwarg to figure legend · matplotlib/matplotlib@3d369d4 · GitHub
[go: up one dir, main page]

Skip to content

Commit 3d369d4

Browse files
committed
ENH: add outside kwarg to figure legend
1 parent 0e3a261 commit 3d369d4

File tree

8 files changed

+243
-50
lines changed

8 files changed

+243
-50
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Figure legends can be placed outside figures using constrained_layout
2+
---------------------------------------------------------------------
3+< 8000 /span>
Constrained layout will make space for Figure legends if they are specified
4+
by a *loc* keyword argument that starts with the string "outside". The
5+
codes are unique from axes codes, in that "outside upper right" will
6+
make room at the top of the figure for the legend, whereas
7+
"outside right upper" will make room on the right-hand side of the figure.
8+
See :doc:`/tutorials/intermediate/legend_guide` for details.

examples/text_labels_and_annotations/figlegend_demo.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,26 @@
2828

2929
plt.tight_layout()
3030
plt.show()
31+
32+
##############################################################################
33+
# Sometimes we do not want the legend to overlap the axes. If you use
34+
# constrained_layout you can specify "outside right upper", and
35+
# constrained_layout will make room for the legend.
36+
37+
fig, axs = plt.subplots(1, 2, layout='constrained')
38+
39+
x = np.arange(0.0, 2.0, 0.02)
40+
y1 = np.sin(2 * np.pi * x)
41+
y2 = np.exp(-x)
42+
l1, = axs[0].plot(x, y1)
43+
l2, = axs[0].plot(x, y2, marker='o')
44+
45+
y3 = np.sin(4 * np.pi * x)
46+
y4 = np.exp(-2 * x)
47+
l3, = axs[1].plot(x, y3, color='tab:green')
48+
l4, = axs[1].plot(x, y4, color='tab:red', marker='^')
49+
50+
fig.legend((l1, l2), ('Line 1', 'Line 2'), loc='upper left')
51+
fig.legend((l3, l4), ('Line 3', 'Line 4'), loc='outside right upper')
52+
53+
plt.show()

lib/matplotlib/_constrained_layout.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,25 @@ def make_layout_margins(layoutgrids, fig, renderer, *, w_pad=0, h_pad=0,
418418
# pass the new margins down to the layout grid for the solution...
419419
layoutgrids[gs].edit_outer_margin_mins(margin, ss)
420420

421+
# make margins for figure-level legends:
422+
for leg in fig.legends:
423+
inv_trans_fig = None
424+
if leg._outside and leg._bbox_to_anchor is None:
425+
if inv_trans_fig is None:
426+
inv_trans_fig = fig.transFigure.inverted().transform_bbox
427+
bbox = inv_trans_fig(leg.get_tightbbox(renderer))
428+
w = bbox.width + 2 * w_pad
429+
h = bbox.height + 2 * h_pad
430+
legendloc = leg._outside
431+
if legendloc == 'lower':
432+
layoutgrids[fig].edit_margin_min('bottom', h)
433+
elif legendloc == 'upper':
434+
layoutgrids[fig].edit_margin_min('top', h)
435+
if legendloc == 'right':
436+
layoutgrids[fig].edit_margin_min('right', w)
437+
elif legendloc == 'left':
438+
layoutgrids[fig].edit_margin_min('left', w)
439+
421440

422441
def make_margin_suptitles(layoutgrids, fig, renderer, *, w_pad=0, h_pad=0):
423442
# Figure out how large the suptitle is and make the

lib/matplotlib/axes/_axes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ def legend(self, *args, **kwargs):
294294
295295
Other Parameters
296296
----------------
297-
%(_legend_kw_doc)s
297+
%(_legend_kw_axes)s
298298
299299
See Also
300300
--------

lib/matplotlib/figure.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1085,7 +1085,8 @@ def legend(self, *args, **kwargs):
10851085
10861086
Other Parameters
10871087
----------------
1088-
%(_legend_kw_doc)s
1088+
%(_legend_kw_figure)s
1089+
10891090
10901091
See Also
10911092
--------

lib/matplotlib/legend.py

Lines changed: 100 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -94,51 +94,7 @@ def _update_bbox_to_anchor(self, loc_in_canvas):
9494
self.legend.set_bbox_to_anchor(loc_in_bbox)
9595

9696

97-
_docstring.interpd.update(_legend_kw_doc="""
98-
loc : str or pair of floats, default: :rc:`legend.loc` ('best' for axes, \
99-
'upper right' for figures)
100-
The location of the legend.
101-
102-
The strings
103-
``'upper left', 'upper right', 'lower left', 'lower right'``
104-
place the legend at the corresponding corner of the axes/figure.
105-
106-
The strings
107-
``'upper center', 'lower center', 'center left', 'center right'``
108-
place the legend at the center of the corresponding edge of the
109-
axes/figure.
110-
111-
The string ``'center'`` places the legend at the center of the axes/figure.
112-
113-
The string ``'best'`` places the legend at the location, among the nine
114-
locations defined so far, with the minimum overlap with other drawn
115-
artists. This option can be quite slow for plots with large amounts of
116-
data; your plotting speed may benefit from providing a specific location.
117-
118-
The location can also be a 2-tuple giving the coordinates of the lower-left
119-
corner of the legend in axes coordinates (in which case *bbox_to_anchor*
120-
will be ignored).
121-
122-
For back-compatibility, ``'center right'`` (but no other location) can also
123-
be spelled ``'right'``, and each "string" locations can also be given as a
124-
numeric value:
125-
126-
=============== =============
127-
Location String Location Code
128-
=============== =============
129-
'best' 0
130-
'upper right' 1
131-
'upper left' 2
132-
'lower left' 3
133-
'lower right' 4
134-
'right' 5
135-
'center left' 6
136-
'center right' 7
137-
'lower center' 8
138-
'upper center' 9
139-
'center' 10
140-
=============== =============
141-
97+
_legend_kw_doc_base = """
14298
bbox_to_anchor : `.BboxBase`, 2-tuple, or 4-tuple of floats
14399
Box that is used to position the legend in conjunction with *loc*.
144100
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):
295251
296252
draggable : bool, default: False
297253
Whether the legend can be dragged with the mouse.
298-
""")
254+
"""
255+
256+
_loc_doc_base = """
257+
loc : str or pair of floats, {0}
258+
The location of the legend.
259+
260+
The strings
261+
``'upper left', 'upper right', 'lower left', 'lower right'``
262+
place the legend at the corresponding corner of the axes/figure.
263+
264+
The strings
265+
``'upper center', 'lower center', 'center left', 'center right'``
266+
place the legend at the center of the corresponding edge of the
267+
axes/figure.
268+
269+
The string ``'center'`` places the legend at the center of the axes/figure.
270+
271+
The string ``'best'`` places the legend at the location, among the nine
272+
locations defined so far, with the minimum overlap with other drawn
273+
artists. This option can be quite slow for plots with large amounts of
274+
data; your plotting speed may benefit from providing a specific location.
275+
276+
The location can also be a 2-tuple giving the coordinates of the lower-left
277+
corner of the legend in axes coordinates (in which case *bbox_to_anchor*
278+
will be ignored).
279+
280+
For back-compatibility, ``'center right'`` (but no other location) can also
281+
be spelled ``'right'``, and each "string" locations can also be given as a
282+
numeric value:
283+
284+
=============== =============
285+
Location String Location Code
286+
=============== =============
287+
'best' 0
288+
'upper right' 1
289+
'upper left' 2
290+
'lower left' 3
291+
'lower right' 4
292+
'right' 5
293+
'center left' 6
294+
'center right' 7
295+
'lower center' 8
296+
'upper center' 9
297+
'center' 10
298+
=============== =============
299+
{1}"""
300+
301+
_legend_kw_axes_st = (_loc_doc_base.format("default: :rc:`legend.loc`", '') +
302+
_legend_kw_doc_base)
303+
_docstring.interpd.update(_legend_kw_axes=_legend_kw_axes_st)
304+
305+
_outside_doc = """
306+
If a figure is using the constrained layout manager, the string codes
307+
of the *loc* keyword argument can get better layout behaviour using the
308+
prefix 'outside'. There is ambiguity at the corners, so 'outside
309+
upper right' will make space for the legend above the rest of the
310+
axes in the layout, and 'outside right upper' will make space on the
311+
right side of the layout. In addition to the values of *loc*
312+
listed above, we have 'outside right upper', 'outside right lower',
313+
'outside left upper', and 'outside left lower'. See
314+
:doc:`/tutorials/intermediate/legend_guide` for more details.
315+
"""
316+
317+
_legend_kw_figure_st = (_loc_doc_base.format("default: 'upper right'",
318+
_outside_doc) +
319+
_legend_kw_doc_base)
320+
_docstring.interpd.update(_legend_kw_figure=_legend_kw_figure_st)
321+
322+
_legend_kw_both_st = (
323+
_loc_doc_base.format("default: 'best' for axes, 'upper right' for figures",
324+
_outside_doc) +
325+
_legend_kw_doc_base)
326+
_docstring.interpd.update(_legend_kw_doc=_legend_kw_both_st)
299327

300328

301329
class Legend(Artist):
@@ -482,13 +510,39 @@ def val_or_rc(val, rc_name):
482510
)
483511
self.parent = parent
484512

513+
loc0 = loc
485514
self._loc_used_default = loc is None
486515
if loc is None:
487516
loc = mpl.rcParams["legend.loc"]
488517
if not self.isaxes and loc in [0, 'best']:
489518
loc = 'upper right'
519+
520+
# handle outside legends:
521+
self._outside = None
490522
if isinstance(loc, str):
523+
if loc.split()[0] == 'outside':
524+
# strip outside:
525+
loc = loc.split('outside ')[1]
526+
# strip "center" at the beginning
527+
self._outside = loc.replace('center ', '')
528+
# strip first
529+
self._outside = self._outside.split()[0]
530+
locs = loc.split()
531+
if len(locs) > 1 and locs[0] in ('right', 'left'):
532+
# locs doesn't accept "left upper", etc, so swap
533+
if locs[0] != 'center':
534+
locs = locs[::-1]
535+
loc = locs[0] + ' ' + locs[1]
536+
# check that loc is in acceptable strings
491537
loc = _api.check_getitem(self.codes, loc=loc)
538+
539+
if self.isaxes and self._outside:
540+
# warn if user has done "outside upper right", but don't
541+
# error because at this point we just drop the "outside".
542+
raise ValueError(
543+
f"'outside' option for loc='{loc0}' keyword argument only "
544+
"works for figure legends")
545+
492546
if not self.isaxes and loc == 0:
493547
raise ValueError(
494548
"Automatic legend placement (loc='best') not implemented for "

lib/matplotlib/tests/test_legend.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import warnings
55

66
import numpy as np
7+
from numpy.testing import assert_allclose
78
import pytest
89

910
from matplotlib.testing.decorators import check_figures_equal, image_comparison
@@ -18,7 +19,6 @@
1819
import matplotlib.legend as mlegend
1920
from matplotlib import rc_context
2021
from matplotlib.font_manager import FontProperties
21-
from numpy.testing import assert_allclose
2222

2323

2424
def test_legend_ordereddict():
@@ -486,6 +486,47 @@ def test_warn_args_kwargs(self):
486486
"be discarded.")
487487

488488

489+
def test_figure_legend_outside():
490+
todos = ['upper ' + pos for pos in ['left', 'center', 'right']]
491+
todos += ['lower ' + pos for pos in ['left', 'center', 'right']]
492+
todos += ['left ' + pos for pos in ['lower', 'center', 'upper']]
493+
todos += ['right ' + pos for pos in ['lower', 'center', 'upper']]
494+
495+
upperext = [20.347556, 27.722556, 790.583, 545.499]
496+
lowerext = [20.347556, 71.056556, 790.583, 588.833]
497+
leftext = [151.681556, 27.722556, 790.583, 588.833]
498+
rightext = [20.347556, 27.722556, 659.249, 588.833]
499+
axbb = [upperext, upperext, upperext,
500+
lowerext, lowerext, lowerext,
501+
leftext, leftext, leftext,
502+
rightext, rightext, rightext]
503+
504+
legbb = [[10., 555., 133., 590.], # upper left
505+
[338.5, 555., 461.5, 590.], # upper center
506+
[667, 555., 790., 590.], # upper right
507+
[10., 10., 133., 45.], # lower left
508+
[338.5, 10., 461.5, 45.], # lower center
509+
[667., 10., 790., 45.], # lower right
510+
[10., 10., 133., 45.], # left lower
511+
[10., 282.5, 133., 317.5], # left center
512+
[10., 555., 133., 590.], # left upper
513+
[667, 10., 790., 45.], # right lower
514+
[667., 282.5, 790., 317.5], # right center
515+
[667., 555., 790., 590.]] # right upper
516+
517+
for nn, todo in enumerate(todos):
518+
print(todo)
519+
fig, axs = plt.subplots(constrained_layout=True, dpi=100)
520+
axs.plot(range(10), label='Boo1')
521+
leg = fig.legend(loc='outside ' + todo)
522+
fig.draw_without_rendering()
523+
524+
assert_allclose(axs.get_window_extent().extents,
525+
axbb[nn])
526+
assert_allclose(leg.get_window_extent().extents,
527+
legbb[nn])
528+
529+
489530
@image_comparison(['legend_stackplot.png'])
490531
def test_legend_stackplot():
491532
"""Test legend for PolyCollection using stackplot."""

tutorials/intermediate/legend_guide.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,54 @@
135135
ax_dict['bottom'].legend(bbox_to_anchor=(1.05, 1),
136136
loc='upper left', borderaxespad=0.)
137137

138-
plt.show()
138+
##############################################################################
139+
# Figure legends
140+
# --------------
141+
#
142+
# Sometimes it makes more sense to place a legend relative to the (sub)figure
143+
# rather than individual Axes. By using ``constrained_layout`` and
144+
# specifying "outside" at the beginning of the *loc* keyword argument,
145+
# the legend is drawn outside the Axes on the (sub)figure.
146+
147+
fig, axs = plt.subplot_mosaic([['left', 'right']], layout='constrained')
148+
149+
axs['left'].plot([1, 2, 3], label="test1")
150+
axs['left'].plot([3, 2, 1], label="test2")
151+
152+
axs['right'].plot([1, 2, 3], 'C2', label="test3")
153+
axs['right'].plot([3, 2, 1], 'C3', label="test4")
154+
# Place a legend to the right of this smaller subplot.
155+
fig.legend(loc='outside upper right')
156+
157+
##############################################################################
158+
# This accepts a slightly different grammar than the normal *loc* keyword,
159+
# where "outside right upper" is different from "outside upper right".
160+
#
161+
ucl = ['upper', 'center', 'lower']
162+
lcr = ['left', 'center', 'right']
163+
fig, ax = plt.subplots(figsize=(6, 4), layout='constrained', facecolor='0.7')
164+
165+
ax.plot([1, 2], [1, 2], label='TEST')
166+
# Place a legend to the right of this smaller subplot.
167+
for loc in [
168+
'outside upper left',
169+
'outside upper center',
170+
'outside upper right',
171+
'outside lower left',
172+
'outside lower center',
173+
'outside lower right']:
174+
fig.legend(loc=loc, title=loc)
175+
176+
fig, ax = plt.subplots(figsize=(6, 4), layout='constrained', facecolor='0.7')
177+
ax.plot([1, 2], [1, 2], label='test')
178+
179+
for loc in [
180+
'outside left upper',
181+
'outside right upper',
182+
'outside left lower',
183+
'outside right lower']:
184+
fig.legend(loc=loc, title=loc)
185+
139186

140187
###############################################################################
141188
# Multiple legends on the same Axes

0 commit comments

Comments
 (0)
0