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

Conversation

jklymak
Copy link
Member

@jklymak jklymak commented Mar 19, 2021

PR Summary

Much simpler redo of #13072. Closes #13023.

old

import numpy as np
import matplotlib.pyplot as plt

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

for i, ax in enumerate(axs):
    ax.plot(range(10), label=f'Boo{i}')
    if i == 0:
        ax.set_ylabel('Booo')
lg = fig.legend(loc='upper right') 
fig.suptitle('Top')
plt.show()

doesn't place the legend outside the axes...

Old

new

Added an outside kwarg to fig.legend:

...
fig.legend(loc='outside right upper') 
...

New

If we want it in the upper right, but on top, instead of beside, then

...
fig.legend(loc='outside upper right') 
...

New

Caveats

There is no magic here - multiple legends will over-run each other. The legends will also overlap with a suptitle/supxlabel/supylabel if they are present. They all get put in the same "margin", but basic functionality is there.

PR Checklist

  • Has pytest style unit tests (and pytest passes).
  • Is Flake 8 compliant (run flake8 on changed files to check).
  • New features are documented, with examples if plot related.
  • Documentation is sphinx and numpydoc compliant (the docs should build without error).
  • Conforms to Matplotlib style conventions (install flake8-docstrings and run flake8 --docstring-convention=all).
  • New features have an entry in doc/users/next_whats_new/ (follow instructions in README.rst there).
  • API changes documented in doc/api/next_api_changes/ (follow instructions in README.rst there).

@jklymak jklymak added topic: geometry manager LayoutEngine, Constrained layout, Tight layout topic: legend labels Mar 19, 2021
@jklymak jklymak added this to the v3.5.0 milestone Mar 19, 2021
@jklymak
Copy link
Member Author

jklymak commented Mar 19, 2021

@timhoffm and @anntzer before I go nuts with the docs for this, early feedback on the API would be welcome...

@jklymak jklymak changed the title ENH: allow fig.legend in layout ENH: allow fig.legend outside axes... Mar 19, 2021
@anntzer
Copy link
Contributor

anntzer commented Mar 20, 2021

We should perhaps do a call to hash out the API decisions both for this and for #12679 once and for all...

@jklymak
Copy link
Member Author

jklymak commented Mar 20, 2021

Ok but this pr is completely agnostic of how the legend position is specified. If we later decide to do something with compass notation that doesn't change this PR at all.

@jklymak jklymak force-pushed the enh-layout-fig-legend branch 2 times, most recently from c6a9869 to 9c577ae Compare March 21, 2021 17:18
@jklymak jklymak marked this pull request as ready for review March 21, 2021 17:44
@timhoffm
Copy link
Member

timhoffm commented Mar 24, 2021

I did not have the time to read through this completely and make my own thoughts.

Some quick observations though:

  • fig.legend(loc='upper right', outside='upper') does not speak to me. This seems like a too clever way to put in additional degrees of freedom.
  • Legend positioning is already a bit of a mess. I always struggle with the bbox_to_anchor option modifying the meaning of loc. I feel it's getting worse with an addtional parameter.
  • Which positions do we want to address. Are these essentially the A-L ones from the cheatsheet?
    grafik
    Or even more, including the outer edges? The image makes clear that there are more outer positions than inner positions so that a simple additional outside bool cannot be enough. MATLAB folds this into the string. e.g. 'westoutside'. I'm wondering if that's a better choice.
  • Semi-OT: Could Axes legend also be made to work "outside" with constrained_layout?

@jklymak
Copy link
Member Author

jklymak commented Mar 25, 2021

The cheatsheet way of doing things with bbox_to_anchor is basically what this seeks to avoid. Hopefully we can all agree that getting A, on the left side, by saying loc="upper right", bbox_to_anchor=(-.1, .9) is not an acceptable API. Of course this won't break that, but I don't think we should be emulating it.

Here the proposal is:

A: loc="left", outside='upper'
B: loc="left", outside=True
C: loc="lower left", outside=True
D: loc="lower left", outside='lower'
E: loc="lower center", outside=True
F: loc="lower right", outside='lower'
G: loc="lower right", outside=True
H: loc="right", outside=True
I: loc="upper right", outside=True
J: loc="upper right", outside='upper'
K: loc="upper center", outside=True
L: loc="upper left", outside='upper'

Note that if the user specifies bbox_to_anchor then the outside kwarg is ignored.

I'm not wedded to this API, but actually feel (pretty strongly) that it is the simplest way forward, and doesn't lock us out of a different string-based API...

Yes, axes.legend can get outside placement, but lets save that for a separate PR.

@jklymak jklymak force-pushed the enh-layout-fig-legend branch from 9c577ae to ffb8578 Compare May 11, 2021 16:43
@jklymak
Copy link
Member Author

jklymak commented May 11, 2021

... rebased

@jklymak
Copy link
Member Author

jklymak commented May 11, 2021

I'll advocate for this one as often-desired, and I'll also advocate for "lets keep it simple"

@Tobychev
Copy link

Tobychev commented Jun 13, 2022

@jklymak Could the API be simplified by just having separate location specification for outside placement? so you either specify loc, or outside, but not both?

Also adding the two specifiers "top" and "bottom" then results in the following API:
A: outside='upper left'
B: outside="left"
C: outside="lower left"
D: outside="bottom left"
E: outside="bottom"
F: outside="bottom right"
G: outside="lower right"
H: outside="right"
I: outside="upper right"
J: outside="top right"
K: outside="top"
L: outside="top left"

As the illustration makes clear, in the outside case there is a degeneracy/ambiguity between the two sides of the corners that the current loc position names doesn't have to deal with. To me it seems reasonable that a clear resolution of this ambiguity is solved at the cost of introducing two new (relative) position specifiers.

@QuLogic QuLogic modified the milestones: v3.6.0, v3.7.0 Jul 5, 2022
@efiring
Copy link
Member

efiring commented Dec 1, 2022

I like the idea of separate kwargs for inside versus outside. For the outside naming, maybe specify row or column first, position within row or column second:
A: outside='left top'
B: outside="left middle"
C: outside="left bottom"
D: outside="bottom left"
E: outside="bottom middle"
F: outside="bottom right"
G: outside="right bottom"
H: outside="right middle"
I: outside="right top"
J: outside="top right"
K: outside="top middle"
L: outside="top left"

It's far from perfect, but I think this is a better match to the way I might describe a location around the periphery of the box, without consulting a diagram every time.

Another option would be to ditch the separate "left top" and "top left" and just use compass locations and names for everything. Inside or outside, there are just 4 corner locations, "NE", "SE", etc. Simple, concise, and unambiguous.
On second thought, I guess those diagonal locations are poor for legends, so the modified compass forms would be "NE top", "NE right", etc.

@jklymak jklymak force-pushed the enh-layout-fig-legend branch from 1ff75d5 to f5e39a1 Compare December 9, 2022 21:09
# explicitly set the bbox transform if the user hasn't.
l = mlegend.Legend(self, handles, labels, *extra_args,
bbox_transform=transform, **kwargs)
l._outside = outside
Copy link
Member

Choose a reason for hiding this comment

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

I'm not following the logic here. l._outside seems to dynamically add an attribute to the Legend. It's better to to pass this via __init__ and document there what the type and expected values are.

I'm wondering whether the whole logic would be better moved in to Legend.__init__. On the downside, not every legend can cope with "outside", OTOH, if you don't hack this in via a dynamic attribute, you'd have a formal outside parameter in Legend.__init__ and you'd have to cope with the outside case anyway. In that situation it seems simpler to make Legend.__init__(... loc=...) accept whatever is needed (i.e. also 'ouside upper left') and do all the logic in there.

Copy link
Member Author

Choose a reason for hiding this comment

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

Except the loc keyword arg does error checking at the legend level, and legend doesn't know if it is being called as a figure legend or an axes legend. I think it's better to keep figure.legend only code in the figure.legend method

Copy link
Member

Choose a reason for hiding this comment

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

But the legend carries the information where it wants to be rendered. That's loc and now ammended with _outside.
By introducing "outside", positioning has become more complex. While we already have the case that the meaning of loc changes depending on bbox_to_anchor, we still got away with a cheap validation because the supported values are the same.

That's now getting a bit more complicated. I still think it's valuable to have all the validation logic in one place. We may need to consider making legend aware on whether it's positioning in a figure or Axes.

Anyway, the outside state should flow through a public API into Legend, otherwise downstream users/libraries will not be able to create their own outside Legends.

Copy link
Member Author

Choose a reason for hiding this comment

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

Anyway, the outside state should flow through a public API into Legend, otherwise downstream users/libraries will not be able to create their own outside Legends.

Can you clarify what you have in mind here?

This is a pretty over specified problem, where at your and @efiring request we are offering a different idiom for axes than figures for the loc keyword argument. Now you are suggesting that somehow legend itself must parse that, and parse it differently whether it is a figure legend or an axes legend, information the legend does not currently have.

That all seems very confusing to me, and very hard to document. Either we have the grammar for loc proposed here and parse it in the figure method and leave the general object mostly alone. Or the original proposal was to have an optional kwarg "outside" that was not made available to the axes method. That seems cleaner to me, and less likely to lead to confusion, but...

This PR does the mechanical part of adjusting the constrained layout to accept the legend. This is an oft requested feature, but it's not one I particularly plan to use. So if there is going to be a bunch more API discussion I'd like to suggest that one of the two forms above be merged so the functionality is present and then a follow up PR can work out the API and how to document it. Conversely happy to just close the PR and someone else can use it to base their own PR on.

Copy link
Member

Choose a reason for hiding this comment

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

I looked at the code and the legend indeed knows whether it's an Axes or a Figure legend.

if isinstance(parent, Axes):
self.isaxes = True
self.axes = parent
self.set_figure(parent.figure)
elif isinstance(parent, FigureBase):
self.isaxes = False
self.set_figure(parent)
else:
raise TypeError(
"Legend needs either Axes or FigureBase as parent"
)

and the inaxes parameter is already used for conditional loc validation:

if not self.isaxes and loc == 0:
raise ValueError(
"Automatic legend placement (loc='best') not implemented for "
"figure legend")

I propose it's better to pass loc to Legend unmodified and have all the validation (and maybe transformation) logic inside.

I'm sorry I don't have the time to write a PR for that right now. Overall, your PR solves the problem and does not introduce any undesired public API or side-effects. So we may take it as is for now and cleanup the internal logic later. I believe this is needed for better long-term maintainability.

Copy link
Member Author

Choose a reason for hiding this comment

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

OK, I moved it in the newest version.

@jklymak jklymak force-pushed the enh-layout-fig-legend branch from 1a80d9e to c1fe98d Compare December 12, 2022 23:10
@jklymak jklymak force-pushed the enh-layout-fig-legend branch from 2913d0b to c5926ad Compare December 13, 2022 20:16
@jklymak jklymak marked this pull request as ready for review December 13, 2022 21:54
@jklymak jklymak removed the status: needs comment/discussion needs consensus on next step label Dec 13, 2022
@rcomer rcomer mentioned this pull request Dec 22, 2022
4 tasks
@tacaswell tacaswell added the Release critical For bugs that make the library unusable (segfaults, incorrect plots, etc) and major regressions. label Dec 22, 2022
@@ -0,0 +1,10 @@
:orphan:
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
:orphan:

if self.isaxes and self._outside:
# warn if user has done "outside upper right", but don't
# error because at this point we just drop the "outside".
raise UserWarning(
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
raise UserWarning(
raise ValueError(

@jklymak jklymak force-pushed the enh-layout-fig-legend branch 2 times, most recently from 65f061c to 3d369d4 Compare January 4, 2023 23:40
@jklymak
Copy link
Member Author

jklymak commented Jan 5, 2023

BTW, this is not particularly hard to re-milestone if it's slowing down the release. OTOH, I think it's substantively done, and oft-requested.

Copy link
Member

@tacaswell tacaswell left a comment

Choose a reason for hiding this comment

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

The name self._outside threw me for a bit (I thought it was a bool and I was deeply confused how this worked). Given that this is a private attribute not a huge deal (and likely a me-problem).

@jklymak
Copy link
Member Author

jklymak commented Jan 5, 2023

Easy enough to change to _outside_loc or something like that. For the main code it is basically a boolean. It only gets used as a string inside _constrained_layout.py.

@jklymak jklymak force-pushed the enh-layout-fig-legend branch from 3d369d4 to e722171 Compare January 5, 2023 01:29
@jklymak
Copy link
Member Author

jklymak commented Jan 5, 2023

Changed self._outside to self._outside_loc

Copy link
Member

@timhoffm timhoffm left a comment

Choose a reason for hiding this comment

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

Almost good to go: Please remove the obsolete comment.

Anybody can merge after that.


if self.isaxes and self._outside_loc:
# warn if user has done "outside upper right", but don't
# error because at this point we just drop the "outside".
Copy link
Member

Choose a reason for hiding this comment

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

The comment is not valid/needed anymore.

@jklymak jklymak force-pushed the enh-layout-fig-legend branch from e722171 to 54f7236 Compare January 6, 2023 21:11
@ksunden ksunden merged commit e4c1d70 into matplotlib:main Jan 6, 2023
@jklymak jklymak deleted the enh-layout-fig-legend branch January 6, 2023 23:50
@jklymak
Copy link
Member Author

jklymak commented Jan 6, 2023

Thanks for the reviews!

@dstansby
Copy link
Member

Just dropping in to share some ❤️ for this feature, just used it for the first time and it's so helpful!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
New feature Release critical For bugs that make the library unusable (segfaults, incorrect plots, etc) and major regressions. topic: geometry manager LayoutEngine, Constrained layout, Tight layout topic: legend
Projects
None yet
Development

Successfully merging this pull request may close these issues.

constrained_layout support for figure.legend
9 participants