Skip to content

Add legend handler and artist for FancyArrow #10688

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

Conversation

magedhennawy
Copy link

@magedhennawy magedhennawy commented Mar 5, 2018

closes #8236

I added new legend handlers for FancyArrowPatch, Annotation artists and HandlerAnnotation.

HandlerAnnotation can draw FancyArrowPatch artist in the legend:

if the annotation has an arrow, it uses HandlerFancyArrowPatch to draw an arrow

Example code:

import matplotlib.pylab as plt
from matplotlib.patches import FancyArrowPatch
from matplotlib.legend_handler import HandlerAnnotation

fig, ax = plt.subplots(1)
ax.plot([0, 1], [0, 0], label='line1')
ax.plot([0, 1], [1, 1], label='line2')
ax.annotate("",
            xy=(0.3,1.0),
            xytext=(0.3,0.0),
            arrowprops={'arrowstyle':'<->', 'color':'C7' },
            label='distance')
ax.legend()

plt.show()

test

**************** Added extra features. ******************

after merge with #8292

I added new legend handlers for Text, FancyArrowPatch and Annotation artists (HandlerText, HandlerFancyArrowPatch and HandlerAnnotation).

HandlerAnnotation can draw different artists in the legend:

if the annotation has no text and no arrow, it draws a white Rectangle
if the annotation has only text, it uses HandlerText to draw a text. The text is replaced by 'Aa' if it is longer than one character. (This is configurable by setting rep_str and rep_maxlen.)
if the annotation has only an arrow, it uses HandlerFancyArrowPatch to draw an arrow
If the annotation has both text and arrow, it uses HandlerTuple to draw both.
In HandlerTuple, I added the possibility of different width ratios for the artists. HandlerAnnotation uses a width_ratios=[1,4] (ie. the arrow is 4 times wider than the text).

Example code:

import matplotlib.pylab as plt
from matplotlib.patches import FancyArrowPatch
from matplotlib.legend_handler import HandlerAnnotation

fig, ax = plt.subplots(1)
ax.plot([0, 1], [0, 0], label='line1')
ax.plot([0, 1], [1, 1], label='line2')

# no text, no arrow
ax.annotate("",
            xy=(0.1,0.5),
            xytext=(0.1,0.5),
            label='annotation (empty)')

# text, no arrow
my_annotation = ax.annotate("X",
            xy=(0.1,0.5),
            xytext=(0.1,0.5),
            color='C2',
            label='annotation (text, no arrow)')

# no text, arrow
ax.annotate("",
            xy=(0.3,1.0),
            xytext=(0.3,0.0),
            arrowprops={'arrowstyle':'<->', 'color':'C7' },
            label='annotation (no text, arrow)')

# text, arrow
ax.annotate("Some long string",
            xy=(0.4,1.0),
            xytext=(0.35,0.1),
            arrowprops={'arrowstyle':'->', 'color':'C2' },
            color='C1',
            label='annotation (text + arrow)')

# Fancy arrow patch
arrpatch = FancyArrowPatch([0.5, 0.8], [0.9, 0.9],
                           arrowstyle='<|-',
                           mutation_scale=20,
                           color='C3',
                           label='arrowpatch')
ax.add_patch(arrpatch)

# Long text, will not be used in legend
ax.text(x=0.1, y=0.1,
        s='Hello',
        color='C5',
        label='text')

# Short text, copied in legend
ax.text(x=0.1, y=0.2,
        s='Z',
        color='C0',
        label='short text')

ax.legend(handler_map={my_annotation: HandlerAnnotation(rep_str='Abcde', rep_maxlen=0)})
fig.show() 

test

@jklymak
Copy link
Member

jklymak commented Mar 5, 2018

@magedhennawy you'll need to rebase onto master to get the correct tests etc to run. Sorry, a lot has chnaged recently with the move to MPL 3.0 (python 3 only).

@jklymak jklymak added this to the v3.0 milestone Mar 5, 2018
@magedhennawy
Copy link
Author

@jklymak rebased :)

@jlecoeur
Copy link

jlecoeur commented Mar 5, 2018

@magedhennawy Thanks for taking #8292 over! I was not realistically going to complete it anytime soon.

@magedhennawy
Copy link
Author

@jlecoeur no problem, thank YOU for the extra features, i had only fixed the issue, you sir went above and beyond. and made my life easy <3

