Skip to content

Commit b5a9247

Browse files
committed
ENH: add supxlabel and supylabel to figure...
1 parent 48031d8 commit b5a9247

File tree

9 files changed

+205
-49
lines changed

9 files changed

+205
-49
lines changed
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
supxlabel and supylabel
2+
-----------------------
3+
4+
It is possible to add x and ylabels to a whole figure, analogous to
5+
`.FigureBase.suptitle` using the new `.FigureBase.supxlabel` and
6+
`.FigureBase.supylabel` methods.
7+
8+
.. plot::
9+
10+
np.random.seed(19680801)
11+
fig, axs = plt.subplots(3, 2, figsize=(5, 5), constrained_layout=True,
12+
sharex=True, sharey=True)
13+
14+
for nn, ax in enumerate(axs.flat):
15+
ax.set_title(f'Channel {nn}')
16+
ax.plot(np.cumsum(np.random.randn(50)))
17+
18+
fig.supxlabel('Time [s]')
19+
fig.supylabel('Data [V]')

examples/subplots_axes_and_figures/figure_title.py

+42-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
"""
2-
============
3-
Figure title
4-
============
2+
=============================================
3+
Figure labels: suptitle, supxlabel, supylabel
4+
=============================================
55
6-
Each subplot can have its own title (`.Axes.set_title`). Additionally,
7-
`.Figure.suptitle` adds a centered title at the top of the figure.
6+
Often it is desirable to have a title for a figure, while each subplot gets
7+
its own title using `.FigureBase.suptitle`.
8+
9+
We can also add figure-level x- and y-labels using `.FigureBase.supxlabel` and
10+
`.FigureBase.supylabel`.
811
"""
12+
from matplotlib.cbook import get_sample_data
913
import matplotlib.pyplot as plt
14+
1015
import numpy as np
1116

1217

@@ -24,4 +29,36 @@
2429

2530
fig.suptitle('Different types of oscillations', fontsize=16)
2631

32+
##############################################################################
33+
# Note it is also sometimes useful to have a global x- or y-label, which
34+
# can be achieved with the `.FigureBase.supxlabel` and `.FigureBase.supylabel`
35+
# methods.
36+
37+
fig, axs = plt.subplots(3, 5, figsize=(8, 5), constrained_layout=True,
38+
sharey=True, sharex=True)
39+
40+
fname = get_sample_data('percent_bachelors_degrees_women_usa.csv',
41+
asfileobj=False)
42+
gender_degree_data = np.genfromtxt(fname, delimiter=',', names=True)
43+
44+
majors = ['Health Professions', 'Public Administration', 'Education',
45+
'Psychology', 'Foreign Languages', 'English',
46+
'Art and Performance', 'Biology',
47+
'Agriculture', 'Business',
48+
'Math and Statistics', 'Architecture', 'Physical Sciences',
49+
'Computer Science', 'Engineering']
50+
51+
for nn, ax in enumerate(axs.flat):
52+
ax.set_xlim(1969.5, 2011.1)
53+
column = majors[nn]
54+
column_rec_name = column.replace('\n', '_').replace(' ', '_')
55+
56+
line, = ax.plot('Year', column_rec_name, data=gender_degree_data,
57+
lw=2.5)
58+
ax.set_title(column, fontsize='small', loc='left')
59+
ax.set_ylim([0, 100])
60+
ax.grid()
61+
fig.supxlabel('Year')
62+
fig.supylabel('Percent Degrees Awarded To Women')
63+
2764
plt.show()

lib/matplotlib/_constrained_layout.py

+25-10
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import numpy as np
1919

2020
import matplotlib.cbook as cbook
21+
import matplotlib.transforms as mtransforms
2122

2223
_log = logging.getLogger(__name__)
2324

