Skip to content

Commit ed65f68

Browse files
author
Lukas Schrangl
committed
Add support for (sub-) panel labels to Axes
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.
1 parent 6f4f19e commit ed65f68

File tree

4 files changed

+207
-0
lines changed

4 files changed

+207
-0
lines changed

lib/matplotlib/axes/_axes.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,88 @@ def set_title(self, label, fontdict=None, loc=None, pad=None,
190190
title.update(kwargs)
191191
return title
192192

193+
def get_panellabel(self):
194+
"""
195+
Get the (sub-) panel label text.
196+
197+
This is typically a letter (a, b, c, …) identifying the panel for
198+
reference in a figure legend.
199+
200+
Returns
201+
-------
202+
label : str
203+
The panel label string.
204+
205+
"""
206+
return self.panellabel.get_text()
207+
208+
def set_panellabel(self, label, fontdict=None, h_align='axislabel',
209+
v_align='title', pad=0., **kwargs):
210+
"""
211+
Set a (sub-) panel label.
212+
213+
This is typically a letter (a, b, c, …) identifying the panel for
214+
reference in a figure legend.
215+
216+
Parameters
217+
----------
218+
label : str
219+
Text to use for the label
220+
fontdict : dict, optional
221+
A dictionary controlling the appearance of the label text,
222+
the default `fontdict` is::
223+
224+
{'fontsize': rcParams['axes.panellabelsize'],
225+
'fontweight' : rcParams['axes.panellabelweight'],
226+
'verticalalignment': 'baseline',
227+
'horizontalalignment': 'left'}
228+
229+
v_align : {'top', 'title', 'axislabel', 'frame'}, optional
230+
Set vertical alignment of the label. If 'top', align to top
231+
of the title. If 'title', align to baseline of the title. If
232+
'axislabel', align to top of the x axis label (only useful if
233+
x axis is on top). If 'frame', align to the frame.
234+
Defaults to 'title'.
235+
h_align : {'axislabel', 'frame'}, optional
236+
Set vertical alignment of the label. If
237+
'axislabel', align to left edge of the y axis label. If 'frame',
238+
align to the frame. Defaults to 'axislabel'.
239+
pad : float or pair of float, optional
240+
The offset of the label from the left and top of the axes, in
241+
points. If only one number is given, it will be used for both
242+
dimensions. Default is 0.
243+
244+
Returns
245+
-------
246+
text : :class:`~matplotlib.text.Text`
247+
The matplotlib text instance representing the panel label
248+
249+
Other Parameters
250+
----------------
251+
**kwargs : `~matplotlib.text.Text` properties
252+
Other keyword arguments are text properties, see
253+
:class:`~matplotlib.text.Text` for a list of valid text
254+
properties.
255+
"""
256+
default = {
257+
'fontsize': rcParams['axes.panellabelsize'],
258+
'fontweight': rcParams['axes.panellabelweight'],
259+
'verticalalignment': 'baseline',
260+
'horizontalalignment': 'left'}
261+
self.panellabel.set_text(label)
262+
self.panellabel.update(default)
263+
if fontdict is not None:
264+
self.panellabel.update(fontdict)
265+
self.panellabel.update(kwargs)
266+
self._panellabel_align = (h_align, v_align)
267+
if isinstance(pad, Number):
268+
pad = (pad, pad)
269+
t = mtransforms.ScaledTranslation(
270+
pad[0] / 72, pad[1] / 72, self.figure.dpi_scale_trans)
271+
self.panellabel.set_transform(self.transAxes + t)
272+
self.panellabel.set_clip_box(None)
273+
return self.panellabel
274+
193275
def get_xlabel(self):
194276
"""
195277
Get the xlabel text string.

lib/matplotlib/axes/_base.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,14 @@ def cla(self):
10701070
for _title in (self.title, self._left_title, self._right_title):
10711071
self._set_artist_props(_title)
10721072

1073+
self.panellabel = mtext.Text(
1074+
x=0.0, y=1.0, text='',
1075+
transform=self.transAxes,
1076+
)
1077+
self._panellabel_align = ('axislabel', 'title')
1078+
self._autopanellabelpos = None
1079+
self._set_artist_props(self.panellabel)
1080+
10731081
# The patch draws the background of the axes. We want this to be below
10741082
# the other artists. We use the frame to draw the edges so we are
10751083
# setting the edgecolor to None.
@@ -2614,6 +2622,57 @@ def _update_title_position(self, renderer):
26142622
x, _ = title.get_position()
26152623
title.set_position((x, ymax))
26162624

2625+
def _update_panellabel_position(self, renderer):
2626+
"""
2627+
Update the panel label position based title and axis label positions.
2628+
"""
2629+
if self._autopanellabelpos is not None and not self._autopanellabelpos:
2630+
_log.debug(
2631+
'panel label position was updated manually, not adjusting')
2632+
return
2633+
2634+
if self._autopanellabelpos is None:
2635+
pp = self.panellabel.get_position()
2636+
if not np.allclose(pp, (0.0, 1.0)):
2637+
self._autotitlepos = False
2638+
_log.debug('not adjusting title pos because a title was'
2639+
' already placed manually: (%f, %f)', *pp)
2640+
return
2641+
self._autopanellabelpos = True
2642+
2643+
panel_x = math.inf
2644+
for axx in self.figure._align_panellabel_x_grp.get_siblings(self):
2645+
if self._panellabel_align[0] == 'frame':
2646+
other_x = axx.get_window_extent(renderer).xmin
2647+
else:
2648+
try:
2649+
other_x = axx.yaxis.get_tightbbox(renderer).xmin
2650+
except AttributeError:
2651+
# axx.yaxis.get_tightbbox() returned None
2652+
other_x = axx.get_window_extent(renderer).xmin
2653+
panel_x = min(panel_x, other_x)
2654+
panel_y = -math.inf
2655+
for axy in self.figure._align_panellabel_y_grp.get_siblings(self):
2656+
if self._panellabel_align[1] == 'frame':
2657+
other_y = axy.get_window_extent(renderer).ymax
2658+
elif self._panellabel_align[1] == 'axislabel':
2659+
try:
2660+
other_y = axy.xaxis.get_tightbbox(renderer).ymax
2661+
except AttributeError:
2662+
# axy.xaxis.get_tightbbox() returned None
2663+
other_y = axy.get_window_extent(renderer).ymax
2664+
elif self._panellabel_align[1] == 'top':
2665+
other_y = axy.title.get_tightbbox(renderer).ymax
2666+
else:
2667+
t = axy.title
2668+
tpos = t.get_position()
2669+
other_y = t.get_transform().transform(tpos)[1]
2670+
panel_y = max(panel_y, other_y)
2671+
2672+
inv_panel_trafo = self.transAxes.inverted()
2673+
self.panellabel.set_position(
2674+
inv_panel_trafo.transform((panel_x, panel_y)))
2675+
26172676
# Drawing
26182677
@martist.allow_rasterization
26192678
def draw(self, renderer=None, inframe=False):
@@ -2651,6 +2710,7 @@ def draw(self, renderer=None, inframe=False):
26512710
artists.remove(spine)
26522711

26532712
self._update_title_position(renderer)
2713+
self._update_panellabel_position(renderer)
26542714

26552715
if not self.axison or inframe:
26562716
for _axis in self._get_axis_list():
@@ -2660,6 +2720,7 @@ def draw(self, renderer=None, inframe=False):
26602720
artists.remove(self.title)
26612721
artists.remove(self._left_title)
26622722
artists.remove(self._right_title)
2723+
artists.remove(self.panellabel)
26632724

26642725
if not self.figure.canvas.is_saving():
26652726
artists = [a for a in artists
@@ -2693,6 +2754,7 @@ def draw(self, renderer=None, inframe=False):
26932754
mimage._draw_list_compositing_images(renderer, self, artists)
26942755

26952756
renderer.close_group('axes')
2757+
26962758
self.stale = False
26972759

26982760
def draw_artist(self, a):
@@ -4271,6 +4333,7 @@ def get_children(self):
42714333
*self.child_axes,
42724334
*([self.legend_] if self.legend_ is not None else []),
42734335
self.patch,
4336+
self.panellabel,
42744337
]
42754338

42764339
def contains(self, mouseevent):
@@ -4372,6 +4435,7 @@ def get_tightbbox(self, renderer, call_axes_locator=True,
43724435
bb.append(bb_yaxis)
43734436

43744437
self._update_title_position(renderer)
4438+
self._update_panellabel_position(renderer)
43754439
axbbox = self.get_window_extent(renderer)
43764440
bb.append(axbbox)
43774441

lib/matplotlib/figure.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,9 @@ def __init__(self,
369369
self._align_xlabel_grp = cbook.Grouper()
370370
self._align_ylabel_grp = cbook.Grouper()
371371

372+
self._align_panellabel_x_grp = cbook.Grouper()
373+
self._align_panellabel_y_grp = cbook.Grouper()
374+
372375
# list of child gridspecs for this figure
373376
self._gridspecs = []
374377

@@ -2637,6 +2640,62 @@ def align_labels(self, axs=None):
26372640
self.align_xlabels(axs=axs)
26382641
self.align_ylabels(axs=axs)
26392642

2643+
def align_panellabels(self, axs=None):
2644+
"""
2645+
Align the panel labels of subplots if label alignment is being done
2646+
automatically (i.e. the label position is not manually set).
2647+
2648+
Alignment persists for draw events after this is called.
2649+
2650+
Labels in the same column are moved horizontally to the position of
2651+
the left-most label. Labels an the same row are moved vertically to
2652+
the position of the top-most label.
2653+
2654+
Parameters
2655+
----------
2656+
axs : list of `~matplotlib.axes.Axes`
2657+
Optional list (or ndarray) of `~matplotlib.axes.Axes`
2658+
to align the ylabels.
2659+
Default is to align all axes on the figure.
2660+
2661+
Notes
2662+
-----
2663+
This assumes that ``axs`` are from the same `.GridSpec`, so that
2664+
their `.SubplotSpec` positions correspond to figure positions.
2665+
2666+
Examples
2667+
--------
2668+
Example with missing y-label::
2669+
2670+
fig, axs = plt.subplots(2, 1)
2671+
axs[0].set_ylabel('YLabel')
2672+
axs[0].set_panellabel("a")
2673+
axs[1].set_panellabel("b")
2674+
fig.align_panellabels()
2675+
2676+
"""
2677+
if axs is None:
2678+
axs = self.axes
2679+
axs = np.asarray(axs).ravel()
2680+
for ax in axs:
2681+
ss = ax.get_subplotspec()
2682+
nrows, ncols, row0, row1, col0, col1 = ss.get_rows_columns()
2683+
# loop through other axes and search ones that share the
2684+
# appropriate column or row number.
2685+
# Add to a list associated with each axes of siblings.
2686+
# This list is inspected in `Axes.draw` by
2687+
# `axis._update_panellabel_position`.
2688+
for axc in axs:
2689+
if axc is ax:
2690+
continue
2691+
ss = axc.get_subplotspec()
2692+
nrows, ncols, rowc0, rowc1, colc0, colc1 = \
2693+
ss.get_rows_columns()
2694+
if colc0 == col0:
2695+
self._align_panellabel_x_grp.join(ax, axc)
2696+
if row0 == rowc0:
2697+
self._align_panellabel_y_grp.join(ax, axc)
2698+
26402699
def add_gridspec(self, nrows, ncols, **kwargs):
26412700
"""
26422701
Return a `.GridSpec` that has this figure as a parent. This allows

lib/matplotlib/rcsetup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,8 @@ def _validate_linestyle(ls):
11951195
'axes.titleweight': ['normal', validate_fontweight], # font weight of axes title
11961196
'axes.titlecolor': ['auto', validate_color_or_auto], # font color of axes title
11971197
'axes.titlepad': [6.0, validate_float], # pad from axes top to title in points
1198+
'axes.panellabelsize': ['x-large', validate_fontsize], # fontsize of the panel label
1199+
'axes.panellabelweight': ['bold', validate_fontweight], # font weight of panel label
11981200
'axes.grid': [False, validate_bool], # display grid or not
11991201
'axes.grid.which': ['major', validate_axis_locator], # set whether the gid are by
12001202
# default draw on 'major'

0 commit comments

Comments
 (0)