Skip to content

Update legend to automatically grow with marker_size #10765

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

Conversation

jinshifen33
Copy link
Contributor

@jinshifen33 jinshifen33 commented Mar 12, 2018

PR Summary

Legend used to not grow with increasingly large marker sizes, resulting in large markers flowing out of the legend.

Note: This fix results in an image comparison test failing; this is a result of the legend now being drawn differently and the image drawn now is slightly larger.

This is the original image:
37182110-d30b35a0-22fd-11e8-986b-8ea18e1f0add

This is the updated image:
marker_cycle

Issue #8705

PR Checklist

  • Has Pytest style unit tests
  • Code is PEP 8 compliant

@@ -241,7 +241,7 @@ class AnyObject(object):
pass


class AnyObjectHandler(object):
class AnyObjectHandler(HandlerBase):

Choose a reason for hiding this comment

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

Isn't the idea of this example to show that you can use any object (hence the name) and do not actually need to subclass any existing object?

Choose a reason for hiding this comment

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

The "AnyObject" is referring to the idea that you can pass any object to the legend as a handle. The Legend handler should still be a child of HandlerBase.

Choose a reason for hiding this comment

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

No, this example is meant to show that you may use any object as handler as long as it implements the legend_artist method. There also seems no reason to subclass HandlerBase, why "should" it?

The next example then shows how to subclass HandlerBase for the ellipse case. This is as intended, because it actually uses some of the parent's methods.

Copy link

@UTSC-SISYPHUS UTSC-SISYPHUS Mar 16, 2018

Choose a reason for hiding this comment

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

You should subclass HandlerBase because the legend expects to be dealing with HandlerBase's. It should be enough to modify legend and HandlerBase to change handler functionality. That's why I made AnyObjectHandler a HandlerBase in the first place. I made Legend call a new function. It doesn't make sense to have legend interact with any arbitrary random object you want because it makes it more difficult to update in the future.

Choose a reason for hiding this comment

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

The text above that example reads:

A custom handler can be implemented to turn any handle into a legend key (handles don’t necessarily need to be matplotlib artists). The handler must implement a “legend_artist” method which returns a single artist for the legend to use. Signature details about the “legend_artist” are documented at legend_artist().

Are you proposing to change this text as well? Otherwise this code example shows exactly what the text says; and it's good the way it is, because it allows people to easily grasp the concept.

I made Legend call a new function.

Maybe you shouldn't do that.

Copy link
Member

Choose a reason for hiding this comment

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

I agree with @ImportanceOfBeingErnest , this is showing off duck typing (we try to use isinstance as rarely as possible).

If this PR is expanding the required API for things that can be handlers, then that should be documented as such.

@ImportanceOfBeingErnest
Copy link
Member

In general I have a problem understanding this PR. Seen from the tests, this would result in the normal code

plt.plot([1], [1], 's', markersize=40., label="label")
plt.legend()

suddenly producing a huge legend in the plot.

Shouldn't it be the other way around, namely that arbitrarily large or small markers have a normalized size in the legend? At least that would actually be the desired way to create a legend - as far as I see it from a user's perspective. Or even better, to allow to choose whether markersize should be adapted or not, i.e. adding a possible markerscale option like markerscale="normalized".

@jklymak
Copy link
Member

jklymak commented Mar 15, 2018

I can’t tell from the images above what the change is; they look the same to me.

@ImportanceOfBeingErnest I sort of agree w your comment above, except, that’s potentially harder than what’s done here, and I imagine we can all agree that legends that overspill their borders are undesirable. But the issue w normalization of marker sizes is that they are often used to differentiate data. I.e. a marker size of 30 could get a different legend entry than a marker size of 40. So I think normalaization would not work for many folks. Some sort of shrink factor might make sense in some cases, but legends are already a mess of kwargs so I’m not really seriously suggesting that.

@br-Zhang
Copy link
Contributor

@jklymak The one being highlighted in the PR is just one of the old regression tests that broke due to these changes. The real changes are shown in the new tests added to test_legend.py, where the legend box is now being resized while previously the markers would go off-bounds.

@jinshifen33
Copy link
Contributor Author

jinshifen33 commented Mar 15, 2018

it seems like we messed up with the images , changing it.

@jklymak
Copy link
Member

jklymak commented Mar 15, 2018

... indeed there already is a markerscale kwarg....

@UTSC-SISYPHUS
Copy link

UTSC-SISYPHUS commented Mar 15, 2018

Here is the difference between the two images:
marker_cycle-failed-diff

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.