jklymak
jklymak previously requested changes Mar 5, 2018
Copy link
Member

@jklymak jklymak left a comment

Choose a reason for hiding this comment

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

This looks really cool. Thanks so much for everyone's work on it. Some gotchyas:

Please revert all the legend doc style changes (or some folks who just made all those changes are going to be pretty sore at you).

Please provide a better docstring for width_ratios. I didn't really dig into what you are talking about, but I shouldn't have to to understand a doc string.

A more comprehensive test in the modern style would be appreciated. (also why is the arrow off the page?)

I think this should go in. It'll need a whats new entry, and ideally an example.

Default is ``None``, which will take the value from
:rc:`legend.markerscale`.
drawn ones. Default is ``None`` which will take the value from
the ``legend.markerscale`` :data:`rcParam <matplotlib.rcParams>`.
Copy link
Member

Choose a reason for hiding this comment

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

:rc: is the proper markup for rc params. Please undo this.

:rc:`legend.numpoints`.
entry for a line/:class:`matplotlib.lines.Line2D`.
Default is ``None`` which will take the value from the
``legend.numpoints`` :data:`rcParam<matplotlib.rcParams>`.
Copy link
Member

Choose a reason for hiding this comment

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

please leave the :rc: markup as it was.

@@ -134,14 +136,15 @@ def _update_bbox_to_anchor(self, loc_in_canvas):
corner of the legend in axes coordinates (in which case
``bbox_to_anchor`` will be ignored).

bbox_to_anchor : `.BboxBase` or pair of floats
bbox_to_anchor : :class:`matplotlib.transforms.BboxBase` instance \
Copy link
Member

Choose a reason for hiding this comment

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

The original link to .BboxBase should have been fine as it was. Saves you having to continue the line.

:rc:`legend.frameon`.
Control whether the legend should be drawn on a patch (frame).
Default is ``None`` which will take the value from the
``legend.frameon`` :data:`rcParam<matplotlib.rcParams>`.
Copy link
Member

Choose a reason for hiding this comment

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

as above

the :class:`~matplotlib.patches.FancyBboxPatch` which
makes up the legend's background.
Default is ``None`` which will take the value from the
``legend.fancybox`` :data:`rcParam<matplotlib.rcParams>`.
Copy link
Member

Choose a reason for hiding this comment

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

as above

Default is ``None`` which will take the value from the
``legend.facecolor`` :data:`rcParam<matplotlib.rcParams>`.
If ``"inherit"``, it will take the ``axes.facecolor``
:data:`rcParam<matplotlib.rcParams>`.
Copy link
Member

Choose a reason for hiding this comment

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

etc

Copy link
Member

Choose a reason for hiding this comment

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

I'll not note the rest of these. These should all be reverted.

handler = legend.get_legend_handler(handler_map, handle1)

_a_list = handler.create_artists(legend, handle1,
six.next(xds_cycle),
Copy link
Member

Choose a reason for hiding this comment

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

We don't need six anymore. This will be py3 only....

Copy link
Member

Choose a reason for hiding this comment

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

Can you explain (like I'm dumb) why we need widths to cycle here? I'm not grokking the width_ratios and why they are needed.

Copy link
Author

@magedhennawy magedhennawy Mar 6, 2018

Choose a reason for hiding this comment

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

my fault for the six, i merged with the other pr without seeking the py2->py3 changes

Copy link
Author

Choose a reason for hiding this comment

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

this was pulled from #8292 and i played with it in order to find a reason to keep it.
basically, in the use case were a user would like both an arrow AND text in the legend, it would show up as
cycled_widths

and not as this:
no_cycle_width

and the "sections" that you were asking for further explanation for, are what overlap in the latter picture.
When the widths aren't cycled, the part of the legend where a text or an arrow would show up for the annotation would be drawn on top of one another.
So width_ratios was defined so that each arrow "section" and text "section" would be given their respective space in the legend box in the appropriate location, where their respective widths would be cycled in order to place them both together.

Copy link
Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

Ok this seems great. but some of this should be in the doc string so it’s clear. Sections is a bit vague.

Copy link
Author

Choose a reason for hiding this comment

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

Cool, if my explanation to you was good enough, ill write it in there :)

Copy link
Member

Choose a reason for hiding this comment

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

Could be shorter. Ie width_ratios specifies the restive widths of a text/arrow legend annotation pair

Copy link
Author

Choose a reason for hiding this comment

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

Gotcha 👍
thanks again!
last thing left on my list is writing better tests :)

