diff --git a/doc/api/figure_api.rst b/doc/api/figure_api.rst index 937020afd8fc..2371e5a9a863 100644 --- a/doc/api/figure_api.rst +++ b/doc/api/figure_api.rst @@ -71,6 +71,7 @@ Annotating Figure.align_labels Figure.align_xlabels Figure.align_ylabels + Figure.align_titles Figure.autofmt_xdate @@ -264,6 +265,7 @@ Annotating SubFigure.align_labels SubFigure.align_xlabels SubFigure.align_ylabels + SubFigure.align_titles Adding and getting Artists -------------------------- diff --git a/doc/users/next_whats_new/figure_align_titles.rst b/doc/users/next_whats_new/figure_align_titles.rst new file mode 100644 index 000000000000..230e5f0a8990 --- /dev/null +++ b/doc/users/next_whats_new/figure_align_titles.rst @@ -0,0 +1,7 @@ +subplot titles can now be automatically aligned +----------------------------------------------- + +Subplot axes titles can be misaligned vertically if tick labels or +xlabels are placed at the top of one subplot. The new method on the +`.Figure` class: `.Figure.align_titles` will now align the titles +vertically. diff --git a/galleries/examples/subplots_axes_and_figures/align_labels_demo.py b/galleries/examples/subplots_axes_and_figures/align_labels_demo.py index 88f443ca0076..4935878ee027 100644 --- a/galleries/examples/subplots_axes_and_figures/align_labels_demo.py +++ b/galleries/examples/subplots_axes_and_figures/align_labels_demo.py @@ -1,37 +1,43 @@ """ -=============== -Aligning Labels -=============== +========================== +Aligning Labels and Titles +========================== -Aligning xlabel and ylabel using `.Figure.align_xlabels` and -`.Figure.align_ylabels` +Aligning xlabel, ylabel, and title using `.Figure.align_xlabels`, +`.Figure.align_ylabels`, and `.Figure.align_titles`. -`.Figure.align_labels` wraps these two functions. +`.Figure.align_labels` wraps the x and y label functions. Note that the xlabel "XLabel1 1" would normally be much closer to the -x-axis, and "YLabel1 0" would be much closer to the y-axis of their -respective axes. +x-axis, "YLabel0 0" would be much closer to the y-axis, and title +"Title0 0" would be much closer to the top of their respective axes. """ import matplotlib.pyplot as plt import numpy as np -import matplotlib.gridspec as gridspec +fig, axs = plt.subplots(2, 2, layout='constrained') -fig = plt.figure(tight_layout=True) -gs = gridspec.GridSpec(2, 2) - -ax = fig.add_subplot(gs[0, :]) +ax = axs[0][0] ax.plot(np.arange(0, 1e6, 1000)) -ax.set_ylabel('YLabel0') -ax.set_xlabel('XLabel0') +ax.set_title('Title0 0') +ax.set_ylabel('YLabel0 0') + +ax = axs[0][1] +ax.plot(np.arange(1., 0., -0.1) * 2000., np.arange(1., 0., -0.1)) +ax.set_title('Title0 1') +ax.xaxis.tick_top() +ax.tick_params(axis='x', rotation=55) + for i in range(2): - ax = fig.add_subplot(gs[1, i]) + ax = axs[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('XLabel1 %d' % i) if i == 0: ax.tick_params(axis='x', rotation=55) + fig.align_labels() # same as fig.align_xlabels(); fig.align_ylabels() +fig.align_titles() plt.show() diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 23cc1c869c07..0164f4e11169 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2985,8 +2985,13 @@ def _update_title_position(self, renderer): titles = (self.title, self._left_title, self._right_title) - # Need to check all our twins too, and all the children as well. - axs = self._twinned_axes.get_siblings(self) + self.child_axes + # Need to check all our twins too, aligned axes, and all the children + # as well. + axs = set() + axs.update(self.child_axes) + axs.update(self._twinned_axes.get_siblings(self)) + axs.update(self.figure._align_label_groups['title'].get_siblings(self)) + for ax in self.child_axes: # Child positions must be updated first. locator = ax.get_axes_locator() ax.apply_aspect(locator(self, renderer) if locator else None) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 087c193d48c3..0a0ff01a2571 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -132,10 +132,14 @@ def __init__(self, **kwargs): self._supxlabel = None self._supylabel = None - # groupers to keep track of x and y labels we want to align. - # see self.align_xlabels and self.align_ylabels and - # axis._get_tick_boxes_siblings - self._align_label_groups = {"x": cbook.Grouper(), "y": cbook.Grouper()} + # groupers to keep track of x, y labels and title we want to align. + # see self.align_xlabels, self.align_ylabels, + # self.align_titles, and axis._get_tick_boxes_siblings + self._align_label_groups = { + "x": cbook.Grouper(), + "y": cbook.Grouper(), + "title": cbook.Grouper() + } self._localaxes = [] # track all Axes self.artists = [] @@ -1293,7 +1297,7 @@ def subplots_adjust(self, left=None, bottom=None, right=None, top=None, def align_xlabels(self, axs=None): """ - Align the xlabels of subplots in the same subplot column if label + Align the xlabels of subplots in the same subplot row if label alignment is being done automatically (i.e. the label position is not manually set). @@ -1314,6 +1318,7 @@ def align_xlabels(self, axs=None): See Also -------- matplotlib.figure.Figure.align_ylabels + matplotlib.figure.Figure.align_titles matplotlib.figure.Figure.align_labels Notes @@ -1375,6 +1380,7 @@ def align_ylabels(self, axs=None): See Also -------- matplotlib.figure.Figure.align_xlabels + matplotlib.figure.Figure.align_titles matplotlib.figure.Figure.align_labels Notes @@ -1412,6 +1418,53 @@ def align_ylabels(self, axs=None): # grouper for groups of ylabels to align self._align_label_groups['y'].join(ax, axc) + def align_titles(self, axs=None): + """ + Align the titles of subplots in the same subplot row if title + alignment is being done automatically (i.e. the title position is + not manually set). + + Alignment persists for draw events after this is called. + + Parameters + ---------- + axs : list of `~matplotlib.axes.Axes` + Optional list of (or ndarray) `~matplotlib.axes.Axes` + to align the titles. + Default is to align all Axes on the figure. + + See Also + -------- + matplotlib.figure.Figure.align_xlabels + matplotlib.figure.Figure.align_ylabels + matplotlib.figure.Figure.align_labels + + Notes + ----- + This assumes that ``axs`` are from the same `.GridSpec`, so that + their `.SubplotSpec` positions correspond to figure positions. + + Examples + -------- + Example with titles:: + + fig, axs = plt.subplots(1, 2) + axs[0].set_aspect('equal') + axs[0].set_title('Title 0') + axs[1].set_title('Title 1') + fig.align_titles() + """ + if axs is None: + axs = self.axes + axs = [ax for ax in np.ravel(axs) if ax.get_subplotspec() is not None] + for ax in axs: + _log.debug(' Working on: %s', ax.get_title()) + rowspan = ax.get_subplotspec().rowspan + for axc in axs: + rowspanc = axc.get_subplotspec().rowspan + if (rowspan.start == rowspanc.start): + self._align_label_groups['title'].join(ax, axc) + def align_labels(self, axs=None): """ Align the xlabels and ylabels of subplots with the same subplots @@ -1430,8 +1483,8 @@ def align_labels(self, axs=None): See Also -------- matplotlib.figure.Figure.align_xlabels - matplotlib.figure.Figure.align_ylabels + matplotlib.figure.Figure.align_titles """ self.align_xlabels(axs=axs) self.align_ylabels(axs=axs) diff --git a/lib/matplotlib/figure.pyi b/lib/matplotlib/figure.pyi index 687ae9e500d0..eae21c2614f0 100644 --- a/lib/matplotlib/figure.pyi +++ b/lib/matplotlib/figure.pyi @@ -161,6 +161,7 @@ class FigureBase(Artist): ) -> None: ... def align_xlabels(self, axs: Iterable[Axes] | None = ...) -> None: ... def align_ylabels(self, axs: Iterable[Axes] | None = ...) -> None: ... + def align_titles(self, axs: Iterable[Axes] | None = ...) -> None: ... def align_labels(self, axs: Iterable[Axes] | None = ...) -> None: ... def add_gridspec(self, nrows: int = ..., ncols: int = ..., **kwargs) -> GridSpec: ... @overload diff --git a/lib/matplotlib/tests/baseline_images/test_figure/figure_align_titles_constrained.png b/lib/matplotlib/tests/baseline_images/test_figure/figure_align_titles_constrained.png new file mode 100644 index 000000000000..78dffc18e20c Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_figure/figure_align_titles_constrained.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_figure/figure_align_titles_tight.png b/lib/matplotlib/tests/baseline_images/test_figure/figure_align_titles_tight.png new file mode 100644 index 000000000000..f719ae6931f0 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_figure/figure_align_titles_tight.png differ diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 8ee6aae99361..58aecd3dea8b 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -66,6 +66,32 @@ def test_align_labels(): fig.align_labels() +@image_comparison(['figure_align_titles_tight.png', + 'figure_align_titles_constrained.png'], + tol=0 if platform.machine() == 'x86_64' else 0.022, + style='mpl20') +def test_align_titles(): + for layout in ['tight', 'constrained']: + fig, axs = plt.subplots(1, 2, layout=layout, width_ratios=[2, 1]) + + ax = axs[0] + ax.plot(np.arange(0, 1e6, 1000)) + ax.set_title('Title0 left', loc='left') + ax.set_title('Title0 center', loc='center') + ax.set_title('Title0 right', loc='right') + + ax = axs[1] + ax.plot(np.arange(0, 1e4, 100)) + ax.set_title('Title1') + ax.set_xlabel('Xlabel0') + ax.xaxis.set_label_position("top") + ax.xaxis.tick_top() + for tick in ax.get_xticklabels(): + tick.set_rotation(90) + + fig.align_titles() + + def test_align_labels_stray_axes(): fig, axs = plt.subplots(2, 2) for nn, ax in enumerate(axs.flat):