diff --git a/doc/users/next_whats_new/2017-11-1_figure_align_labels.rst b/doc/users/next_whats_new/2017-11-1_figure_align_labels.rst new file mode 100644 index 000000000000..8128512c1576 --- /dev/null +++ b/doc/users/next_whats_new/2017-11-1_figure_align_labels.rst @@ -0,0 +1,37 @@ +xlabels and ylabels can now be automatically aligned +---------------------------------------------------- + +Subplot axes ``ylabels`` can be misaligned horizontally if the tick labels +are very different widths. The same can happen to ``xlabels`` if the +ticklabels are rotated on one subplot (for instance). The new methods +on the `Figure` class: `Figure.align_xlabels` and `Figure.align_ylabels` +will now align these labels horizontally or vertically. If the user only +wants to align some axes, a list of axes can be passed. If no list is +passed, the algorithm looks at all the labels on the figure. + +Only labels that have the same subplot locations are aligned. i.e. the +ylabels are aligned only if the subplots are in the same column of the +subplot layout. + +Alignemnt is persistent and automatic after these are called. + +A convenience wrapper `Figure.align_labels` calls both functions at once. + +.. plot:: + + import matplotlib.gridspec as gridspec + + fig = plt.figure(figsize=(5, 3), tight_layout=True) + gs = gridspec.GridSpec(2, 2) + + ax = fig.add_subplot(gs[0,:]) + ax.plot(np.arange(0, 1e6, 1000)) + ax.set_ylabel('Test') + for i in range(2): + ax = fig.add_subplot(gs[1, i]) + ax.set_ylabel('Booooo') + ax.set_xlabel('Hello') + if i == 0: + for tick in ax.get_xticklabels(): + tick.set_rotation(45) + fig.align_labels() diff --git a/examples/subplots_axes_and_figures/align_labels_demo.py b/examples/subplots_axes_and_figures/align_labels_demo.py new file mode 100644 index 000000000000..bd574156a506 --- /dev/null +++ b/examples/subplots_axes_and_figures/align_labels_demo.py @@ -0,0 +1,37 @@ +""" +=============== +Aligning Labels +=============== + +Aligning xlabel and ylabel using `Figure.align_xlabels` and +`Figure.align_ylabels` + +`Figure.align_labels` wraps these two 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. +""" +import matplotlib.pyplot as plt +import numpy as np +import matplotlib.gridspec as gridspec + +fig = plt.figure(tight_layout=True) +gs = gridspec.GridSpec(2, 2) + +ax = fig.add_subplot(gs[0, :]) +ax.plot(np.arange(0, 1e6, 1000)) +ax.set_ylabel('YLabel0') +ax.set_xlabel('XLabel0') + +for i in range(2): + ax = fig.add_subplot(gs[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: + for tick in ax.get_xticklabels(): + tick.set_rotation(55) +fig.align_labels() # same as fig.align_xlabels(); fig.align_ylabels() + +plt.show() diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index ac6bf3425c41..47caa380279f 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -1113,10 +1113,12 @@ def get_tightbbox(self, renderer): return ticks_to_draw = self._update_ticks(renderer) - ticklabelBoxes, ticklabelBoxes2 = self._get_tick_bboxes(ticks_to_draw, - renderer) - self._update_label_position(ticklabelBoxes, ticklabelBoxes2) + self._update_label_position(renderer) + + # go back to just this axis's tick labels + ticklabelBoxes, ticklabelBoxes2 = self._get_tick_bboxes( + ticks_to_draw, renderer) self._update_offset_text_position(ticklabelBoxes, ticklabelBoxes2) self.offsetText.set_text(self.major.formatter.get_offset()) @@ -1167,7 +1169,7 @@ def draw(self, renderer, *args, **kwargs): # *copy* of the axis label box because we don't wan't to scale # the actual bbox - self._update_label_position(ticklabelBoxes, ticklabelBoxes2) + self._update_label_position(renderer) self.label.draw(renderer) @@ -1670,7 +1672,16 @@ def set_ticks(self, ticks, minor=False): self.set_major_locator(mticker.FixedLocator(ticks)) return self.get_major_ticks(len(ticks)) - def _update_label_position(self, bboxes, bboxes2): + def _get_tick_boxes_siblings(self, xdir, renderer): + """ + Get the bounding boxes for this `.axis` and its siblings + as set by `.Figure.align_xlabels` or `.Figure.align_ylablels`. + + By default it just gets bboxes for self. + """ + raise NotImplementedError('Derived must override') + + def _update_label_position(self, renderer): """ Update the label position based on the bounding box enclosing all the ticklabels and axis spine @@ -1846,13 +1857,37 @@ def set_label_position(self, position): self.label_position = position self.stale = True - def _update_label_position(self, bboxes, bboxes2): + def _get_tick_boxes_siblings(self, renderer): + """ + Get the bounding boxes for this `.axis` and its siblings + as set by `.Figure.align_xlabels` or `.Figure.align_ylablels`. + + By default it just gets bboxes for self. + """ + bboxes = [] + bboxes2 = [] + # get the Grouper that keeps track of x-label groups for this figure + grp = self.figure._align_xlabel_grp + # if we want to align labels from other axes: + for nn, axx in enumerate(grp.get_siblings(self.axes)): + ticks_to_draw = axx.xaxis._update_ticks(renderer) + tlb, tlb2 = axx.xaxis._get_tick_bboxes(ticks_to_draw, renderer) + bboxes.extend(tlb) + bboxes2.extend(tlb2) + return bboxes, bboxes2 + + def _update_label_position(self, renderer): """ Update the label position based on the bounding box enclosing all the ticklabels and axis spine """ if not self._autolabelpos: return + + # get bounding boxes for this axis and any siblings + # that have been set by `fig.align_xlabels()` + bboxes, bboxes2 = self._get_tick_boxes_siblings(renderer=renderer) + x, y = self.label.get_position() if self.label_position == 'bottom': try: @@ -2191,13 +2226,37 @@ def set_label_position(self, position): self.label_position = position self.stale = True - def _update_label_position(self, bboxes, bboxes2): + def _get_tick_boxes_siblings(self, renderer): + """ + Get the bounding boxes for this `.axis` and its siblings + as set by `.Figure.align_xlabels` or `.Figure.align_ylablels`. + + By default it just gets bboxes for self. + """ + bboxes = [] + bboxes2 = [] + # get the Grouper that keeps track of y-label groups for this figure + grp = self.figure._align_ylabel_grp + # if we want to align labels from other axes: + for axx in grp.get_siblings(self.axes): + ticks_to_draw = axx.yaxis._update_ticks(renderer) + tlb, tlb2 = axx.yaxis._get_tick_bboxes(ticks_to_draw, renderer) + bboxes.extend(tlb) + bboxes2.extend(tlb2) + return bboxes, bboxes2 + + def _update_label_position(self, renderer): """ Update the label position based on the bounding box enclosing all the ticklabels and axis spine """ if not self._autolabelpos: return + + # get bounding boxes for this axis and any siblings + # that have been set by `fig.align_ylabels()` + bboxes, bboxes2 = self._get_tick_boxes_siblings(renderer=renderer) + x, y = self.label.get_position() if self.label_position == 'left': try: @@ -2209,7 +2268,6 @@ def _update_label_position(self, bboxes, bboxes2): spinebbox = self.axes.bbox bbox = mtransforms.Bbox.union(bboxes + [spinebbox]) left = bbox.x0 - self.label.set_position( (left - self.labelpad * self.figure.dpi / 72.0, y) ) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index b56c67b3c054..d9e00247574b 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -380,6 +380,12 @@ def __init__(self, self.clf() self._cachedRenderer = 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_xlabel_grp = cbook.Grouper() + self._align_ylabel_grp = cbook.Grouper() + @property @cbook.deprecated("2.1", alternative="Figure.patch") def figurePatch(self): @@ -2084,6 +2090,165 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None, pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect) self.subplots_adjust(**kwargs) + def align_xlabels(self, axs=None): + """ + Align the ylabels of subplots in the same subplot column if label + alignment is being done automatically (i.e. the label position is + not manually set). + + Alignment persists for draw events after this is called. + + If a label is on the bottom, it is aligned with labels on axes that + also have their label on the bottom and that have the same + bottom-most subplot row. If the label is on the top, + it is aligned with labels on axes with the same top-most row. + + Parameters + ---------- + axs : list of `~matplotlib.axes.Axes` (None) + Optional list of (or ndarray) `~matplotlib.axes.Axes` to align + the xlabels. Default is to align all axes on the figure. + + Note + ---- + This assumes that ``axs`` are from the same `~.GridSpec`, so that + their `~.SubplotSpec` positions correspond to figure positions. + + See Also + -------- + matplotlib.figure.Figure.align_ylabels + + matplotlib.figure.Figure.align_labels + + Example + ------- + Example with rotated xtick labels:: + + fig, axs = plt.subplots(1, 2) + for tick in axs[0].get_xticklabels(): + tick.set_rotation(55) + axs[0].set_xlabel('XLabel 0') + axs[1].set_xlabel('XLabel 1') + fig.align_xlabels() + + """ + + if axs is None: + axs = self.axes + axs = np.asarray(axs).ravel() + for ax in axs: + _log.debug(' Working on: %s', ax.get_xlabel()) + ss = ax.get_subplotspec() + nrows, ncols, row0, row1, col0, col1 = ss.get_rows_columns() + labpo = ax.xaxis.get_label_position() # top or bottom + + # loop through other axes, and search for label positions + # that are same as this one, and that share the appropriate + # row number. + # Add to a grouper associated with each axes of sibblings. + # This list is inspected in `axis.draw` by + # `axis._update_label_position`. + for axc in axs: + if axc.xaxis.get_label_position() == labpo: + ss = axc.get_subplotspec() + nrows, ncols, rowc0, rowc1, colc, col1 = \ + ss.get_rows_columns() + if (labpo == 'bottom' and rowc1 == row1 or + labpo == 'top' and rowc0 == row0): + # grouper for groups of xlabels to align + self._align_xlabel_grp.join(ax, axc) + + def align_ylabels(self, axs=None): + """ + Align the ylabels of subplots in the same subplot column if label + alignment is being done automatically (i.e. the label position is + not manually set). + + Alignment persists for draw events after this is called. + + If a label is on the left, it is aligned with labels on axes that + also have their label on the left and that have the same + left-most subplot column. If the label is on the right, + it is aligned with labels on axes with the same right-most column. + + Parameters + ---------- + axs : list of `~matplotlib.axes.Axes` (None) + Optional list (or ndarray) of `~matplotlib.axes.Axes` to align + the ylabels. Default is to align all axes on the figure. + + Note + ---- + This assumes that ``axs`` are from the same `~.GridSpec`, so that + their `~.SubplotSpec` positions correspond to figure positions. + + See Also + -------- + matplotlib.figure.Figure.align_xlabels + + matplotlib.figure.Figure.align_labels + + Example + ------- + Example with large yticks labels:: + + fig, axs = plt.subplots(2, 1) + axs[0].plot(np.arange(0, 1000, 50)) + axs[0].set_ylabel('YLabel 0') + axs[1].set_ylabel('YLabel 1') + fig.align_ylabels() + + """ + + if axs is None: + axs = self.axes + axs = np.asarray(axs).ravel() + for ax in axs: + _log.debug(' Working on: %s', ax.get_ylabel()) + ss = ax.get_subplotspec() + nrows, ncols, row0, row1, col0, col1 = ss.get_rows_columns() + same = [ax] + labpo = ax.yaxis.get_label_position() # left or right + # loop through other axes, and search for label positions + # that are same as this one, and that share the appropriate + # column number. + # Add to a list associated with each axes of sibblings. + # This list is inspected in `axis.draw` by + # `axis._update_label_position`. + for axc in axs: + if axc != ax: + if axc.yaxis.get_label_position() == labpo: + ss = axc.get_subplotspec() + nrows, ncols, row0, row1, colc0, colc1 = \ + ss.get_rows_columns() + if (labpo == 'left' and colc0 == col0 or + labpo == 'right' and colc1 == col1): + # grouper for groups of ylabels to align + self._align_ylabel_grp.join(ax, axc) + + def align_labels(self, axs=None): + """ + Align the xlabels and ylabels of subplots with the same subplots + row or column (respectively) if label alignment is being + done automatically (i.e. the label position is not manually set). + + Alignment persists for draw events after this is called. + + Parameters + ---------- + axs : list of `~matplotlib.axes.Axes` (None) + Optional list (or ndarray) of `~matplotlib.axes.Axes` to + align the labels. Default is to align all axes on the figure. + + See Also + -------- + matplotlib.figure.Figure.align_xlabels + + matplotlib.figure.Figure.align_ylabels + """ + self.align_xlabels(axs=axs) + self.align_ylabels(axs=axs) + def figaspect(arg): """ diff --git a/lib/matplotlib/gridspec.py b/lib/matplotlib/gridspec.py index 035f554959af..8bd46c1a7d5f 100644 --- a/lib/matplotlib/gridspec.py +++ b/lib/matplotlib/gridspec.py @@ -340,13 +340,30 @@ def get_gridspec(self): return self._gridspec def get_geometry(self): - """Get the subplot geometry (``n_rows, n_cols, row, col``). + """ + Get the subplot geometry (``n_rows, n_cols, start, stop``). - Unlike SuplorParams, indexes are 0-based. + start and stop are the index of the start and stop of the + subplot. """ rows, cols = self.get_gridspec().get_geometry() return rows, cols, self.num1, self.num2 + def get_rows_columns(self): + """ + Get the subplot row and column numbers: + (``n_rows, n_cols, row_start, row_stop, col_start, col_stop``) + """ + gridspec = self.get_gridspec() + nrows, ncols = gridspec.get_geometry() + row_start, col_start = divmod(self.num1, ncols) + if self.num2 is not None: + row_stop, col_stop = divmod(self.num2, ncols) + else: + row_stop = row_start + col_stop = col_start + return nrows, ncols, row_start, row_stop, col_start, col_stop + def get_position(self, fig, return_all=False): """Update the subplot position from ``fig.subplotpars``. """ diff --git a/lib/matplotlib/tests/baseline_images/test_figure/figure_align_labels.pdf b/lib/matplotlib/tests/baseline_images/test_figure/figure_align_labels.pdf new file mode 100644 index 000000000000..c1763f68be19 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_figure/figure_align_labels.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_figure/figure_align_labels.png b/lib/matplotlib/tests/baseline_images/test_figure/figure_align_labels.png new file mode 100644 index 000000000000..02c99021186a Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_figure/figure_align_labels.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_figure/figure_align_labels.svg b/lib/matplotlib/tests/baseline_images/test_figure/figure_align_labels.svg new file mode 100644 index 000000000000..9ec923dc434e --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_figure/figure_align_labels.svg @@ -0,0 +1,2724 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index 4ad48d5d6966..3ec1c59e0dab 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -6,11 +6,53 @@ from matplotlib.ticker import AutoMinorLocator, FixedFormatter import matplotlib.pyplot as plt import matplotlib.dates as mdates +import matplotlib.gridspec as gridspec import numpy as np import warnings import pytest +@image_comparison(baseline_images=['figure_align_labels']) +def test_align_labels(): + # Check the figure.align_labels() command + fig = plt.figure(tight_layout=True) + gs = gridspec.GridSpec(3, 3) + + ax = fig.add_subplot(gs[0, :2]) + ax.plot(np.arange(0, 1e6, 1000)) + ax.set_ylabel('Ylabel0 0') + ax = fig.add_subplot(gs[0, -1]) + ax.plot(np.arange(0, 1e4, 100)) + + for i in range(3): + ax = fig.add_subplot(gs[1, i]) + ax.set_ylabel('YLabel1 %d' % i) + ax.set_xlabel('XLabel1 %d' % i) + if i in [0, 2]: + ax.xaxis.set_label_position("top") + ax.xaxis.tick_top() + if i == 0: + for tick in ax.get_xticklabels(): + tick.set_rotation(90) + if i == 2: + ax.yaxis.set_label_position("right") + ax.yaxis.tick_right() + + for i in range(3): + ax = fig.add_subplot(gs[2, i]) + ax.set_xlabel('XLabel2 %d' % (i)) + ax.set_ylabel('YLabel2 %d' % (i)) + + if i == 2: + ax.plot(np.arange(0, 1e4, 10)) + ax.yaxis.set_label_position("right") + ax.yaxis.tick_right() + for tick in ax.get_xticklabels(): + tick.set_rotation(90) + + fig.align_labels() + + def test_figure_label(): # pyplot figure creation, selection and closing with figure label and # number