@@ -649,11 +650,26 @@ class HandlerTuple(HandlerBase):
pad : float, optional
If None, fall back to ``legend.borderpad`` as the default.
In units of fraction of font size. Default is None.


width_ratios : tuple, optional
Copy link
Member

Choose a reason for hiding this comment

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

The term "section" here is opaque to me. What is a "section" and why does it need different widths?

Copy link
Author

Choose a reason for hiding this comment

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

done

@@ -250,6 +250,15 @@ def test_hatching():
ax.legend(handlelength=4, handleheight=4)


@image_comparison(baseline_images=['legend_arrow_annotation'])
Copy link
Member

Choose a reason for hiding this comment

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

add extensions=['png'], style='mpl20'

# Related to issue 8236
# Tests arrow annotations showing up in legend
fig, ax = plt.subplots()
ax.annotate("foo", (0, 1), xytext=(2, 3), arrowprops={}, label='bar')
Copy link
Member

Choose a reason for hiding this comment

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

please add:

ax.set_xtick_labels('')
ax.set_ytick_labels('')

This helps us cut down on extra text comparison failures.

Copy link
Member

Choose a reason for hiding this comment

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

This test seems a bit minimal. Might as well test all the new artists, as in your example in the PR (or a good subset of them).

Copy link
Author

Choose a reason for hiding this comment

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

will do :)

@tacaswell
Copy link
Member

thanks for working on this @magedhennawy !

@jklymak jklymak dismissed their stale review March 12, 2018 16:47

Changes made. Haven't re-reviewed

@jklymak
Copy link
Member

jklymak commented Mar 15, 2018

You must have a typo somewhere, because this doesn't run at all now!

@magedhennawy
Copy link
Author

taking a look

@jklymak
Copy link
Member

jklymak commented Mar 15, 2018

  • you've used mpatches but not defined it.
  • you changed one of three identical legend parameter list docstrings (yes its annoying, but for now you need to make them all the same).
  • you have some PEP8 errors.

@magedhennawy
Copy link
Author

@jklymak im not entirely sure what your second point is referring too :/

@@ -428,7 +430,7 @@ def __init__(self, parent, handles, labels,
The vertical offset (relative to the font size) for the markers
created for a scatter plot legend entry. 0.0 is at the base the
legend text, and 1.0 is at the top. To draw all markers at the
same height, set to ``[0.5]``. Default is ``[0.375, 0.5, 0.3125]``.
same height, set to ``[0.5]``. Default ``[0.375, 0.5, 0.3125]``.
Copy link
Member

Choose a reason for hiding this comment

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

You changed this docstring here. If you want to make the change, you also need to change it in figure.py and axes/_axes.py

Copy link
Author

Choose a reason for hiding this comment

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

ah. okay. gotcha, ill keep it consistent to how the other files are.

Thanks again.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, see #10799 for the proposed fix to this lamentable state of affairs. But in this case, I'd just remove the change.

@jklymak jklymak mentioned this pull request Mar 15, 2018
6 tasks
@jklymak
Copy link
Member

jklymak commented Mar 16, 2018

Now I'm confused - who is authoring this PR?

@bursu
Copy link

bursu commented Mar 16, 2018

We are working on it together, picking up since he had something to do.

@magedhennawy
Copy link
Author

@jklymak i had to step out for an event, so my partner stepped in for me :)

@magedhennawy
Copy link
Author

@jklymak finally. sorry it took a while


@image_comparison(baseline_images=['legend_all_annotation_text'],
extensions=['png'],
style='mpl20')
Copy link
Member

Choose a reason for hiding this comment

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

Why the other tests rather than just this one?

Copy link
Author

Choose a reason for hiding this comment

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

Not entirely sure, I'm thinking that he wanted to have them all tested separately and then just a cumulative one, also as a "how to use" kind of thing

Copy link
Member

Choose a reason for hiding this comment

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

Image tests have a time cost and a repo-space cost. I'd remove the redundatn ones unless there is a pressing need for them.

Copy link
Author

