Skip to content

Align x and y labels between axes #9652

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 4 commits into from
Jan 24, 2018
Merged

Conversation

jklymak
Copy link
Member

@jklymak jklymak commented Nov 1, 2017

PR Summary

Aligns the x and y labels automatically at draw time.

Example:

import matplotlib
matplotlib.use('Qt5Agg')
import matplotlib.pyplot as plt
import numpy as np
import matplotlib.gridspec as gridspec

for after in [True, False]:
    fig = plt.figure(tight_layout=True)
    gs = gridspec.GridSpec(2, 2)

    ax = fig.add_subplot(gs[0,:])
    ax.plot(np.arange(0, 1e6, 1000))
    ax.set_ylabel('Test')
    for i in range(2):
        ax = fig.add_subplot(gs[1, i])
        ax.set_ylabel('Booooo')
        ax.set_xlabel('Hello')
        if i == 0:
            for tick in ax.get_xticklabels():
                tick.set_rotation(45)

    if after:
        # fig.align_ylabels()
        # fig.align_xlabels()
        fig.align_labels()  # do both x and y
        fig.savefig('After.png')
    else:
        fig.savefig('Before.png')

Before

before

After

after

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
  • 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
  • Check righthand/top labels
  • align_labels()
  • align titles: Done but Move title up if x-axis is on the top of the figure #9498 needs to be merged for it to work (or matter) ;-)

@dstansby
Copy link
Member

dstansby commented Nov 1, 2017

Ooh, this looks nice. I also approve of having this as a function rather than another kwarg option.

@dstansby dstansby added this to the v2.2 milestone Nov 1, 2017
@anntzer
Copy link
Contributor

anntzer commented Nov 1, 2017

Very nice indeed, something I've always wanted to have.