A couple of small things... This looks good to me. Not sure if it breaks anything, but looks like the tests are happy, so if it does, then its something we aren't testing.

@@ -88,6 +88,58 @@ def adjust_drawing_area(self, legend, orig_handle,
height = height - self._ypad * fontsize
return xdescent, ydescent, width, height

def scale_dimensions(self, legend, width, height, 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.

Should this be private?

Choose a reason for hiding this comment

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

scale dimensions is called by legend so it's public.

Copy link
Member

Choose a reason for hiding this comment

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

We can call private methods from w/in the rest of Matplotlib. I don't think there is any stricture that private means module-only. I don't see any reason for this to be part of the public API (i.e. appear on the webpage).

@jklymak jklymak added this to the v3.0 milestone Mar 15, 2018


@image_comparison(baseline_images=['legend_large_markers'],
extensions=['png'])
Copy link
Member

Choose a reason for hiding this comment

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

Ooops, somehow managed to lose this comment: These should be style='mpl20' if possible. You need the text, but you could clear the x and y ticks to reduce the amount of text. We are trying to make image tests have less text to make it easier for them to pass different FreeType versions.

@ImportanceOfBeingErnest
Copy link
Member

@jklymak My point is that this PR seems to automatically change the legend size depending on the size of the markers. This is often not desired. Suppose e.g. having two subplots both with a legend and suddenly one of those legends is bigger - this would only be acceptable if an option exists to revert that and have both legends the same size independent on the markersize.

On the other hand we currently do have the option to change the line height etc in the legend, so using large markers would just require to tweak those parameters a bit.

If I set a certain labelspacing I want the legend to have this labelspacing. And not change it to something else just because the legend thinks that's better. Also if I use a style sheet and set the labelspacing there, I do not want some algorithm changing that.

@jklymak
Copy link
Member

jklymak commented Mar 15, 2018

So would it maybe make sense to have labelspacing=“auto” that would trigger this behaviour?

@ImportanceOfBeingErnest
Copy link
Member

ImportanceOfBeingErnest commented Mar 15, 2018

Yes, labelspacing="auto" would be an option. But what would then be the default value for "normal" labelspacing, which is currently set to 0.5?

@UTSC-SISYPHUS
Copy link

The fix is not affecting the labelspacing. It is changing the size of the DrawingArea the handles are drawn in. In the test that has been changed, the legend looks like there is more labelspacing because in the original version, the artists were spilling over into the padding between DrawingAreas.

@UTSC-SISYPHUS
Copy link

Currently, setting handlelength and handleheight can be used to grow the DrawingArea. I can add a normalize flag and add code for scaling down artists but how should I handle that? If there is a size 50 marker and a size 10 marker should they both be scaled down by the same factor or should they be scaled independently to both fit the DrawingAreas?

@ImportanceOfBeingErnest
Copy link
Member

There needs to be the option to have the same distance between legend labels, independent on how large the marker size is. So for a given handleheight and labelspacing (and fontsize) this should not change. This seems to not be the case with this PR.

Btw. handleheight seems to be missing in the docs.

Downscaling the markers can currently be done using markerscale , which is proportional to the original marker. My proposal for normalizing would mean to have a constant markersize, independent on the one of the original artist - but that was just a wishlist item, not sure if it should be added here. Also this can be achieved using the update_function of the legend handler if needed, so it's not really urgent.

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 additional API on handler objects should be optional so that any existing third-party handler classes that do not inherit from HandlerBase continue to work.

enough to fit.

'''
if isinstance(orig_handle, Line2D):
Copy link
Member

Choose a reason for hiding this comment

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

Is there a way to handle this without isinstance?

@anntzer
Copy link
Contributor

anntzer commented Mar 16, 2018

Do we actually need the additional API? Can we not infer the size of the handle (e.g., by reusing the tight_bbox code) and directly use that?
I actually (personally) have no problem with saying "you must subclass LegendHandler" (or provide all the methods that LegendHandler does, and we allow ourselves to add more) -- although you certainly need to maintain backcompat at least for a while --, but only if you can convince me that there's no other alternative.

@efiring
Copy link
Member

efiring commented Mar 25, 2018

The new tests are showing the symbol labels in the legend aligned with the bottom of the symbol. Wouldn't most people want the labels to be horizontally aligned with the center of the symbol?

In addition, I'm wondering about the cost/benefit tradeoff here. It is adding code and complexity to the already complex legend; is it really worth it?

@UTSC-SISYPHUS
Copy link

UTSC-SISYPHUS commented Mar 25, 2018

Yes they would. I could change the alignment to center but then I would need to replace every image comparison test image with a legend it because the handles would be drawn a few pixels away from where they are now.

@efiring
Copy link
Member

efiring commented Mar 25, 2018

Given that the change to centering is required for your other change to yield the desired result, I don't see any way to avoid it. Even for the less extreme example in your PR summary, the alignment looks wrong.

I also wonder whether the default pad should be reduced.

Or, getting back to my second point in my earlier comment: how important is it to have this automatic machinery, as opposed to leaving it to the user to handle larger symbols by manually increasing the padding? Will it have unintended consequences? Will eventually handling the scatter case make the legend code even more unwieldy?

@jklymak
Copy link
Member

jklymak commented Mar 25, 2018

My opinion is that:

  1. the code is sufficiently fenced off to not be too complicated (though it could use some documentation to be crystal clear)
  2. I think its nice to have the spacing automatically adjust
  3. I think if the user specified labelspacing then it should not adjust.
  4. To make the failing tests pass, you could just specify the labelspacing to the current default (0.5?)

@UTSC-SISYPHUS
Copy link

@efiring handling scatters would just require overriding the handle_width/height methods in the correct legend handler. The methods are intended to be overridden for different handle types if necessary so it's not too unwieldy.

@ImportanceOfBeingErnest
Copy link
Member

This PR seems rather independent on labelspacing; it is rather handleheight that seems relevant here. In any case those are two parameters which can be set as arguments to the legend API, and as rcParams. Just as with any other matplotlib parameters the functionality should be (1) Use argument, if provided (2) use rcParam if argument is not provided. Hence you cannot use one or both arguments not being present in the function call as a trigger to activate this automatic sizing, as that would render the rcParams useless.

I think it is mostly agreed that the automatic change should be optional or at least allow to be turned off. The only way I see this happening is to either

  • add a new argument to the legend together with the respective rcParam, something like
    adjust_to_handle_height = True (surely a better name is required); or
  • let the existing argument(s) take a new value "auto"
    labelspacing= "auto", handleheight = "auto"

@jklymak
Copy link
Member

jklymak commented Mar 25, 2018

Sorry forgot about rcParams.

I’d have rcParams set both to None and use the auto spacing be default and recover the old behaviour with the old params.

But that’s just my vote. In general I don’t think we should have overlapping text and symbols if we can help it and the resulting code isn’t ridiculously convoluted.

@UTSC-SISYPHUS
Copy link

I can add a scaleHandleBox param. It will be true by default, It will have the current functionality while it's on. If it's off it will just go with handlelength and handleheight.

handleheight/length can't be set to auto because they're used as numbers in the normal size calculations. labelspaceing says it's "space between the legend entries" so I don't think it's relevant here.
Do you agree with this change?
P.S. is matplotlibrc.template the correct place to make changes to the rcParams?

@ImportanceOfBeingErnest
Copy link
Member

handleheight/length can't be set to auto because they're used as numbers in the normal size calculations.

Indeed, that is basically the comment I made already, but with handleheight instead of labelspacing.

I think one would add this scalehandlebox as an argument to legend as well, right?

Since all rcParams are lower case, one would go with scalehandlebox (not sure if there isn't a better name though, but I can't think of one right now and it's probably better than adjust_to_handle_height anyways).

Adding an rcParam would be done in the lib/matplotlib/rcsetup.py file. This is the file that defines and validates the parameters. matplotlibrc.template will need to be updated as well, but that is for documentation purposes only (this file is not actually used by matplotlib itself, but should act as a valid rc file in case all parameters are uncommented). The recent PR #10306 may give you a nice overview what is needed to add an rcParam.

@UTSC-SISYPHUS
Copy link

UTSC-SISYPHUS commented Mar 25, 2018

I added the scalehandlebox param.

I would like to change
# pack handleBox and labelBox into itemBox itemBoxes = [HPacker(pad=0, sep=self.handletextpad * fontsize, children=[h, t] if markerfirst else [t, h], align="baseline")
to
# pack handleBox and labelBox into itemBox itemBoxes = [HPacker(pad=0, sep=self.handletextpad * fontsize, children=[h, t] if markerfirst else [t, h], align="center")

(baseline to center)

It would make the legends look nicer in cases where the heights of handleBox and labelBox are different but it would require replacing the images for around 50 image comparisons.

Here's what the improvement would be:
original:
legend_large_markers-expected
new:
legend_large_markers

The differences for most images would be of this nature:
new:
legend_auto1
original:
legend_auto1-expected
diff:
legend_auto1-failed-diff

Is there any reason I shouldn't make the change and update the tests?

@efiring
Copy link
Member

efiring commented Mar 26, 2018

I think the alignment change is needed, and helps even without your other changes. Maybe the thing to do is use the opportunity to update the affected tests to our current style mode instead of classic mode. Let's see what others say, though.

@jklymak
Copy link
Member

jklymak commented Mar 26, 2018

labelspacing is relevant. Try to change it to a big number and see what happens.

I don't think you need the new kwarg or rcParam, and I think adding a new one is more confusing than adding yet another kwarg controlling the vertical spacing of legend elements. i.e. by default you set handleheight and labelspacing to None. If they are still None when they are set (l 408) then you set an internal variable _scalehandlebox to True, and set handleheight and labelspacing to their default values. If either of them is set then, _scalehandlebox is False and you just do what you did before.

I actually think its unfortunate that there is both a handleheight and labelspacing, since they both kind of do the same thing.

@ImportanceOfBeingErnest
Copy link
Member

and set handleheight and labelspacing to their default values.

What is the default value if they are both None???? Do you want to hardcode some value into the code?

labelspacing is not relevant here in the sense that the PR does not touch it at all. Although maybe it should?

image

@jklymak
Copy link
Member

jklymak commented Mar 26, 2018

Yes if handleheight. is none, then the automatic algorithm is used and whatever value is needed for handleheight in the code is hard coded. I don’t see why it’s needed at all if we automatically determine the height of the handle but it certainly need not be a variable or if it does you will have to walk me through it

Thanks for explaining the technical diff between label spacing and handle height. What an unfortunate pair of names. Hence why I’m being a bit of a pita about the API here. I agree that this variable need not be part of this PR. But I still do not think that he height and width of the handles should have three kwargs controlling their size.

@ImportanceOfBeingErnest
Copy link
Member

if handleheight is none, then the automatic algorithm is used and whatever value is needed for handleheight in the code is hard coded.

I would agree to that if "the algorithm" was a true optimization in both directions. However, as it is now it's only an expansion in one direction in case the marker is larger than the handleheight. Therefore there needs to be a "default" value which is used for all cases which are not using huge markers (which are like 99% of all cases I suppose). Hardcoding this default value would be a step back compared to where we are now, letting the rcParams decide the default.

@UTSC-SISYPHUS
Copy link

The "algorithm" does expand both width and height.

@ImportanceOfBeingErnest
Copy link
Member

Sorry, by "direction" I mean of course that it only expands - it does not shrink.

@jklymak
Copy link
Member

jklymak commented Mar 26, 2018

Hardcoding this default value would be a step back compared to where we are now, letting the rcParams decide the default.

Adding yet another confusing kwarg to legend would be a step backwards compared to having the code do the right thing via a simple API.

Right now a legend entry needs a certain height and it needs a certain spacing between entries. I'm fine with the spacing being left along.

Ideally, the "automatic" height should be the larger of the text height and the marker height. If the user wants to overide this with a smaller or larger height, they should be welcome to, using non-None values of the handleheight kwarg/rcParam. I don't think there is a separate need for a default height that the user needs to control. You seem to want to use this as a min height, below which the height won't drop, even if it will automatically expand; i.e. make it 0.5 unless it needs to be bigger. I don't think there is enough reason for that extra flexibility to deserve a new kwarg for a method that already has 26 optional kwargs.

@ImportanceOfBeingErnest
Copy link
Member

Using the font size as the default from which to potentially enlarge the handleheight is an option.

What will determine the "standard" handlelength? The handle itself? This might work for markers, but not for lines, which usually are just as long as the handlelength. So if handlelength=None, how long will the legend for the usual plot be?
I would then also fear that one ends up with significantly different legends with markers compared to legends with lines or else one needs to hardcode a value, which is again undesired.

@jklymak
Copy link
Member

jklymak commented Mar 26, 2018

Again, I don't see a fundamental problem with an autoscale in the horizontal, unless the user specifies a handlelength either via rcParam or kwarg. Yes, that will mean that lines will have a hardcoded default length if you want the width to otherwise be adjustable. I still think thats an improvement on the present situation, and still gives the user a way to recover current behaviour.

@tacaswell tacaswell modified the milestones: v3.0, v3.1 Jul 7, 2018
@jklymak jklymak added the stale label Feb 9, 2019
@jklymak jklymak modified the milestones: v3.1.0, unassigned Feb 9, 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.

10 participants