diff --git a/doc/api/next_api_changes/behavior/19029-FU.rst b/doc/api/next_api_changes/behavior/19029-FU.rst new file mode 100644 index 000000000000..2abfba176ee3 --- /dev/null +++ b/doc/api/next_api_changes/behavior/19029-FU.rst @@ -0,0 +1,11 @@ +y-axis labels with non-default rotation no longer overlap tick labels +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Setting a y-axis label with a rotation other than the default (e.g. +``ax.set_ylabel("...", rotation=270)``, or ``cbar.set_label("...", +rotation=270)`` on a colorbar) previously could cause the label to +overlap the tick labels because the automatic label placement assumed a +90° rotation. The label position is now computed from the rendered +label's actual extent, so it sits clear of the tick labels for any +rotation. This may shift the position of y-labels that use non-default +rotations. diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 0ddfee2d537c..5c6b471e307b 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -2735,14 +2735,27 @@ def _update_label_position(self, renderer): # Union with extents of the left spine if present, of the axes otherwise. bbox = mtransforms.Bbox.union([ *bboxes, self.axes.spines.get("left", self.axes).get_window_extent()]) - self.label.set_position( - (bbox.x0 - self.labelpad * self.get_figure(root=True).dpi / 72, y)) + target_x = bbox.x0 - self.labelpad * self.get_figure(root=True).dpi / 72 + self.label.set_position((target_x, y)) + # The vertical alignment set in `set_label_position` assumes a + # 90° rotation; for any other rotation the rotated label may + # extend back toward the tick labels and overlap them. Shift + # the label outward by the overhang. See issue #19029. + label_bbox = self.label.get_window_extent(renderer) + overhang = label_bbox.x1 - target_x + if overhang > 0: + self.label.set_position((target_x - overhang, y)) else: # Union with extents of the right spine if present, of the axes otherwise. bbox = mtransforms.Bbox.union([ *bboxes2, self.axes.spines.get("right", self.axes).get_window_extent()]) - self.label.set_position( - (bbox.x1 + self.labelpad * self.get_figure(root=True).dpi / 72, y)) + target_x = bbox.x1 + self.labelpad * self.get_figure(root=True).dpi / 72 + self.label.set_position((target_x, y)) + # See comment in the 'left' branch above (issue #19029). + label_bbox = self.label.get_window_extent(renderer) + overhang = target_x - label_bbox.x0 + if overhang > 0: + self.label.set_position((target_x + overhang, y)) def _update_offset_text_position(self, bboxes, bboxes2): """ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 57a295d418a6..76833a2dfb7f 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -9070,6 +9070,56 @@ def test_ylabel_ha_with_position(ha): assert ax.yaxis.label.get_ha() == ha +@pytest.mark.parametrize('label_position', ['left', 'right']) +@pytest.mark.parametrize('rotation', [0, 45, 90, 135, 180, 270, -90]) +def test_ylabel_no_overlap_with_ticklabels(label_position, rotation): + """Regression test for #19029: a y-label with rotation other than 90° + must not overlap the tick labels regardless of label_position. + """ + fig, ax = plt.subplots() + ax.plot([1, 3, 2]) + ax.yaxis.set_label_position(label_position) + if label_position == 'right': + ax.yaxis.tick_right() + ax.set_ylabel("test label", rotation=rotation) + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + label_bbox = ax.yaxis.label.get_window_extent(renderer) + tick_bboxes = [t.get_window_extent(renderer) + for t in ax.yaxis.get_ticklabels() if t.get_visible()] + tick_union = mtransforms.Bbox.union(tick_bboxes) + if label_position == 'right': + assert label_bbox.x0 >= tick_union.x1 + else: + assert label_bbox.x1 <= tick_union.x0 + + +@pytest.mark.parametrize('label_position', ['left', 'right']) +def test_align_ylabels_mixed_rotation(label_position): + """Issue #19029: ``fig.align_ylabels`` must keep two y-labels visually + aligned even when they use different rotations. The per-axis overhang + correction in `_update_label_position` shifts each axis independently; + this test guards against that breaking sibling alignment. + """ + fig, axs = plt.subplots(2, figsize=(4, 6)) + for ax in axs: + ax.plot([1, 30, 2]) + ax.yaxis.set_label_position(label_position) + if label_position == 'right': + ax.yaxis.tick_right() + axs[0].set_ylabel('first', rotation=90) + axs[1].set_ylabel('second', rotation=270) + fig.align_ylabels(axs) + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + bbox0 = axs[0].yaxis.label.get_window_extent(renderer) + bbox1 = axs[1].yaxis.label.get_window_extent(renderer) + # Both visible label bboxes should occupy the same horizontal range, + # even though the underlying anchor positions differ between rotations. + assert bbox0.x0 == pytest.approx(bbox1.x0, abs=0.5) + assert bbox0.x1 == pytest.approx(bbox1.x1, abs=0.5) + + def test_bar_label_location_vertical(): ax = plt.gca() xs, heights = [1, 2], [3, -4] diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index 691df9cde3bd..8d1490438d9e 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -733,6 +733,25 @@ def test_colorbar_label(): assert cbar3.ax.get_xlabel() == 'horizontal cbar' +def test_colorbar_label_rotation_no_overlap(): + """Smoke test for #19029: a rotation=270 colorbar label must not + overlap negative tick labels (the original user-facing symptom). + """ + fig, ax = plt.subplots() + im = ax.imshow([[-100, 0], [50, 100]]) + cbar = fig.colorbar(im, ax=ax) + cbar.set_label('RT(1ax) - RT(2ax) [ms]', rotation=270) + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + label_bbox = cbar.ax.yaxis.label.get_window_extent(renderer) + # Colorbar labels live on the right, so the label must sit clear of + # the rightmost visible tick-label edge. + tick_x1_max = max(t.get_window_extent(renderer).x1 + for t in cbar.ax.yaxis.get_ticklabels() + if t.get_visible()) + assert label_bbox.x0 >= tick_x1_max + + # TODO: tighten tolerance after baseline image is regenerated for text overhaul @image_comparison(['colorbar_keeping_xlabel.png'], style='mpl20', tol=0.03) def test_keeping_xlabel():