Skip to content

ENH: add figure.legend; outside kwarg for better layout outside subplots #13072

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

Closed
wants to merge 16 commits into from

Conversation

jklymak
Copy link
Member

@jklymak jklymak commented Jan 1, 2019

PR Summary

If constrained_layout is being used, this new kwarg allows the user to automagically place a legend outside the subplots in a grid spec (or all the subplots in a figure for a more straightforward example).

Closes #13023 ping @dcherian

simple example

fig, axs = plt.subplots(1, 2, constrained_layout=True)

for i, ax in enumerate(axs):
    ax.plot(range(10), label=f'Boo{i}')
lg = fig.legend(loc='upper right', outside=True)
plt.show()

yields:

simple

Nested gridspec example

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='upper 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()

yields:

complicated

Added ability to vertically lay-out the corners

See outside='vertical' below...

import matplotlib.pyplot as plt

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='upper 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='vertical',
                 borderaxespad=4, ncol=2)
plt.show()

yields:

figure_1

  • needs more tests

PR Checklist

  • Has Pytest style unit tests
  • Code is Flake 8 compliant
  • New features are documented, with examples if plot related
  • Documentation is sphinx and numpydoc compliant
  • Added an entry to doc/users/next_whats_new/ if major new feature (follow instructions in README.rst there)
  • Documented in doc/api/api_changes.rst if API changed in a backward-incompatible way

@jklymak jklymak added this to the v3.1 milestone Jan 1, 2019
@jklymak jklymak added the topic: geometry manager LayoutEngine, Constrained layout, Tight layout label Jan 1, 2019
@timhoffm
Copy link
Member

timhoffm commented Jan 1, 2019

I‘m positive on the idea. But don‘t have the time to look into this in more detail for a few days.

So just a quick API thought for now: Is it reasonable to have two separate functions fig.legend and fig.legend_outside for the user? Internally, this is quite different, but it might be worth considering having something like fig.legend(loc=„outside upper right“) and branch internally. That of course depends on how similar the API of both versions would be.

(Sorry for the bad formatting - phone keyboard)

@jklymak
Copy link
Member Author

jklymak commented Jan 1, 2019

I‘m positive on the idea. But don‘t have the time to look into this in more detail for a few days.

So just a quick API thought for now: Is it reasonable to have two separate functions fig.legend and fig.legend_outside for the user? Internally, this is quite different, but it might be worth considering having something like fig.legend(loc="outside upper right") and branch internally. That of course depends on how similar the API of both versions would be.

The API can be identical from my point of view, except for specifying "outside" somehow. Its not currently, but thats just because I think we are trying to get away from implicit APIs, and parsing the arguments so that the inputs work at different levels is a bit of a pain, but it can be dealt with.

So,

  1. fig.legend_outside(loc='upper right')
  2. fig.legend(loc='outside upper right'), but what about numerical codes fig.legend(loc=1)?
  3. fig.legend(loc='upper right', outside=True) and fig.legend(loc=1, outside=True) with an error raised if constrained_layout == False or bbox_to_anchor is not None.

I'd be fine w/ 3, but I'm not sure what is more discoverable for users, 1 or 3. Other opinions?

Note that 3 would also mean adding a axs keyword argument to fig.legend, but thats not too bad - its nicer than collecting the handles and labels from the subplots of interest, and maybe a nice enhancement anyway.

@dcherian
Copy link

dcherian commented Jan 1, 2019

I like 3. I'd be more likely to discover it by looking at the docstring for figure.legend

@pharshalp
Copy link
Contributor

pharshalp commented Jan 1, 2019

+1.0 for option 3, -0.01 for option 2, -1.0 for option 1.

@anntzer
Copy link
Contributor

anntzer commented Jan 1, 2019

+1 for option 3.

@jklymak
Copy link
Member Author

jklymak commented Jan 2, 2019

... option 3 it is.

@jklymak jklymak changed the title ENH: add figure.legend_outside ENH: add figure.legend; outside kwarg for better layout outside subplots Jan 2, 2019
@anntzer
Copy link
Contributor

anntzer commented Jan 2, 2019

Actually looking at it again... "upper right" can mean one of two things: in the upper-right corner, to the right of the axes, or in the upper-right corner, above the axis.
Looks like right now it's always to the right? Should perhaps introduce new locations "right-upper" etc. so that "upper-right" means "UR corner + above"? (Perhaps would read better using compass locations (#12679), then one can have "eastnortheast"/"ENE" vs "northnortheast"/"NNE"...)
A quick look suggests that MATLAB only has "northeastoutside" which behaves like "upper-right" does right now.

@jklymak
Copy link
Member Author