@@ -276,21 +277,35 @@ def _make_layout_margins(fig, renderer, *, w_pad=0, h_pad=0,
276277
def _make_margin_suptitles(fig, renderer, *, w_pad=0, h_pad=0):
277278
# Figure out how large the suptitle is and make the
278279
# top level figure margin larger.
280+
281+
invTransFig = fig.transFigure.inverted().transform_bbox
282+
# get the h_pad and w_pad as distances in the local subfigure coordinates:
283+
padbox = mtransforms.Bbox([[0, 0], [w_pad, h_pad]])
284+
padbox = (fig.transFigure -
285+
fig.transSubfigure).transform_bbox(padbox)
286+
h_pad_local = padbox.height
287+
w_pad_local = padbox.width
288+
279289
for panel in fig.subfigs:
280290
_make_margin_suptitles(panel, renderer, w_pad=w_pad, h_pad=h_pad)
281291

282292
if fig._suptitle is not None and fig._suptitle.get_in_layout():
283-
invTransFig = fig.transSubfigure.inverted().transform_bbox
284-
parenttrans = fig.transFigure
285-
w_pad, h_pad = (fig.transSubfigure -
286-
parenttrans).transform((w_pad, 1 - h_pad))
287-
w_pad, one = (fig.transSubfigure -
288-
parenttrans).transform((w_pad, 1))
289-
h_pad = one - h_pad
290-
bbox = invTransFig(fig._suptitle.get_tightbbox(renderer))
291293
p = fig._suptitle.get_position()
292-
fig._suptitle.set_position((p[0], 1-h_pad))
293-
fig._layoutgrid.edit_margin_min('top', bbox.height + 2 * h_pad)
294+
fig._suptitle.set_position((p[0], 1 - h_pad_local))
295+
bbox = invTransFig(fig._suptitle.get_tightbbox(renderer)) # fig coords
296+
fig._layoutgrid.edit_margin_min('top', bbox.height + 2.0 * h_pad)
297+
298+
if fig._supxlabel is not None and fig._supxlabel.get_in_layout():
299+
p = fig._supxlabel.get_position()
300+
fig._supxlabel.set_position((p[0], h_pad_local))
301+
bbox = invTransFig(fig._supxlabel.get_tightbbox(renderer)) # fig coord
302+
fig._layoutgrid.edit_margin_min('bottom', bbox.height + 2.0 * h_pad)
303+
304+
if fig._supylabel is not None and fig._supxlabel.get_in_layout():
305+
p = fig._supylabel.get_position()
306+
fig._supylabel.set_position((w_pad_local, p[1]))
307+
bbox = invTransFig(fig._supylabel.get_tightbbox(renderer)) # fig coord
308+
fig._layoutgrid.edit_margin_min('left', bbox.width + 2.0 * w_pad)
294309

295310

296311
def _match_submerged_margins(fig):

lib/matplotlib/figure.py

+59-27
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ def __init__(self):
233233
del self._axes
234234

235235
self._suptitle = None
236+
self._supxlabel = None
237+
self._supylabel = None
236238

237239
# constrained_layout:
238240
self._layoutgrid = None
@@ -254,7 +256,6 @@ def __init__(self):
254256
self.images = []
255257
self.legends = []
256258
self.subfigs = []
257-
self._suptitle = None
258259
self.stale = True
259260
self.suppressComposite = None
260261

@@ -369,26 +370,26 @@ def get_window_extent(self, *args, **kwargs):
369370
"""
370371
return self.bbox
371372

372-
def suptitle(self, t, **kwargs):
373+
def _suplabels(self, t, info, **kwargs):
373374
"""
374-
Add a centered title to the figure.
375+
Add a centered {name} to the figure.
375376
376377
Parameters
377378
----------
378379
t : str
379-
The title text.
380+
The {name} text.
380381
381-
x : float, default: 0.5
382+
x : float, default: {x0}
382383
The x location of the text in figure coordinates.
383384
384-
y : float, default: 0.98
385+
y : float, default: {y0}
385386
The y location of the text in figure coordinates.
386387
387-
horizontalalignment, ha : {'center', 'left', right'}, default: 'center'
388+
horizontalalignment, ha : {{'center', 'left', 'right'}}, default: {ha}
388389
The horizontal alignment of the text relative to (*x*, *y*).
389390
390-
verticalalignment, va : {'top', 'center', 'bottom', 'baseline'}, \
391-
default: 'top'
391+
verticalalignment, va : {{'top', 'center', 'bottom', 'baseline'}}, \
392+
default: {va}
392393
The vertical alignment of the text relative to (*x*, *y*).
393394
394395
fontsize, size : default: :rc:`figure.titlesize`
@@ -401,8 +402,8 @@ def suptitle(self, t, **kwargs):
401402
402403
Returns
403404
-------
404-
`.Text`
405-
The instance of the title.
405+
text
406+
The `.Text` instance of the {name}.
406407
407408
Other Parameters
408409
----------------
@@ -415,19 +416,20 @@ def suptitle(self, t, **kwargs):
415416
**kwargs
416417
Additional kwargs are `matplotlib.text.Text` properties.
417418
418-
Examples
419-
--------
420-
>>> fig.suptitle('This is the figure title', fontsize=12)
421419
"""
420+
422421
manual_position = ('x' in kwargs or 'y' in kwargs)
422+
suplab = getattr(self, info['name'])
423423

424-
x = kwargs.pop('x', 0.5)
425-
y = kwargs.pop('y', 0.98)
424+
x = kwargs.pop('x', info['x0'])
425+
y = kwargs.pop('y', info['y0'])
426426

427427
if 'horizontalalignment' not in kwargs and 'ha' not in kwargs:
428-
kwargs['horizontalalignment'] = 'center'
428+
kwargs['horizontalalignment'] = info['ha']
429429
if 'verticalalignment' not in kwargs and 'va' not in kwargs:
430-
kwargs['verticalalignment'] = 'top'
430+
kwargs['verticalalignment'] = info['va']
431+
if 'rotation' not in kwargs:
432+
kwargs['rotation'] = info['rotation']
431433

432434
if 'fontproperties' not in kwargs:
433435
if 'fontsize' not in kwargs and 'size' not in kwargs:
@@ -436,19 +438,46 @@ def suptitle(self, t, **kwargs):
436438
kwargs['weight'] = mpl.rcParams['figure.titleweight']
437439

438440
sup = self.text(x, y, t, **kwargs)
439-
if self._suptitle is not None:
440-
self._suptitle.set_text(t)
441-
self._suptitle.set_position((x, y))
442-
self._suptitle.update_from(sup)
441+
if suplab is not None:
442+
suplab.set_text(t)
443+
suplab.set_position((x, y))
444+
suplab.update_from(sup)
443445
sup.remove()
444446
else:
445-
self._suptitle = sup
446-
447+
suplab = sup
447448
if manual_position:
448-
self._suptitle.set_in_layout(False)
449-
449+
suplab.set_in_layout(False)
450+
setattr(self, info['name'], suplab)
450451
self.stale = True
451-
return self._suptitle
452+
return suplab
453+
454+
@docstring.Substitution(x0=0.5, y0=0.98, name='suptitle', ha='center',
455+
va='top')
456+
@docstring.copy(_suplabels)
457+
def suptitle(self, t, **kwargs):
458+
# docstring from _suplabels...
459+
info = {'name': '_suptitle', 'x0': 0.5, 'y0': 0.98,
460+
'ha': 'center', 'va': 'top', 'rotation': 0}
461+
return self._suplabels(t, info, **kwargs)
462+
463+
@docstring.Substitution(x0=0.5, y0=0.01, name='supxlabel', ha='center',
464+
va='bottom')
465+
@docstring.copy(_suplabels)
466+
def supxlabel(self, t, **kwargs):
467+
# docstring from _suplabels...
468+
info = {'name': '_supxlabel', 'x0': 0.5, 'y0': 0.01,
469+
'ha': 'center', 'va': 'bottom', 'rotation': 0}
470+
return self._suplabels(t, info, **kwargs)
471+
472+
@docstring.Substitution(x0=0.02, y0=0.5, name='supylabel', ha='left',
473+
va='center')
474+
@docstring.copy(_suplabels)
475+
def supylabel(self, t, **kwargs):
476+
# docstring from _suplabels...
477+
info = {'name': '_supylabel', 'x0': 0.02, 'y0': 0.5,
478+
'ha': 'left', 'va': 'center', 'rotation': 'vertical',
479+
'rotation_mode': 'anchor'}
480+
return self._suplabels(t, info, **kwargs)
452481

453482
def get_edgecolor(self):
454483
"""Get the edge color of the Figure rectangle."""
@@ -2811,6 +2840,9 @@ def clf(self, keep_observers=False):
28112840
if not keep_observers:
28122841
self._axobservers = cbook.CallbackRegistry()
28132842
self._suptitle = None
2843+
self._supxlabel = None
2844+
self._supylabel = None
2845+
28142846
if self.get_constrained_layout():
28152847
self.init_layoutgrid()
28162848
self.stale = True

lib/matplotlib/tests/test_figure.py

+45-2
Original file line numberDiff line numberDiff line change
@@ -838,7 +838,7 @@ def test_reused_gridspec():
838838
savefig_kwarg={'facecolor': 'teal'},
839839
remove_text=False)
840840
def test_subfigure():
841-
np.random.seed(19680808)
841+
np.random.seed(19680801)
842842
fig = plt.figure(constrained_layout=True)
843843
sub = fig.subfigures(1, 2)
844844

@@ -862,7 +862,7 @@ def test_subfigure():
862862
remove_text=False)
863863
def test_subfigure_ss():
864864
# test assigning the subfigure via subplotspec
865-
np.random.seed(19680808)
865+
np.random.seed(19680801)
866866
fig = plt.figure(constrained_layout=True)
867867
gs = fig.add_gridspec(1, 2)
868868

@@ -879,3 +879,46 @@ def test_subfigure_ss():
879879
ax.set_title('Axes')
880880

881881
fig.suptitle('Figure suptitle', fontsize='xx-large')
882+
883+
884+
@image_comparison(['test_subfigure_double.png'], style='mpl20',
885+
savefig_kwarg={'facecolor': 'teal'},
886+
remove_text=False)
887+
def test_subfigure_double():
888+
# test assigning the subfigure via subplotspec
889+
np.random.seed(19680801)
890+
891+
fig = plt.figure(constrained_layout=True, figsize=(10, 8))
892+
893+
fig.suptitle('fig')
894+
895+
subfigs = fig.subfigures(1, 2, wspace=0.07)
896+
897+
subfigs[0].set_facecolor('coral')
898+
subfigs[0].suptitle('subfigs[0]')
899+
900+
subfigs[1].set_facecolor('coral')
901+
subfigs[1].suptitle('subfigs[1]')
902+
903+
subfigsnest = subfigs[0].subfigures(2, 1, height_ratios=[1, 1.4])
904+
subfigsnest[0].suptitle('subfigsnest[0]')
905+
subfigsnest[0].set_facecolor('r')
906+
axsnest0 = subfigsnest[0].subplots(1, 2, sharey=True)
907+
for ax in axsnest0:
908+
fontsize = 12
909+
pc = ax.pcolormesh(np.random.randn(30, 30), vmin=-2.5, vmax=2.5)
910+
ax.set_xlabel('x-label', fontsize=fontsize)
911+
ax.set_ylabel('y-label', fontsize=fontsize)
912+
ax.set_title('Title', fontsize=fontsize)
913+
914+
subfigsnest[0].colorbar(pc, ax=axsnest0)
915+
916+
subfigsnest[1].suptitle('subfigsnest[1]')
917+
subfigsnest[1].set_facecolor('g')
918+
axsnest1 = subfigsnest[1].subplots(3, 1, sharex=True)
919+
for nn, ax in enumerate(axsnest1):
920+
ax.set_ylabel(f'ylabel{nn}')
921+
subfigsnest[1].supxlabel('supxlabel')
922+
subfigsnest[1].supylabel('supylabel')
923+
924+
axsRight = subfigs[1].subplots(2, 2)

lib/matplotlib/tight_layout.py

+15-5
Original file line numberDiff line numberDiff line change
@@ -108,20 +108,30 @@ def auto_adjust_subplotpars(
108108
if not margin_left:
109109
margin_left = (max(hspaces[:, 0].max(), 0)
110110
+ pad_inches / fig_width_inch)
111+
suplabel = fig._supylabel
112+
if suplabel and suplabel.get_in_layout():
113+
rel_width = fig.transFigure.inverted().transform_bbox(
114+
suplabel.get_window_extent(renderer)).width
115+
margin_left += rel_width + pad_inches / fig_width_inch
116+
111117
if not margin_right:
112118
margin_right = (max(hspaces[:, -1].max(), 0)
113119
+ pad_inches / fig_width_inch)
114120
if not margin_top:
115121
margin_top = (max(vspaces[0, :].max(), 0)
116122
+ pad_inches / fig_height_inch)
117-
suptitle = fig._suptitle
118-
if suptitle and suptitle.get_in_layout():
119-
rel_suptitle_height = fig.transFigure.inverted().transform_bbox(
120-
suptitle.get_window_extent(renderer)).height
121-
margin_top += rel_suptitle_height + pad_inches / fig_height_inch
123+
if fig._suptitle and fig._suptitle.get_in_layout():
124+
rel_height = fig.transFigure.inverted().transform_bbox(
125+
fig._suptitle.get_window_extent(renderer)).height
126+
margin_top += rel_height + pad_inches / fig_height_inch
122127
if not margin_bottom:
123128
margin_bottom = (max(vspaces[-1, :].max(), 0)
124129
+ pad_inches / fig_height_inch)
130+
suplabel = fig._supxlabel
131+
if suplabel and suplabel.get_in_layout():
132+
rel_height = fig.transFigure.inverted().transform_bbox(
133+
suplabel.get_window_extent(renderer)).height
134+
margin_bottom += rel_height + pad_inches / fig_height_inch
125135

126136
if margin_left + margin_right >= 1:
127137
cbook._warn_external('Tight layout not applied. The left and right '

0 commit comments

Comments
 (0)