diff --git a/doc/users/next_whats_new/suplabels.rst b/doc/users/next_whats_new/suplabels.rst new file mode 100644 index 000000000000..2d4de6c289ef --- /dev/null +++ b/doc/users/next_whats_new/suplabels.rst @@ -0,0 +1,19 @@ +supxlabel and supylabel +----------------------- + +It is possible to add x- and y-labels to a whole figure, analogous to +`.FigureBase.suptitle` using the new `.FigureBase.supxlabel` and +`.FigureBase.supylabel` methods. + +.. plot:: + + np.random.seed(19680801) + fig, axs = plt.subplots(3, 2, figsize=(5, 5), constrained_layout=True, + sharex=True, sharey=True) + + for nn, ax in enumerate(axs.flat): + ax.set_title(f'Channel {nn}') + ax.plot(np.cumsum(np.random.randn(50))) + + fig.supxlabel('Time [s]') + fig.supylabel('Data [V]') diff --git a/examples/subplots_axes_and_figures/figure_title.py b/examples/subplots_axes_and_figures/figure_title.py index 8118f9cabbd4..0b8a7e2c5855 100644 --- a/examples/subplots_axes_and_figures/figure_title.py +++ b/examples/subplots_axes_and_figures/figure_title.py @@ -1,12 +1,18 @@ """ -============ -Figure title -============ +============================================= +Figure labels: suptitle, supxlabel, supylabel +============================================= -Each subplot can have its own title (`.Axes.set_title`). Additionally, -`.Figure.suptitle` adds a centered title at the top of the figure. +Each axes can have a title (or actually three - one each with *loc* "left", +"center", and "right"), but is sometimes desirable to give a whole figure +(or `.SubFigure`) an overall title, using `.FigureBase.suptitle`. + +We can also add figure-level x- and y-labels using `.FigureBase.supxlabel` and +`.FigureBase.supylabel`. """ +from matplotlib.cbook import get_sample_data import matplotlib.pyplot as plt + import numpy as np @@ -24,4 +30,35 @@ fig.suptitle('Different types of oscillations', fontsize=16) +############################################################################## +# A global x- or y-label can be set using the `.FigureBase.supxlabel` and +# `.FigureBase.supylabel` methods. + +fig, axs = plt.subplots(3, 5, figsize=(8, 5), constrained_layout=True, + sharex=True, sharey=True) + +fname = get_sample_data('percent_bachelors_degrees_women_usa.csv', + asfileobj=False) +gender_degree_data = np.genfromtxt(fname, delimiter=',', names=True) + +majors = ['Health Professions', 'Public Administration', 'Education', + 'Psychology', 'Foreign Languages', 'English', + 'Art and Performance', 'Biology', + 'Agriculture', 'Business', + 'Math and Statistics', 'Architecture', 'Physical Sciences', + 'Computer Science', 'Engineering'] + +for nn, ax in enumerate(axs.flat): + ax.set_xlim(1969.5, 2011.1) + column = majors[nn] + column_rec_name = column.replace('\n', '_').replace(' ', '_') + + line, = ax.plot('Year', column_rec_name, data=gender_degree_data, + lw=2.5) + ax.set_title(column, fontsize='small', loc='left') + ax.set_ylim([0, 100]) + ax.grid() +fig.supxlabel('Year') +fig.supylabel('Percent Degrees Awarded To Women') + plt.show() diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index 0f601b3b2bf9..7ced20b3cf50 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -18,6 +18,7 @@ import numpy as np import matplotlib.cbook as cbook +import matplotlib.transforms as mtransforms _log = logging.getLogger(__name__) @@ -276,21 +277,35 @@ def _make_layout_margins(fig, renderer, *, w_pad=0, h_pad=0, def _make_margin_suptitles(fig, renderer, *, w_pad=0, h_pad=0): # Figure out how large the suptitle is and make the # top level figure margin larger. + + inv_trans_fig = fig.transFigure.inverted().transform_bbox + # get the h_pad and w_pad as distances in the local subfigure coordinates: + padbox = mtransforms.Bbox([[0, 0], [w_pad, h_pad]]) + padbox = (fig.transFigure - + fig.transSubfigure).transform_bbox(padbox) + h_pad_local = padbox.height + w_pad_local = padbox.width + for panel in fig.subfigs: _make_margin_suptitles(panel, renderer, w_pad=w_pad, h_pad=h_pad) if fig._suptitle is not None and fig._suptitle.get_in_layout(): - invTransFig = fig.transSubfigure.inverted().transform_bbox - parenttrans = fig.transFigure - w_pad, h_pad = (fig.transSubfigure - - parenttrans).transform((w_pad, 1 - h_pad)) - w_pad, one = (fig.transSubfigure - - parenttrans).transform((w_pad, 1)) - h_pad = one - h_pad - bbox = invTransFig(fig._suptitle.get_tightbbox(renderer)) p = fig._suptitle.get_position() - fig._suptitle.set_position((p[0], 1-h_pad)) - fig._layoutgrid.edit_margin_min('top', bbox.height + 2 * h_pad) + fig._suptitle.set_position((p[0], 1 - h_pad_local)) + bbox = inv_trans_fig(fig._suptitle.get_tightbbox(renderer)) + fig._layoutgrid.edit_margin_min('top', bbox.height + 2.0 * h_pad) + + if fig._supxlabel is not None and fig._supxlabel.get_in_layout(): + p = fig._supxlabel.get_position() + fig._supxlabel.set_position((p[0], h_pad_local)) + bbox = inv_trans_fig(fig._supxlabel.get_tightbbox(renderer)) + fig._layoutgrid.edit_margin_min('bottom', bbox.height + 2.0 * h_pad) + + if fig._supylabel is not None and fig._supxlabel.get_in_layout(): + p = fig._supylabel.get_position() + fig._supylabel.set_position((w_pad_local, p[1])) + bbox = inv_trans_fig(fig._supylabel.get_tightbbox(renderer)) + fig._layoutgrid.edit_margin_min('left', bbox.width + 2.0 * w_pad) def _match_submerged_margins(fig): diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 052599857cad..67436b15f444 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -233,6 +233,8 @@ def __init__(self): del self._axes self._suptitle = None + self._supxlabel = None + self._supylabel = None # constrained_layout: self._layoutgrid = None @@ -254,7 +256,6 @@ def __init__(self): self.images = [] self.legends = [] self.subfigs = [] - self._suptitle = None self.stale = True self.suppressComposite = None @@ -369,26 +370,26 @@ def get_window_extent(self, *args, **kwargs): """ return self.bbox - def suptitle(self, t, **kwargs): + def _suplabels(self, t, info, **kwargs): """ - Add a centered title to the figure. + Add a centered {name} to the figure. Parameters ---------- t : str - The title text. + The {name} text. - x : float, default: 0.5 + x : float, default: {x0} The x location of the text in figure coordinates. - y : float, default: 0.98 + y : float, default: {y0} The y location of the text in figure coordinates. - horizontalalignment, ha : {'center', 'left', right'}, default: 'center' + horizontalalignment, ha : {{'center', 'left', 'right'}}, default: {ha} The horizontal alignment of the text relative to (*x*, *y*). - verticalalignment, va : {'top', 'center', 'bottom', 'baseline'}, \ -default: 'top' + verticalalignment, va : {{'top', 'center', 'bottom', 'baseline'}}, \ +default: {va} The vertical alignment of the text relative to (*x*, *y*). fontsize, size : default: :rc:`figure.titlesize` @@ -401,8 +402,8 @@ def suptitle(self, t, **kwargs): Returns ------- - `.Text` - The instance of the title. + text + The `.Text` instance of the {name}. Other Parameters ---------------- @@ -415,19 +416,20 @@ def suptitle(self, t, **kwargs): **kwargs Additional kwargs are `matplotlib.text.Text` properties. - Examples - -------- - >>> fig.suptitle('This is the figure title', fontsize=12) """ + manual_position = ('x' in kwargs or 'y' in kwargs) + suplab = getattr(self, info['name']) - x = kwargs.pop('x', 0.5) - y = kwargs.pop('y', 0.98) + x = kwargs.pop('x', info['x0']) + y = kwargs.pop('y', info['y0']) if 'horizontalalignment' not in kwargs and 'ha' not in kwargs: - kwargs['horizontalalignment'] = 'center' + kwargs['horizontalalignment'] = info['ha'] if 'verticalalignment' not in kwargs and 'va' not in kwargs: - kwargs['verticalalignment'] = 'top' + kwargs['verticalalignment'] = info['va'] + if 'rotation' not in kwargs: + kwargs['rotation'] = info['rotation'] if 'fontproperties' not in kwargs: if 'fontsize' not in kwargs and 'size' not in kwargs: @@ -436,19 +438,46 @@ def suptitle(self, t, **kwargs): kwargs['weight'] = mpl.rcParams['figure.titleweight'] sup = self.text(x, y, t, **kwargs) - if self._suptitle is not None: - self._suptitle.set_text(t) - self._suptitle.set_position((x, y)) - self._suptitle.update_from(sup) + if suplab is not None: + suplab.set_text(t) + suplab.set_position((x, y)) + suplab.update_from(sup) sup.remove() else: - self._suptitle = sup - + suplab = sup if manual_position: - self._suptitle.set_in_layout(False) - + suplab.set_in_layout(False) + setattr(self, info['name'], suplab) self.stale = True - return self._suptitle + return suplab + + @docstring.Substitution(x0=0.5, y0=0.98, name='suptitle', ha='center', + va='top') + @docstring.copy(_suplabels) + def suptitle(self, t, **kwargs): + # docstring from _suplabels... + info = {'name': '_suptitle', 'x0': 0.5, 'y0': 0.98, + 'ha': 'center', 'va': 'top', 'rotation': 0} + return self._suplabels(t, info, **kwargs) + + @docstring.Substitution(x0=0.5, y0=0.01, name='supxlabel', ha='center', + va='bottom') + @docstring.copy(_suplabels) + def supxlabel(self, t, **kwargs): + # docstring from _suplabels... + info = {'name': '_supxlabel', 'x0': 0.5, 'y0': 0.01, + 'ha': 'center', 'va': 'bottom', 'rotation': 0} + return self._suplabels(t, info, **kwargs) + + @docstring.Substitution(x0=0.02, y0=0.5, name='supylabel', ha='left', + va='center') + @docstring.copy(_suplabels) + def supylabel(self, t, **kwargs): + # docstring from _suplabels... + info = {'name': '_supylabel', 'x0': 0.02, 'y0': 0.5, + 'ha': 'left', 'va': 'center', 'rotation': 'vertical', + 'rotation_mode': 'anchor'} + return self._suplabels(t, info, **kwargs) def get_edgecolor(self): """Get the edge color of the Figure rectangle.""" @@ -2814,6 +2843,9 @@ def clf(self, keep_observers=False): if not keep_observers: self._axobservers = cbook.CallbackRegistry() self._suptitle = None + self._supxlabel = None + self._supylabel = None + if self.get_constrained_layout(): self.init_layoutgrid() self.stale = True diff --git a/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure.png b/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure.png index 2e7e43ec6ba0..17dfea29d844 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure.png and b/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure_double.png b/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure_double.png new file mode 100644 index 000000000000..1a1ac9fd74f7 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure_double.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure_ss.png b/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure_ss.png index 62342ccf9591..d06a5db7a5dd 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure_ss.png and b/lib/matplotlib/tests/baseline_images/test_figure/test_subfigure_ss.png differ diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 44f094781a00..c5ab3cf6d232 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -838,7 +838,7 @@ def test_reused_gridspec(): savefig_kwarg={'facecolor': 'teal'}, remove_text=False) def test_subfigure(): - np.random.seed(19680808) + np.random.seed(19680801) fig = plt.figure(constrained_layout=True) sub = fig.subfigures(1, 2) @@ -862,7 +862,7 @@ def test_subfigure(): remove_text=False) def test_subfigure_ss(): # test assigning the subfigure via subplotspec - np.random.seed(19680808) + np.random.seed(19680801) fig = plt.figure(constrained_layout=True) gs = fig.add_gridspec(1, 2) @@ -879,3 +879,46 @@ def test_subfigure_ss(): ax.set_title('Axes') fig.suptitle('Figure suptitle', fontsize='xx-large') + + +@image_comparison(['test_subfigure_double.png'], style='mpl20', + savefig_kwarg={'facecolor': 'teal'}, + remove_text=False) +def test_subfigure_double(): + # test assigning the subfigure via subplotspec + np.random.seed(19680801) + + fig = plt.figure(constrained_layout=True, figsize=(10, 8)) + + fig.suptitle('fig') + + subfigs = fig.subfigures(1, 2, wspace=0.07) + + subfigs[0].set_facecolor('coral') + subfigs[0].suptitle('subfigs[0]') + + subfigs[1].set_facecolor('coral') + subfigs[1].suptitle('subfigs[1]') + + subfigsnest = subfigs[0].subfigures(2, 1, height_ratios=[1, 1.4]) + subfigsnest[0].suptitle('subfigsnest[0]') + subfigsnest[0].set_facecolor('r') + axsnest0 = subfigsnest[0].subplots(1, 2, sharey=True) + for ax in axsnest0: + fontsize = 12 + pc = ax.pcolormesh(np.random.randn(30, 30), vmin=-2.5, vmax=2.5) + ax.set_xlabel('x-label', fontsize=fontsize) + ax.set_ylabel('y-label', fontsize=fontsize) + ax.set_title('Title', fontsize=fontsize) + + subfigsnest[0].colorbar(pc, ax=axsnest0) + + subfigsnest[1].suptitle('subfigsnest[1]') + subfigsnest[1].set_facecolor('g') + axsnest1 = subfigsnest[1].subplots(3, 1, sharex=True) + for nn, ax in enumerate(axsnest1): + ax.set_ylabel(f'ylabel{nn}') + subfigsnest[1].supxlabel('supxlabel') + subfigsnest[1].supylabel('supylabel') + + axsRight = subfigs[1].subplots(2, 2) diff --git a/lib/matplotlib/tight_layout.py b/lib/matplotlib/tight_layout.py index df55005047f9..6afb55084772 100644 --- a/lib/matplotlib/tight_layout.py +++ b/lib/matplotlib/tight_layout.py @@ -108,20 +108,30 @@ def auto_adjust_subplotpars( if not margin_left: margin_left = (max(hspaces[:, 0].max(), 0) + pad_inches / fig_width_inch) + suplabel = fig._supylabel + if suplabel and suplabel.get_in_layout(): + rel_width = fig.transFigure.inverted().transform_bbox( + suplabel.get_window_extent(renderer)).width + margin_left += rel_width + pad_inches / fig_width_inch + if not margin_right: margin_right = (max(hspaces[:, -1].max(), 0) + pad_inches / fig_width_inch) if not margin_top: margin_top = (max(vspaces[0, :].max(), 0) + pad_inches / fig_height_inch) - suptitle = fig._suptitle - if suptitle and suptitle.get_in_layout(): - rel_suptitle_height = fig.transFigure.inverted().transform_bbox( - suptitle.get_window_extent(renderer)).height - margin_top += rel_suptitle_height + pad_inches / fig_height_inch + if fig._suptitle and fig._suptitle.get_in_layout(): + rel_height = fig.transFigure.inverted().transform_bbox( + fig._suptitle.get_window_extent(renderer)).height + margin_top += rel_height + pad_inches / fig_height_inch if not margin_bottom: margin_bottom = (max(vspaces[-1, :].max(), 0) + pad_inches / fig_height_inch) + suplabel = fig._supxlabel + if suplabel and suplabel.get_in_layout(): + rel_height = fig.transFigure.inverted().transform_bbox( + suplabel.get_window_extent(renderer)).height + margin_bottom += rel_height + pad_inches / fig_height_inch if margin_left + margin_right >= 1: cbook._warn_external('Tight layout not applied. The left and right '