jklymak commented Jan 2, 2019

Well since we now have the outside kwarg perhaps it could take a string value outside=“vertical”?

@anntzer
Copy link
Contributor

anntzer commented Jan 2, 2019

How would it combine with "east"(/"left")?
Would be a bit ugly to have "outside" be True/False for "90°" directions and "vertical"/"horizontal"/False for the 45° ones.
Sorry for the nitpicking...

@jklymak
Copy link
Member Author

jklymak commented Jan 2, 2019

It’d be True if any string specified (or True) and the string would be checked only for the ambiguous corners (default to the way I’m doing it now, which I think is 95% of what people want)

@jklymak jklymak force-pushed the enh-add-gridspec-legend branch from b419198 to 8e21c15 Compare January 2, 2019 18:20
be changed by specifying a string
``outside="vertical", loc="upper right"``.

axs : sequence of `~.axes.Axes`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be axs instead of axes?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

axs would be standard I think (axes is not great as it suggests a single Axes...)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess colorbar uses ax. Since this is supposed to be in parallel to that maybe I should change to that?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, sounds fair

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ya that sounds good.

@anntzer
Copy link
Contributor

anntzer commented Jan 3, 2019

Haven't read the other threads, but you may also want to check #3745 #3857 #6182.

@jklymak
Copy link
Member Author

jklymak commented Jan 3, 2019

@anntzer, thanks - those are largely for axes legends, whereas this is for figure legends. OTOH, it would be easy to add an "outside" kwarg for axes legends as well, in fact easier than for figures, I think so long as constrained_layout is being used.

Both of them can be done w/o constained_layout as well doing the same things that colorbar does, but that could be the next step of this.

@jklymak
Copy link
Member Author

jklymak commented Jan 4, 2019

This latest push lets axes.legend have an outside=True as well. It doesn't put the legend outside any axes decorations, so it can overlap tick labels, titles, etc. This is because constrained_layout doesn't do anything below the axes level, and this is an axes level-artist. But it is somewhat nicer than specifying the location by hand (?).

import matplotlib.pyplot as plt

fig, ax = plt.subplots(2, 1, constrained_layout=True)
ax[0].plot(range(10), label='boo')
ax[0].legend(loc=1, outside=True)
ax[1].plot(range(10), label='boo')
ax[1].legend(loc=3, outside=True)
plt.show()

axfigure_1