One minor design point: I know that right now we tend to pass the renderer instance down the call stack, but AFAICT we really don't need to do that (we can just get it from artist.figure.canvas.renderer, and I don't think it makes sense to use another renderer anyways (but I may be wrong)). (I have somewhere a plan to remove all these renderers being explicitly shuffled through a bunch of functions, but that's a bit tricky to do due to backcompat :/)

@jklymak
Copy link
Member Author

jklymak commented Nov 1, 2017

Well frankly it’s an attempt to head off calls for this to be in a geometry manager like #9082. You can do it but if you can do it more simply then why not.

@anntzer I’ll look at the renderer stuff. I was really just copying the _update_label_pos function that also passed the renderer so I assumed there were good reasons. I think there are cases where the figure canvas renderer is not defined so the wrapper in tight_layout is useful.

@anntzer
Copy link
Contributor

anntzer commented Nov 1, 2017

As long as you keep renderer the last argument it remains possible to later deprecate it without too much pain so I don't mind keeping this discussion for later...

@@ -4,10 +4,10 @@
===============

Aligning xlabel and ylabel using
:func:`~matplotlib.figure.align_xlabels` and
:func:`~matplotlib.figure.align_xlabels`
:func:`matplotlib.figure.align_xlabels` and
Copy link
Contributor

Choose a reason for hiding this comment

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

for future reference, you don't need the :func: (or :meth: or :class:, etc.) here: the default role resolves any python object.

Subplot axes ``ylabels`` can not line up horizontally if the tick labels
are very different widths. The same can happen to ``xlabels`` if the
ticklabels are rotated on one subplot (for instance). The new methods
on the ``Figure`` class: ``fig.align_xlabels()`` and ``fig.align_ylabels()``
Copy link
Contributor

Choose a reason for hiding this comment

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

rst syntax police here:

on the `Figure` class: `Figure.align_xlabels` and `Figure.align_ylabels`

or

on the `Figure` class: `~Figure.align_xlabels` and `~Figure.align_ylabels`

These are refering to things that have their own entries in the docs (the class and the method. The tilde is documented at http://www.sphinx-doc.org/en/stable/domains.html#role-py:obj and quite useful.

===============

Aligning xlabel and ylabel using
:func:`matplotlib.figure.align_xlabels` and
Copy link
Contributor

Choose a reason for hiding this comment

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

see above re: syntax. the functions don't actually exist in the figure module.

def _get_tick_boxes_siblings(self, renderer):
"""
Get the bounding boxes for this axis and its sibblings
as set by `fig.align_xlabels` or ``fig.align_ylables`.
Copy link
Contributor

Choose a reason for hiding this comment

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

markup

Choose the left-most label to align to. Axes with the same subplot
column are aligned.

Parameters:
Copy link
Contributor

Choose a reason for hiding this comment

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

I'll channel the numpydoc police here.

Also seems normal to define align_xlabels first?

ax = fig.add_subplot(gs[1, i])
ax.plot(np.arange(1., 0., -0.1) * 2000., np.arange(1., 0., -0.1))
ax.set_ylabel('YLabel1 %d' % i)
ax.set_xlabel('XLaleb1 %d' % i)
Copy link
Contributor

Choose a reason for hiding this comment

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

"XLabel1" rather than "XLaleb1"?

PS: I am not an expert of this portion of the code but FWIW, I am very excited by such a functionality landing in Matplotlib :)

@jklymak jklymak changed the title WIP: Align x and y labels between axes (Comments welcome) Align x and y labels between axes Nov 2, 2017
@jklymak jklymak force-pushed the alignxylabels branch 2 times, most recently from dd8e05e to 44b36df Compare November 2, 2017 19:02
@jklymak
Copy link
Member Author

jklymak commented Nov 2, 2017

squashed and rebased with master...

@anntzer
Copy link
Contributor

anntzer commented Nov 2, 2017

@jklymak Can you please not include my name with the @ (@anntzer) in the commit message? everytime you force push the same commit message I get a notification :-)
Either just have anntzer, or push different commits and squash them before merging, whatever works for you.

@jklymak
Copy link
Member Author

jklymak commented Nov 2, 2017

How annoying; my apologies!

@jklymak
Copy link
Member Author

jklymak commented Jan 12, 2018

Rebase. Ping @anntzer, @dstansby, @afvincent who seemed interested; Pretty sure this is a non-breaking change. Happy to move to 3.0 if folks can't take a closer look next couple weeks.

if i == 0:
for tick in ax.get_xticklabels():
tick.set_rotation(55)
fig.align_labels() # same as fig.align_xlabels() and fig.align_ylabels()
Copy link
Contributor

Choose a reason for hiding this comment

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

replace and by (semicolon) (fig.align_xlabels() and fig.align_ylabels() is actually legal python but will only execute the first half :-))

Aligning Labels
===============

Aligning xlabel and ylabel using
Copy link
Contributor

Choose a reason for hiding this comment

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

something wrong with the wrapping her

def _get_tick_boxes_siblings(self, renderer):
"""
Get the bounding boxes for this axis and its sibblings
as set by `Figure.align_xlabels` or ``Figure.align_ylables`.
Copy link
Contributor

Choose a reason for hiding this comment

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

markup, typo "sibblings" above

axx.yaxis._align_label_siblings += [ax.yaxis]

# place holder until #9498 is merged...
def align_titles(self, axs=None, renderer=None):
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 is independent of #9498 (on the feature side; the implementations may be overlapping...), or did I miss somthing?

Copy link
Member Author

@jklymak jklymak Jan 12, 2018

Choose a reason for hiding this comment

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

Oh, I remember now...

This code (align_titles) is not needed now, and hence the call to it is commented out below, because titles are always in the same position relative to the top of the axis (if placed automatically). So, going through this function is needlessly expensive (though for practical purposes its not that expensive).

If #9498 gets merged, the y-position of titles will be different, and this code will become useful.

No test, because if the title is placed manaully this shouldn't run.... EDIT: Ooops, no I don't offer that feature ;-)

same += [axc]

x0, y0 = ax.title.get_position()
for axx in same:
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks easier (and cheaper) to get the max value of y in a first pass and then apply it throughout?

import numpy as np
import warnings
import pytest


@image_comparison(baseline_images=['figure_align_labels'])
def test_align_labels():
Copy link
Contributor

Choose a reason for hiding this comment

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

also test for titles? not sure what's the status of that part

@anntzer
Copy link
Contributor

anntzer commented Jan 12, 2018

One thing that's perhaps not obvious is that calling align_foo has a persistent effect (in other words it does not just do the alignment "for this single draw"). Which is very reasonable, but should be mentioned somewhere.

Otherwise, generally looks good (minor comments above).

@@ -1675,8 +1675,8 @@ def set_ticks(self, ticks, minor=False):

def _get_tick_boxes_siblings(self, renderer):
"""
Get the bounding boxes for this axis and its sibblings
as set by `Figure.align_xlabels` or ``Figure.align_ylables`.
Get the bounding boxes for this `.axis` and it's siblings
Copy link
Contributor

Choose a reason for hiding this comment

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

its :)

for axx in same:
_log.debug(' Same: %s', axx.xaxis.label)
axx.xaxis._align_label_siblings += [ax.xaxis]
if (((labpo == 'bottom') and (rowc1 == row1)) or
Copy link
Contributor

Choose a reason for hiding this comment

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

not a big deal but that's a lot of extra parentheses (only the outermost are needed)

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 somewhere in my head I knew that and precedes or, but I'm never sure, and I'm never sure from language to language, so...

@jklymak
Copy link
Member Author

jklymak commented Jan 12, 2018

Thanks @anntzer

Removed title alignment for now. That depends on #9498, which is the only automatic way to have titles not be aligned; right now they are aligned by default. Of course if the user manually places one title, I assume they can align the other ones by hand because they know the position. I'll put the align_title code in #9498.

I did make a couple of small improvements (got rid of a loop), and tried to indicate what was going on in the comments (because even I was confused looking back at it).

Added the fact that this alignment is persistent to the docs. Also that it only applies to automatically applied label positions. Manually-placed labels don't get aligned. Though thats a bit subtle - I think the way it is now, if a manually aligned label is the outlier, others will get aligned to it. If its an inlier, then the others will not be aligned to it, and the manual label will also stay an inlier. I actually think thats desirable, but could change that behaviour if others disagree. I think its a bit of an edge case; again, if someone is lining things up by hand, they probaby shouldn't be calling this helper.

@anntzer
Copy link
Contributor

anntzer commented Jan 13, 2018

Would it make sense to store the alignedness info on a Grouper (https://matplotlib.org/api/cbook_api.html#matplotlib.cbook.Grouper) stored on the Figure (one per axis), instead of in Axis._align_label_siblings? The main advantage would be that this way, if we ever figure out proper axes unsharing semantics (right now, shared axes cannot be unshared), hopefully the solution would also apply to unaligning (I don't think it's critical, just pointing out the possibility).

@jklymak
Copy link
Member Author

jklymak commented Jan 13, 2018

The axes need not be shared if we align the labels (and indeed it would be strange for the labels to be misaligned in that case).

OTOH, (I think) I can see the advantage of a pair of Grouper in fig, simply to stop carying around MxN lists with repetitive info in them. i.e. Figure._aligned_xaxis_grp, Figure._aligned_yaxis_grp.

@anntzer
Copy link
Contributor

anntzer commented Jan 13, 2018

The point is not to reuse the same grouper instance, just to use the same data structure.
I think this is very close to mergeable (even without the grouper thing).

@anntzer anntzer added the Release critical For bugs that make the library unusable (segfaults, incorrect plots, etc) and major regressions. label Jan 13, 2018

if axs is None:
axs = self.axes
axs = np.asarray(np.array(axs)).flatten().tolist()
Copy link
Contributor

Choose a reason for hiding this comment

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

You really really wanted to make sure that you got a list heh :-)
Currently this will also accept Axes instances (rather than Axes in a single-element list) and multi-dimensional arrays. Was that intended? Just making sure, I would as usual prefer a simpler interface but am only -0 on being more flexible.

Copy link
Member Author

Choose a reason for hiding this comment

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

Too much?

Ummm, I can't remember why I went so nuts on this.

  • I agree a singleton would be silly, though letting a list w/ one element would be fine.
  • It needs to be flattened, but before I can do that, it needs to be an np.array...
  • but, I have no idea why I have np.array and np.asarray in there.

Fixed?

axs = np.asarray(axs).flatten().tolist()

Copy link
Contributor

Choose a reason for hiding this comment

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

for ax in np.asarray(axs).flat: is enough (that'll give you an 1D iterator that directly refers to the original data).

Copy link
Member Author

Choose a reason for hiding this comment

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

Couldn't quite get that to work, and I want to save the list so I don't need the awkward construct twice. Using axs = np.asarray(axs).flatten().

Copy link
Member Author

Choose a reason for hiding this comment

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

TO be honest, not sure why flat doesn't work. It basically dropped one out of three axes on the figure in align_labels_demo.py so that third axis didn't get aligned. I don't really get why it doesn't work, but flatten() does, so...

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, I missed the fact that you had a nested loop also iterating over axs. In that case it is normal that the inner loop also advances the outer iterator and that won't work indeed.

As a side side note, using .ravel() instead of .flatten() is usually more efficient, because the latter always copies the data into a new array, whereas the former only does so when needed (i.e. when the data of the original array is not regularly spaced in memory). We're unlikely to ever have so many axes to run out of memory :-) but still a good habit IMO.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, I see. Interesting. I never understood the difference between flatten() and ravel() and just use flatten because the word means something to me, whereas I'm not sure what ravel is supposed to mean.

Copy link
Contributor

Choose a reason for hiding this comment

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

"ravel" is apparently a synonym of "unravel" :) https://www.merriam-webster.com/dictionary/ravel?utm_campaign=sd&utm_medium=serp&utm_source=jsonld
[OT: reminds me of the day where my colleague explained to me that "being down for sthg" and "being up for sthg" meant essentially the same thing. English is sometimes weird :)]

Copy link
Member Author

Choose a reason for hiding this comment

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

Made even better by numpy's use of unravel to mean the opposite of ravel: https://docs.scipy.org/doc/numpy-1.13.0/reference/generated/numpy.unravel_index.html

Copy link
Contributor

@anntzer anntzer left a comment

Choose a reason for hiding this comment

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

Very minor comments still being discussed but their resolution is not strictly required for this PR to go in.

@dstansby dstansby merged commit 3d3472c into matplotlib:master Jan 24, 2018
@jklymak jklymak deleted the alignxylabels branch January 24, 2018 23:57
@QuLogic QuLogic modified the milestones: needs sorting, v2.2.0 Feb 12, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Release critical For bugs that make the library unusable (segfaults, incorrect plots, etc) and major regressions.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants