Skip to content

Add support for (sub-) panel labels to Axes #15771

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

Conversation

lschr
Copy link
Contributor

@lschr lschr commented Nov 25, 2019

PR Summary

In scientific publications, sub-panels are often enumerated (a, b, c, …), identifying them for reference in the figure legend or main text.

This adds support for such labels, plus various options for alignment.

By default, labels are aligned with the left edge of the y axis label and with the baseline of the title.

Example:

fig, (ax1, ax2) = plt.subplots(1, 2)
ax1.set_title("Plot1")
ax1.plot([0, 1], [0, 1])
ax2.set_title("Plot2")
ax2.plot([0, 1], [0, 0])
ax1.set_panellabel("a")
ax2.set_panellabel("b")
fig.tight_layout()

ex1

However, alignment can be changed:

fig, (ax1, ax2) = plt.subplots(1, 2)
ax1.set_title("Plot1")
ax1.plot([0, 1], [0, 1])
ax2.set_title("Plot2")
ax2.plot([0, 1], [0, 0])
fd = {"horizontalalignment": "right"}
ax1.set_panellabel("a", h_align="frame", fontdict=fd)
ax2.set_panellabel("b", h_align="frame", fontdict=fd)
fig.tight_layout()

ex2

It is also possible to align the labels of multiple sub-panels, even if their titles and/or axis labels are not aligned:

fig, ax = plt.subplots(2, 2)
ax[0, 0].set_title("Plot1")
axt = ax[0, 0].twiny()
ax[0, 0].plot([0, 1], [0, 1])
ax[0, 1].set_title("Plot2")
ax[0, 1].plot([0, 1], [0, 0])
ax[1, 0].set_ylabel("y1")
ax[1, 1].set_ylabel("y2")
ax[0, 0].set_panellabel("a")
ax[0, 1].set_panellabel("b")
ax[1, 0].set_panellabel("c")
ax[1, 1].set_panellabel("d")
fig.align_panellabels()
fig.tight_layout()

ex3

Default font size and font weight are controlled by the axes.panellabelsize and axes.panellabelweight rcParams, respectively.

Before I start polishing documentation, writing unit tests, etc. I would love some feedback!

PR Checklist

  • Has Pytest style unit tests
  • Code is Flake 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

@anntzer
Copy link
Contributor

anntzer commented Nov 25, 2019

There's already title("foo", loc="left") (which can be combined with a real centered title). Would it be possible to fold the functionality here with it?

@lschr
Copy link
Contributor Author

lschr commented Nov 25, 2019

There's already title("foo", loc="left") (which can be combined with a real centered title). Would it be possible to fold the functionality here with it?

I tried that in the beginning, but could find no way to align with the y axis label. Theoretically, one could apply the logic I implemented to the left title, but then the left title would be treated completely differently from the center and right titles. I would much rather prefer to add this new “panel label” (there may be a better name for it) to make it clear what it is intended for and that it will behave differently from the titles.

Edit:
To summarize what I found missing with title("foo", loc="left"):

  • it is aligned with the frame; cannot be changed to something else, like the left edge of the panel (i.e., y axis label)
  • it is not possible to align the titles of different panels, as in the last example I gave

@anntzer
Copy link
Contributor

anntzer commented Nov 25, 2019

seems fair.

@jklymak
Copy link
Member

jklymak commented Nov 26, 2019

I like the idea here. However I’d like to see a little more survey done on whether this is best practice. In particular I’m used to the a, b, c being inside the axes frame more often than not. I or aligned with the frame as @anntzer suggests. I find your examples above to be placed in a way that’s hard to read and looks strange to me so I’d like some argument that this is a good default.

@lschr
Copy link
Contributor Author

lschr commented Nov 26, 2019

I am a biophysics guy and virtually all I ever see is letters in the very top left corner outside the frame.
Some examples:

The list is endless. However, this may be different in different fields of science.

Maybe preset argument would be useful. preset="topleft" could behave as it does now, while
preset="inframe" could set v_align="frame", h_align="frame", fontdict={"horizontalalignment": "left", "verticalalignment": "top"} and some sensible padding.

@jklymak
Copy link
Member

jklymak commented Nov 28, 2019

Agreed this is consistent with how Nature and other journals do things. I am 95% convinced this is a reasonable idea. I think we may need to think about the "align" parameter and how to make it more consistent w/ the rest of matplotlib's anchoring paradigm. I'm not super comfortable with that paradigm personally, and it may not do what you need here, but maybe others should chime in: @ImportanceOfBeingErnest ? @timhoffm ?

Please do ping us if you do not get a lot of comments. This seems reasonable overall, so I think it should get some attention.

@jklymak jklymak added this to the v3.3.0 milestone Nov 28, 2019
@ImportanceOfBeingErnest
Copy link
Member

I totally see how this feature is useful. While I personally never had problems putting those labels where needed, either using ax.annotate or an AnchoredText, the correct usage of those is cumbersome and requires a good portion of knowledge. So I would consider this a nice-to-have feature.

It is definitely true that there is no canonical place for those labels (even for Journals, there is in my experience no standarized position, every one wants them at a different place it seems), and hence a solution needs to be somehow flexible - but that necessarily also makes it complicated.

Concerning the currently proposed solution I would mention that it would be nice if those labels do not slow down axes drawtime in case they are not set. Calculating a position for something that is not even shown and creating transform(s) for it seems unnecessary, and drawing the axes is pretty slow already.

Also, the interplay of this current propasal with layout managers is not too clear to me.

In general I do wonder if a flexible enough solution using AnchoredText can be built by setting the bbox (and its transform) at drawtime.

@timhoffm
Copy link
Member

timhoffm commented Dec 1, 2019

I don't have time to look into this thoroughly at the moment. So just some thought, not a full review:

  • The idea is reasonable and targets a real problem we don't have a good solution for a the moment.
  • This is similar to aligning labels https://matplotlib.org/devdocs/gallery/subplots_axes_and_figures/align_labels_demo.html. One should compare with that to get a consistent API.
    -h_align and v_align are conceptually different from what align parameters mean for single texts (but that align-relative-to-anchor concept is already a source of confusion; e.g. 14300).
  • Is it necessary to define the alignment per label, or could that simply be a parameter of align_panellabels?
  • Do we need dedicated panel labels, or would Axes.text() and a generic function align_texts(texts) be sufficient?

@tacaswell
Copy link
Member

I am a bit concerned about adding the API surface for this. I would rather see this sort of thing as a well-scoped helper function than as a new method.

See https://gist.github.com/tacaswell/9643166 for a worked example of a (possibly too naive) function that does something similar (defaults to inside the frame) with annotate.

@jklymak
Copy link
Member

jklymak commented Dec 3, 2019

Such a helper could be a method of figure rather than axes. It’s a bit annoying having to call this for each subplot, if the labeling is the same.

@lschr
Copy link
Contributor Author

lschr commented Dec 4, 2019

@ImportanceOfBeingErnest

Concerning the currently proposed solution I would mention that it would be nice if those labels do not slow down axes drawtime in case they are not set. Calculating a position for something that is not even shown and creating transform(s) for it seems unnecessary, and drawing the axes is pretty slow already.

I think that should be easy enough, like adding a check whether there is any text and whether it is
visible.

In general I do wonder if a flexible enough solution using AnchoredText can be built by setting the bbox (and its transform) at drawtime.

I'll have a look at that. What would be the advantage over setting the Text position at draw time?

@lschr
Copy link
Contributor Author

lschr commented Dec 4, 2019

@timhoffm

I basically copied the API and implementation from the axis labels.

  • h_align and v_align are conceptually different from what align parameters mean for single texts (but that align-relative-to-anchor concept is already a source of confusion; e.g. 14300).

I agree that this is not optimal. There are horizontalalignment and verticalalignment parameters that are passed to the Text instance and these “new” parameters that specify where to put to put the labels, which mean different things. Maybe h_anchor and v_anchor would be better?

  • Is it necessary to define the alignment per label, or could that simply be a parameter of align_panellabels?

There are reasons not to call align_panellabels, such as complex layouts with nested grids. Also, considering a grid with multiple rows and columns where titles are only set in the top row, it may be desirable to verticaly align the top panel labels with the titles, but all other panel labels with the frames to avoid empty spaces between panels.

For convenience however, an optional argument could be added to align_panellabels that sets alignment for all panels.

  • Do we need dedicated panel labels, or would Axes.text() and a generic function align_texts(texts) be sufficient?

I was not able to come up with such a thing, but matplotlib internals are quite new to me. The only solution I found was to modify draw to set the position of the label after the title and axis label positions have been set.

@lschr
Copy link
Contributor Author

lschr commented Dec 4, 2019

@tacaswell

I am a bit concerned about adding the API surface for this. I would rather see this sort of thing as a well-scoped helper function than as a new method.

See https://gist.github.com/tacaswell/9643166 for a worked example of a (possibly too naive) function that does something similar (defaults to inside the frame) with annotate.

It is quite easy to use text or annotate to put a label inside the frame, but I could not find a way to align it with axis labels and titles except to set the position in draw after title and axis positions have been set. That being said, I am new to matplotlib and this may not be the best solution.

@lschr
Copy link
Contributor Author

lschr commented Dec 4, 2019

@jklymak

Such a helper could be a method of figure rather than axes. It’s a bit annoying having to call this for each subplot, if the labeling is the same.

It would be easy to add a method to Figure. Something like

def set_panellabels(labels, axs=None, **kwargs):
    if axs is None:
        axs = self.get_axes()
    for ax, lab in zip(axs, labels):
        ax.set_panellabel(lab, **kwargs)

@tacaswell
Copy link
Member

https://matplotlib.org/tutorials/text/annotations.html#using-complex-coordinates-with-annotations has some examples of how to anchor annotations against other artists.

I see the benefit of aligning the panel labels with a variety of other things, but in the scope of one axes and between the different axes, however that also brings a tremendous amount of complexity in how to specify it....

@lschr
Copy link
Contributor Author

lschr commented Dec 8, 2019

@tacaswell Thanks for the hint. I guess this is what @ImportanceOfBeingErnest meant by setting the bbox at draw time. As time permits I will give it a try.

@tacaswell
Copy link
Member

This still seems like a good idea in general, but the implementation can likely be both simplified and made more robust, pushing to 3.4.0.

@lschr
Copy link
Contributor Author

lschr commented Apr 14, 2020

I have had no chance to work on this as I have spent most of my time writing my PhD thesis. But as soon as the thesis is finished (quite soon, I hope) I will come back to this.

@jklymak jklymak marked this pull request as draft April 27, 2020 16:32
Lukas Schrangl added 3 commits May 16, 2020 00:04
In scientific publications, sub-panels are often enumerated (a, b, c,
…), identifying them for reference in the figure legend or main text.

This adds support for such labels, plus various options for alignment.
Use Annotation instead of Text (makes padding easy), pass
_get_panellabel_bbox (calculate bounding box for anchoring) method as
xycoords argument.
Replace SubplotSpec.get_rows_columns by rowspan and colspan.
@lschr
Copy link
Contributor Author

lschr commented May 18, 2020

I reimplemented the code that calculates the label positions according to the suggestions by @ImportanceOfBeingErnest and @tacaswell (I think), which indeed seems like a nice improvement.

@lschr
Copy link
Contributor Author

lschr commented May 18, 2020

Since there were some proponents of not putting this into the Axes class, I also tried something different. I derived a PanelLabel class from Annotation

class PanelLabel(mtext.Annotation):
    def __init__(self, label, fontdict=None, h_align='axislabel',
                 v_align='title', pad=0., **kwargs):
        default = {
            'fontsize': rcParams['axes.panellabelsize'],
            'fontweight': rcParams['axes.panellabelweight'],
            'verticalalignment': 'baseline',
            'horizontalalignment': 'left'}
        if isinstance(pad, Number):
            pad = (pad,)*2
        super().__init__(label, (0.0, 1.0), pad, xycoords=self._get_bbox,
                         textcoords="offset points")
        self.update(default)
        if fontdict is not None:
            self.update(fontdict)
        self.update(kwargs)
        self._align = (h_align, v_align)
        self._align_x_grp = {self}
        self._align_y_grp = {self}
        self.set_clip_on(False)
        
    def _get_bbox(self, renderer):
        # Something very similar to Axes._get_panellabel_bbox
        ...

which can be used like

fig, ax = plt.subplots()
pl = ax.add_artist(PanelLabel("a"))

Furthermore,

def align_panellabels(pls):
    for pl in pls:
        pl._align_x_grp = set()
        pl._align_y_grp = set()
        ss = pl.axes.get_subplotspec()
        row0 = ss.rowspan.start
        col0 = ss.colspan.start
        for plc in pls:
            ssc = plc.axes.get_subplotspec()
            if ssc.colspan.start == col0:
                pl._align_x_grp.add(plc)
            if ssc.rowspan.start == row0:
                pl._align_y_grp.add(plc)

allows for alignment of labels using

align_panellabels(list_of_labels)

which could probably be improved to take a list of Axes as its parameter (and then find the PanelLabel child in each Axes) and further to take a Figure as its parameter (where it could go through all associated Axes).

I think, this would be a nice solution, although not really consistent with the rest of the Axes API, where pretty much everything is done via get_* and set_* methods.

@lschr
Copy link
Contributor Author

lschr commented May 24, 2020

@timhoffm @tacaswell @ImportanceOfBeingErnest @jklymak Any feedback or suggestions on how to proceed?

@jklymak
Copy link
Member

jklymak commented Oct 2, 2020

Sorry this fell off everyone's radar. I think this is still a reasonable idea.

I was thinking of this having the same level as figure. i.e. figure.subplot_label(ax=axs, **kwargs), where axs is a list of axes. This is entirely analogous to figure.colorbar. I imagine ax could default to None which would mean all the subplots in the default order.

@QuLogic QuLogic modified the milestones: v3.4.0, v3.5.0 Jan 21, 2021
@QuLogic QuLogic modified the milestones: v3.5.0, v3.6.0 Aug 23, 2021
@timhoffm timhoffm modified the milestones: v3.6.0, unassigned Apr 30, 2022
@story645 story645 modified the milestones: unassigned, needs sorting Oct 6, 2022
@anntzer
Copy link
Contributor

anntzer commented May 17, 2023

I'll close this because the feature request is being tracked at #20182, and also because I think a strategy using annotate() to get fixed offsets relative to axes corners (#25905, or the same example before that PR) would be much simpler than using groupers for alignment. Feel free to ping for reopen if you disagree.

@anntzer anntzer closed this May 17, 2023
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.

8 participants