Skip to content

FIX/ENH: Introduce a monolithic legend handler for Line2D #11358

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 6 commits into from

Conversation

afvincent
Copy link
Contributor

@afvincent afvincent commented Jun 1, 2018

PR Summary

Fixes #11357. Fixes #2035

Introduces a new default legend handler for Line2D instances (which uses the same name as the former default one, i.e. HandlerLine2D). It uses a “monolithic” approach to build the legend handle, instead of using a combination of a “markerless” line and a “lineless“ marker. This allows to interrogate the handle in a consistent manner (see the issue #11357).

As it seems like the new behavior would be the one actually expected by most people (based on a very large sample of at least 3 people ^^), it is made the default handler for Line2D instances, while the former handler is kept as HandlerLine2DCompound.

To revert to the former behavior, one can use

import matplotlib.legend as mlegend
from matplotlib.legend_handler import HandlerLine2DCompound
from matplotlib.lines import Line2D

mlegend.Legend.update_default_handler_map({Line2D: HandlerLine2DCompound()})

Before/after example

Copy-pasting a snippet originally posted on gitter

import matplotlib.pyplot as plt

fig, ax = plt.subplots()
marker_opts = {'marker': 'd', 'ms': 10, 'mfc': "tab:red", 'mec': "tab:gray", 'mew': 3} ax.plot([0, 1], ls='--', label="A line", **marker_opts)
leg = ax.legend()

handle = leg.legendHandles[0] # convenience variable
for key in marker_opts:
    expected = marker_opts[key]
    value = getattr(handle, f"get_{key}")()
    print(f"{key}:\t{value} == {expected} is {value == expected}")

## Currently
#marker:     == d is False  # <- because the marker of the registered artist is set to '' under the hood
#ms:    10.0 == 10 is True
#mfc:    tab:red == tab:red is True
#mec:    tab:gray == tab:gray is True
#mew:    3 == 3 is True

## With #11358:
#marker:    d == d is True
#ms:    10.0 == 10 is True
#mfc:    tab:red == tab:red is True
#mec:    tab:gray == tab:gray is True
#mew:    3 == 3 is True

PR Checklist

  • Has Pytest style unit tests
  • Code is PEP 8 compliant
  • New features are documented, with examples if plot related
  • Documentation is sphinx and numpydoc compliant
  • Documented in doc/api/api_changes.rst if API changed in a backward-incompatible way: normally, this does not break the API

Edit 2018-06-01: (first?) overhaul of the PR
Edit 2018-06-04: looks ready for review

@afvincent
Copy link
Contributor Author

Looks like (at least) figlegend is going to cause issues :/.

@afvincent
Copy link
Contributor Author

Apparently, using a markevery value different from None value triggered an new code path (for that situation) where an axes transform (ax.transAxes) is assumed to be available.

23503e5 is a crude fix that simply uses self.get_transform() if the aforementioned hypothesis breaks. I do not totally understand (not to say at all) why it seems to work, but at least locally it fixes the error that was occurring with the figlegend demo...

@afvincent
Copy link
Contributor Author

Well, I cannot reproduce the Travis failures (of test_markevery_line and test_legend_auto3) on my local computer :/.

@ImportanceOfBeingErnest
Copy link
Member

Any reason to make this new handler private? All other handlers are public such that they can be directly used by the user or subclassed, if needed.

@afvincent
Copy link
Contributor Author

I wanted to avoid having two public handlers for Line2D objects (“There should be one-- and preferably only one --obvious way to do it.”) as this tends to end up in fuzzy situations for end-users, like in the case for arrows. But I was also unsure about how heavily used is the current HandlerLine2D outside of our codebase, which would give clues about the relevance of deprecating it in favor of a more monolithic approach (like the one in _HandlerMonolithicLine2D).

AFAICT, a private new handler is a cheap way to avoid those issues while still fixing #11357 . We could always decide later to make the new handler public and/or deprecate the previous one, could we not? (But maybe I am wrong; API decisions are not my field of expertise ^^)

@ImportanceOfBeingErnest
Copy link
Member

I would rather see this similar to e.g. all the Locators and Formatters. They are all public and some of their functionality overlaps. The user just chooses the one they like and matplotlib decides for a default one.

@ImportanceOfBeingErnest
Copy link
Member

ImportanceOfBeingErnest commented Jun 1, 2018

I have to say I am also frequently surprised about what kind of API changes that truely break things are simply put in without notice while other seemingly straight forward improvements are heavily disputed.

So the following is just my personal view of things:

  • You are changing the API with this proposed PR. So you need to make sure it produces the same results as before (modulo some half pixel offset one wouldn't care about).
  • The new handler should therefore be public and usable by people.
  • The old handler should stay for people who need it. (Possibly rename it to something else.)
  • I supect that this change will break some existing code, namely those where people worked around the fact that there is only one of the two created artists returned. Since this code change is for the better, people are hopefully happy to adjust their code to the new handler.
  • This change needs to be documented in the API changes.

@anntzer
Copy link
Contributor

anntzer commented Jun 1, 2018

If the goal is ultimately to kill the old handler in favor of the new one, I would suggest implementing this as a (new) rcparam switch in create_artists. This would make the ultimate API switch much easier to manage, and (I think) in the meantime people would be able to use the new API by globally setting the rc, or even locally with

with rc_context({"legend.linehandler": "monolithic"/"nonmonolithic"}): ...

(names up to bikeshedding) just around the call to legend().

(-1 on "renaming to something else", that's how we end up with OldAutoLocator which been around for more than 10 years)

@ImportanceOfBeingErnest
Copy link
Member

Oula that would grow big now... if you introduce a rcParam for legend handlers the ultimate expectation would be to define any legend handler through the rcParams. I think this would be useful, but might exceed what is attempted here.

My argument from above was more to do this API change right now, because as I see it, the only thing this would break is code written to overcome exactly the limitation that the new handler fixes.

@afvincent
Copy link
Contributor Author

(About name bikeshedding, before I forget: "nonmonolithic" <- "composite" or "compound")

@anntzer
Copy link
Contributor

anntzer commented Jun 1, 2018

Actually the point was not about making handlers rc-customizable, but to make API changes more controllable (perhaps instead the key can be something like api.legend_line_handler, and likewise for any API change which is accompanied by a change of default behavior).

@jklymak
Copy link
Member

jklymak commented Jun 1, 2018

  1. I think the original API didn't make much sense and that this should be the HandlerLine2D. Yeah, it'll break some code, but I can't see the situation where someone was depending on the old behaviour who can't achieve what they want more easily w/ the new behaviour. I don't think the handlers have such a huge user cross section that we'd get a lot of blowback from this API change; but I'm not as conserative as I probably should be on such things.
  2. If we do decide on a new name rather than just using the old, "monolithic" is a bit overbearing for something that just combines two handles into one, and will appear quite mysterious to new users. HandlerSingleLine2D? HandlerCombinedLine2D?

@ImportanceOfBeingErnest
Copy link
Member

ImportanceOfBeingErnest commented Jun 1, 2018

If you want to revert to the old behaviour you can easily add

from matplotlib import legend, legend_handler, pyplot
legend.Legend.update_default_handler_map({pyplot.Line2D : legend_handler.HandlerLine2DComposite})

(Edited.)

at the beginning of your scipt. I don't see any necessity to add a single rcParameter which can only be used to change the legend handle for lines but not for other artists.

Just to make sure we're all aware of the implications here. Previously if you wanted to e.g. change the marker color of a line in a legend you'd need to do

leg = ax.legend()
leg._legend_handle_box.get_children()[0].get_children()[0].get_children()[0].get_children()[1].set_markerfacecolor("red")

with this PR you can just do

leg.legendHandles[0].set_markerfacecolor("red")

Everyone affected by this will love the new possibility and all I'm asking for here is to make the new handler public, just as all other handlers, and to add an API change note. I would also find it usefull not to remove the old handler from the code base, simply because we cannot know for sure if some people would not like it the old way still, maybe because they have subclassed the handler for some other custom thing.

@anntzer
Copy link
Contributor

anntzer commented Jun 1, 2018

Well, manipulating private attributes is not great.
But perhaps we can indeed thing about publically exposing _default_handler_map.

@afvincent
Copy link
Contributor Author

afvincent commented Jun 1, 2018

@anntzer I think I have something all-public (mlegend.Legend.update_default_handler_map). (rebase incoming)

@afvincent afvincent force-pushed the leg_line2d_handler branch from 23503e5 to 836cb5f Compare June 1, 2018 22:10
@afvincent
Copy link
Contributor Author

Overhaul based on the comments above (thanks all). I'll update the initial post.

@ImportanceOfBeingErnest
Copy link
Member

ImportanceOfBeingErnest commented Jun 1, 2018

I would totally favour making _default_handler_map public, but for completely different reasons: Namely to easily "register" a custom legend handler globally, instead of passing a dictionary to each legend individually.
(Edited: This does not apply. You can use update_default_handler_map publically.)

@afvincent
Copy link
Contributor Author

I may be a bit out of my depth here, but mlegend.Legend.update_default_handler_map is a class method, so does it not already do that globally?

@afvincent afvincent force-pushed the leg_line2d_handler branch from 836cb5f to b1c389b Compare June 1, 2018 22:30
@afvincent
Copy link
Contributor Author

(Rebase because I think I forgot a line break in the API note and Sphinx was complaining.)

@afvincent
Copy link
Contributor Author

afvincent commented Jun 1, 2018

The recurrent failures of test_markevery_line and test_legend_auto3 on Travis are still around, but I am not able to reproduce them locally :/.

Edit: Moreover I still have a couple things to figure out about Sphinx subtleties in the API change note (like how to show the example plot & its source code, and manage to get :ghissue:11358 working...)

@afvincent afvincent added this to the v3.0 milestone Jun 1, 2018
@jklymak
Copy link
Member

jklymak commented Jun 2, 2018

Huh, they are just svg tests that are off by 0.001 and 0.004. My guess is the lines or markers moved a tiny bit in svg land because you are drawing them a bit differently. I'd be inclined to remove the svg tests for those, or increase the tolerance. You may not do the svg tests locally?

@afvincent
Copy link
Contributor Author

afvincent commented Jun 5, 2018

Looking at the SVG files that are locally generated (but do not trigger test failures here :/), the only differences (apart from IDs all along the files) are the additions of z instructions in a few places (in comparison to the expected SVG file). This SVG instruction apparently stands for “closepath”, but I do not know how it modifies the actual final rendering in this case.

In order not to entirely drop the SVG tests for the two aforementioned “failing” tests, I increased the tolerance following the scheme current RMS error + 0.001: let's now see if Travis is happy.

Edit: Before I forget (as this does not seem to trigger failure), a couple Sphinx subtleties still have to be fixed in the API change note (like how to show the example plot & its source code, and manage to get :ghissue:11358 working...) I found the issue with :ghissue and I gave up on the idea of displaying both an example plot and its source code in the API note (in favor of simply showing the self-sufficient example snippet). Rebased to squash the commits that were related to the API note.

@afvincent afvincent force-pushed the leg_line2d_handler branch from 70c9878 to e35e428 Compare June 5, 2018 02:32
@afvincent afvincent force-pushed the leg_line2d_handler branch from e35e428 to 5fa4ad9 Compare June 6, 2018 17:18
@afvincent
Copy link
Contributor Author

@anntzer Done :) (although the message(s) may be a bit verbose). Being there, rebased too.

xdata = np.linspace(xdata[0], xdata[-1], 3)
markevery = [1]

ydata = ((height - ydescent) / 2.) * np.ones(xdata.shape, float)
Copy link
Contributor

@anntzer anntzer Jun 7, 2018

Choose a reason for hiding this comment

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

np.full_like(xdata, (height - ydescent) / 2) (optionally)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll do that tomorrow ;). (TBH, this was just copy-pasted from the original handler and I did not really look about how to optimize the existing code.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@anntzer Well actually this will likely have to go in a future PR ^^ : np.full_like seems to have been introduced with Numpy 1.8 (release notes) and we still support Numpy 1.7.1, do we not?

Copy link
Contributor

Choose a reason for hiding this comment

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

Master requires 1.10 (see __version__numpy__ in __init__.py).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My bad, I looked at the official current doc 🐑... Should I update HandlerLineCollection, HandlerLine2DCompound, and HandlerErrorBar as well, or would it be better to do so in a separate PR?

Copy link
Contributor

Choose a reason for hiding this comment

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

A separate PR seems fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll open a separate PR to use np.full_like in the other relevant handler classes if this PR gets merged. (I am a bit afraid of the conflicts with the original LineHandler2D if I do it right now.)

@afvincent
Copy link
Contributor Author

@anntzer Is the current version fine for you now? (shhh, this is an undercover ping ;))


A class similar to the original handler for `.Line2D` instances but
that uses a monolithic artist rather than using one artist for the
line and another one for the marker(s). NB: the former handler, in
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps use one of .. note:: or .. versionadded:: or .. seealso:: (http://www.sphinx-doc.org/en/stable/markup/para.html#paragraph-level-markup). The latter could also be a See also numpydoc section.

transform = self.axes.transAxes
except AttributeError:
# Typically in the case of a **figure** legend.
transform = self.get_transform()
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this should just be set to None (or IdentityTransform)? I don't know if we really support float markeverys for figure-level artists (this PR only requires support for list-of-indexes markeverys), so setting it to None to throw something (in the isinstance(step, float) case of _mark_every_path) may be better (well, it probably used to throw here, so it's not worse), or if we want to support that case as well (which may be worth noting as a new, if obscure, feature), I think the figure-level artist's get_transform() already includes everything that's needed so we may not need to redo the transform another time? (in which case IdentityTransform may be correct?)

@jklymak
Copy link
Member

jklymak commented Jun 21, 2018

ping @afvincent (or we should punt to 3.1)

@tacaswell
Copy link
Member

Rebased and merged in #20699. Thanks @afvincent and @anntzer !

@tacaswell tacaswell closed this Aug 14, 2021
@QuLogic QuLogic modified the milestones: unassigned, v3.5.0 Aug 17, 2021
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.

Unable to retrieve marker from legend handle legend marker update bug
6 participants