Choose a reason for hiding this comment

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

got it, push incoming.

@jklymak
Copy link
Member

jklymak commented Mar 19, 2018

Can you guys squash your commits? Please (please) make a local backup branch before you try unless you are very comfortable w/ what you are doing. Please see the instructions in the developers guide.

@magedhennawy
Copy link
Author

@jklymak the squash is going to be very messy and i'm unsure as to how ill do it properly due to the merges amidst my commits.
Do you have another idea in order for me to do it as efficiently as possible?? :(

@jklymak
Copy link
Member

jklymak commented Mar 19, 2018

Suggest you

  1. make a backup branch
  2. copy your 4 changed files somewhere temporary out of your github repository
  3. assuming you have matplotlib/matplotlib already set up as upstream and have fetched it recently:
    • on this branch (issue-8236-dev): git reset --hard upstream/master
  4. copy the changed files back into the proper spots
  5. recommit and then repush to github.

@magedhennawy
Copy link
Author

@jklymak works perfectly. thank you 👍

@jklymak
Copy link
Member

jklymak commented Mar 19, 2018

No problem. I only had to be told how to do that 10 or 12 times on here 😉

@magedhennawy
Copy link
Author

does this still need revision?

Copy link
Member

@jklymak jklymak left a comment

Choose a reason for hiding this comment

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

LGTM asside from a couple of formatting issues that are not blockers but could be addressed if there is a more stringent review.

handlebox.xdescent, handlebox.ydescent,
handlebox.width, handlebox.height,
fontsize)
legend, orig_handle,
Copy link
Member

Choose a reason for hiding this comment

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

Please leave the double indent - this makes it easier to tell its an indent vs a code block...

Copy link
Author

Choose a reason for hiding this comment

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

will be changed asap! :)

@@ -441,6 +451,7 @@ class HandlerErrorbar(HandlerLine2D):
"""
Handler for Errorbars.
"""

Copy link
Member

Choose a reason for hiding this comment

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

Were all these extra carriage returns called for somehow?

Copy link
Author

Choose a reason for hiding this comment

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

I believe the pep8 rules that I followed required a blank line after dosctrings in methods, correct me if i'm wrong please.

@jklymak
Copy link
Member

jklymak commented May 2, 2018

Don't be shy about pinging for reviews (on a reasonable basis) - we have a somewhat stringent review policy, but not enough reviewers, and github tends to make it too easy for old PRs to get burried.

@ImportanceOfBeingErnest
Copy link
Member

This is great stuff. I would think this is valuable enough to deserve a What's new entry.

@magedhennawy
Copy link
Author

Got it! Thank you guys for all your feedback

@jklymak jklymak mentioned this pull request May 17, 2018
@tacaswell
Copy link
Member

Took the liberty of adding a whats new entry, @magedhennawy please feel free to tweak it!

self._handlers = handlers

if (width_ratios is not None) and (len(width_ratios) == ndivide):
self._width_ratios = width_ratios
Copy link
Member

Choose a reason for hiding this comment

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

Can you raise here if the width_rations and ndivide are not compatible? I can imaging this being a very frustrating bug to track down where we are ignoring one of the inputs!


self._ndivide = ndivide
self._pad = pad
self._handlers = handlers
Copy link
Member

Choose a reason for hiding this comment

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

Can you validate that handlers is the correct length? That way we can raise an exception as early as possible.

@tacaswell tacaswell modified the milestones: v3.0, v3.1 Jul 9, 2018
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.

Small changes to the __init__ to validate the inputs that have to be the same length.

@tacaswell tacaswell modified the milestones: v3.0, v3.1 Jul 15, 2018
@jklymak
Copy link
Member

jklymak commented Aug 17, 2018

ping @magedhennawy !

@jklymak
Copy link
Member

jklymak commented Oct 6, 2018

pinging again...

@jklymak jklymak added the stale label Feb 10, 2019
@jklymak jklymak modified the milestones: v3.1.0, v3.2.0 Feb 10, 2019
@jklymak
Copy link
Member

jklymak commented Jul 16, 2019

Thanks a lot for the contribution. I'm going to close this as having had no action for quite a while. OTOH, if there is still a community that wants this, please feel free to re-open, with my apologies!

@jklymak jklymak closed this Jul 16, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Legend does not show 'annotate'
6 participants