"""
legend for this gridspec, offset from all the subplots.

See `.Figure.legend_outside` for details on how to call.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"the outside argument to Figure.legend"
Dunno whether you want to rename that function now that Figure.legend_outside was renamed, but perhaps not.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooops, no this definitely still needs some documentation work. Also the above new stuff is a bit of a mess still, so let me mark WIP.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed....

@timhoffm
Copy link
Member

timhoffm commented Jan 6, 2019

Sorry for jumping in again at a late stage of the discussion, but I feel this needs thorough consideration to end up with a reasonable API.

Parameters

Actually looking at it again... "upper right" can mean one of two things: in the upper-right corner, to the right of the axes, or in the upper-right corner, above the axis.
Looks like right now it's always to the right? Should perhaps introduce new locations "right-upper" etc. so that "upper-right" means "UR corner + above"? (Perhaps would read better using compass locations (#12679), then one can have "eastnortheast"/"ENE" vs "northnortheast"/"NNE"...)
A quick look suggests that MATLAB only has "northeastoutside" which behaves like "upper-right" does right now.

Well since we now have the outside kwarg perhaps it could take a string value outside=“vertical”?

How would it combine with "east"(/"left")?
Would be a bit ugly to have "outside" be True/False for "90°" directions and "vertical"/"horizontal"/False for the 45° ones.

This all tells me that two arguments are arguments are maybe not a great idea. We just have one task: Defining the position of the legend. Splitting this in a direction (like "upper right" or "northeast") and in inside/outside is artificial and even not clear because there is no unambiguous "upper right" for "outside". Also the following combinations do not have a defined meaning:

loc='best', outside=True
loc='center', outside=True
loc=(0.1, 0.1), outside=True

Also, we already have the meaning of loc depend on bbox_to_anchor. It's not getting simpler to document and understand if we have multiple interdependent parameters.

I have to think about the desired API a bit more.

Alignment

Looking at the first figure in https://17015-1385122-gh.circle-artifacts.com/0/home/circleci/project/doc/build/html/gallery/text_labels_and_annotations/figlegendoutside_demo.html, I feel that we actually would need some sort of alignment with the axes but outside:

  • The left legend should be centered with the ylabel.
  • The top legend should be centered between the two axes patches.
  • The bottom of the right legend should be aligned with the bottom of the axes patch, not with the bottom of the ticks.

I currently don't have a clear strategy how to do this, but it is at least something to consider when defining the API.

@jklymak
Copy link
Member Author

jklymak commented Jan 6, 2019

Sorry for jumping in again at a late stage of the discussion, but I feel this needs thorough consideration to end up with a reasonable API.

@timhoffm Not late at all for discussing the API. As pointed out before I'd prefer this was its own function, but that wasn't popular. Given that, indeed some kwargs clash, and get dropped. I don't feel strongly about using the outside kwarg; I appreciate that its inelegant, but OTOH, I think its easiest to remember. But further thought is welcome.

Alignment

Looking at the first figure in https://17015-1385122-gh.circle-artifacts.com/0/home/circleci/project/doc/build/html/gallery/text_labels_and_annotations/figlegendoutside_demo.html, I feel that we actually would need some sort of alignment with the axes but outside:

  • The left legend should be centered with the ylabel.
  • The top legend should be centered between the two axes patches.
  • The bottom of the right legend should be aligned with the bottom of the axes patch, not with the bottom of the ticks.

If you want to align legends on the axes level rather than the figure/gridspec level, the way to do that is with an axes legend.

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)
axs[1].legend(outside=True, loc='lower right')
axs[0].legend(handles=[h2, h4], labels=['curve2', 'curve4'],
                   loc='center left', outside=True, borderaxespad=6)
plt.show()

dsafigure_1

I don't have a good way to centre the top legend on the pair of the axes, but thats a pretty hard problem in general as you'd need to make assumptions about how the child axes are set up in order to do it. Same with the vertical alignment issues at the figure level. Basically you are saying you'd like the legend centred on the bounding spines? Thats not impossible, but would require building a new bbox_to_anchor out of those spines etc, so would add substantial complexity. And things like colorbars etc would need to be dealt with. Overall, I'm not sure its worth the complexity given that most people will put the legend on the upper right corner.

@jklymak
Copy link
Member Author

jklymak commented Jan 30, 2019

Are there further comments on the API? Again, my tendency would be to have a different function figure.legend_outside and then we don't have kwarg clashes with figure.legend. There is no way I can or want to make this work with bbox_to_anchor, and obviously best, center etc won't have any meaning. Could have those cases error out.

@anntzer
Copy link
Contributor

anntzer commented Jan 31, 2019

I think the fact that the supported kwargs are effectively different in the inside and outside cases makes me change my vote to be in favor of legend_outside.

And then you can actually have a new list of locations that has ENE/NNE (upperupperleft/leftupperleft), and not NE (upperleft) to handle the corners properly without having to guess...

@jklymak
Copy link
Member Author

jklymak commented Jan 31, 2019

Ok ENE means upper right beside? NNE means upper right above? Did we want NE ( above and beside?).

@anntzer
Copy link
Contributor

anntzer commented Jan 31, 2019

Yes for the first question.

I don't know if anyone would want to use "NE" (if that means "both to the right and above the upper-right corner"), so I'd forget about it until someone complains (but certainly, if we have ENE and NNE, then let's not have NE as a shortcut for ENE, that's just silly).

Of course the discussion about NSEW vs upper/lower/left/right isn't over yet either :/

@jklymak
Copy link
Member Author

jklymak commented Jan 31, 2019

(@anntzer BTW this failed w the font bug: https://travis-ci.org/matplotlib/matplotlib/jobs/486672509)

@jklymak jklymak force-pushed the enh-add-gridspec-legend branch from 32a1a8a to 3dbd703 Compare July 28, 2019 21:57
@jklymak
Copy link
Member Author

jklymak commented Jul 28, 2019

Still not sure what to do about this one. Seemed that consensus was for a separate method, but @TimHoffman felt strongly that it should be a parsable part of the location kwarg of the main legend function. That’s somewhat held up by the fact that we aren’t entirely happy with our location kwarg strings.

I am genuinely curious how often fig.legend is ever called, and if anyone ever wants it to not be outside the axes. I imagine most real world examples are anchoring it outside at axes and then using bbox_inches=tight or adjusting the subplots so the legend fits.

I guess so far this is a decent proof of concept. But I’m not sure if I like the fragility if it all. If we were to add this functionality to non constrained layout should we just do the anchoring I suspect most people want? Getting this to work without constrained_layout is maybe a good way to start reinvigorating it.

@ImportanceOfBeingErnest
Copy link
Member

This is just to answer the question on frequency of usage (I haven't thought about how this relates to or impacts this PR)

I am genuinely curious how often fig.legend is ever called, and if anyone ever wants it to not be outside the axes.

A direct use case is adding a single legend for multiple axes. E.g. in Secondary axis with twinx(): how to add to legend? I proposed to use fig.legend() to get a single legend for twinned axes. Especially,

 fig.legend(loc="upper right", bbox_to_anchor=(1,1), bbox_transform=ax.transAxes)

to get that legend inside the axes. That answer has become quite popular by now.

Also, the most popular solution to How do I make a single legend for many subplots with matplotlib? mentions fig.legend() (though it wouldn't be clear from that answer if people put it in- or outside any of the axes).

@timhoffm
Copy link
Member

@jklymak wrong user mentioned (#13072 (comment) 😄). I could go with a separate method legend_outside for now. However, the separate method does still not help with the location string.

Do we have a way of introducing experimental/preliminary API? If so, I'd suggest to do so here since we're not quite clear how the API would be best designed.

@naintoo
Copy link

naintoo commented Aug 27, 2019

I suggest "NUM PAD" style.
7 8 9
4 5 6
1 2 3
0 - BEST!

This makes sense and you will not get confused

@jklymak
Copy link
Member Author

jklymak commented Aug 27, 2019

Well that would break back compatibility. It’s also not completely obvious for those of us without a number pad in front of us. Also most phones have the opposite positioning of the keys (with 1,2,3) on the top, so it’s not unique

@QuLogic
Copy link
Member

QuLogic commented Aug 27, 2019

I had some initial thoughts, but they don't exactly make sense. I left them here as idea, but see last paragraph.

So perhaps trying to overload the one argument loc in this way may be a bit too confusing. It seems like what you're trying to convey is both a location and an anchor, so why not use two arguments (both of which exist somewhere in mpl, if not as arguments, than as concepts at least) instead? I know we have bbox_to_anchor, but a legend location + legend anchor are sufficient to describe all of the options given above without using confusing upper-right/right-upper/etc. (except for the previously mentioned parameter, which could be de-emphasized a bit if necessary.)

If the anchor is on the same side as the location, then it'll be inside the figure, and if it's on the opposite, it'd be outside the figure. A shortcut like 'inside' could exist to mean "pick a reasonable anchor to work like it used to" and be the default. Maybe an 'outside' shortcut could be "pick the opposite anchor choice from location".

However, thinking about it some more, this is a Figure method and there isn't really a concept of 'outside' the figure. What this is really working on is a (sub)group of Axes. If the result of subplots was a sort of AxesGroup on which you could call legend, then the above location/anchor makes a bit more sense. Or if you could do (ax1 + ax2).legend()... Anyway, now I'm just throwing out random ideas and I'm not sure how to proceed.

@tacaswell tacaswell modified the milestones: v3.2.0, v3.3.0 Aug 27, 2019
@jklymak
Copy link
Member Author

jklymak commented Aug 28, 2019

@QuLogic, thanks for your thoughts. We already have a "AxesGroup"-like object, its a gridspec. As noted above, I'd be happy if this method became a gridspec method.

I admit to some ambivalence about the anchor paradigm. Its super flexible, but correspondingly hard to remember how to use.

@timhoffm
Copy link
Member

Semi-OT random thoughts:

  • What "outside" legends really need to do is steal space from one or more axes. We have a similar concept in colorbar where the parameter ax can take one or more axes to steal space from. Maybe the concepts there and here are similar enough to have a common approach.
  • Maybe we should have a non-Axes container that can be placed into a gridspec, which then holds the legend (or the legend could be placed inside its own axes - which OTOH sounds like a bad idea). Main point here would be that the legend gets a defined share of the layout.

@jjnurminen
Copy link

FWIW, I'm doing grids of subplots with duplicated curves (i.e. a curve may appear in one or more subplots). In this case, using a figure-level legend seems natural, as the legend is not associated with any particular plot. Also, constrained_layout works very well for me, except it doesn't support fig.legend. Thus, I've resorted to creating an extra 'legend axis' outside my subplots and manually constructing a legend there, but that's a bit clumsy. So I would be +1 on implementing this.

@QuLogic QuLogic modified the milestones: v3.3.0, v3.4.0 May 2, 2020
@jklymak jklymak marked this pull request as draft July 23, 2020 16:32
@QuLogic QuLogic modified the milestones: v3.4.0, v3.5.0 Jan 21, 2021
@jklymak jklymak closed this Mar 19, 2021
@jklymak
Copy link
Member Author

jklymak commented Mar 19, 2021

Closed for #19743

@jklymak jklymak deleted the enh-add-gridspec-legend branch March 19, 2021 05:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

constrained_layout support for figure.legend