diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index a16e19f90152..7c049faab799 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -37,6 +37,7 @@ _AxesBase, _TransformedBoundsLocator, _process_plot_format) from matplotlib.axes._secondary_axes import SecondaryAxis from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer +from matplotlib.transforms import _ScaledRotation _log = logging.getLogger(__name__) @@ -3784,13 +3785,14 @@ def apply_mask(arrays, mask): caplines[dep_axis].append(mlines.Line2D( x_masked, y_masked, marker=marker, **eb_cap_style)) if self.name == 'polar': + trans_shift = self.transShift for axis in caplines: for l in caplines[axis]: # Rotate caps to be perpendicular to the error bars for theta, r in zip(l.get_xdata(), l.get_ydata()): - rotation = mtransforms.Affine2D().rotate(theta) + rotation = _ScaledRotation(theta=theta, trans_shift=trans_shift) if axis == 'y': - rotation.rotate(-np.pi / 2) + rotation += mtransforms.Affine2D().rotate(np.pi / 2) ms = mmarkers.MarkerStyle(marker=marker, transform=rotation) self.add_line(mlines.Line2D([theta], [r], marker=ms, diff --git a/lib/matplotlib/tests/baseline_images/test_polar/polar_errorbar.png b/lib/matplotlib/tests/baseline_images/test_polar/polar_errorbar.png new file mode 100644 index 000000000000..6b587a78ae10 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_polar/polar_errorbar.png differ diff --git a/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py index 0bb41a50b2d4..ee38c88a123f 100644 --- a/lib/matplotlib/tests/test_polar.py +++ b/lib/matplotlib/tests/test_polar.py @@ -481,3 +481,21 @@ def test_polar_neg_theta_lims(): ax.set_thetalim(-np.pi, np.pi) labels = [l.get_text() for l in ax.xaxis.get_ticklabels()] assert labels == ['-180°', '-135°', '-90°', '-45°', '0°', '45°', '90°', '135°'] + + +@pytest.mark.parametrize("order", ["before", "after"]) +@image_comparison(baseline_images=['polar_errorbar'], remove_text=True, + extensions=['png'], style='mpl20') +def test_polar_errorbar(order): + theta = np.arange(0, 2 * np.pi, np.pi / 8) + r = theta / np.pi / 2 + 0.5 + fig = plt.figure(figsize=(5, 5)) + ax = fig.add_subplot(projection='polar') + if order == "before": + ax.set_theta_zero_location("N") + ax.set_theta_direction(-1) + ax.errorbar(theta, r, xerr=0.1, yerr=0.1, capsize=7, fmt="o", c="seagreen") + else: + ax.errorbar(theta, r, xerr=0.1, yerr=0.1, capsize=7, fmt="o", c="seagreen") + ax.set_theta_zero_location("N") + ax.set_theta_direction(-1) diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index 64b312d09d34..96e78b6828f8 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -9,9 +9,10 @@ import matplotlib.pyplot as plt import matplotlib.patches as mpatches import matplotlib.transforms as mtransforms -from matplotlib.transforms import Affine2D, Bbox, TransformedBbox +from matplotlib.transforms import Affine2D, Bbox, TransformedBbox, _ScaledRotation from matplotlib.path import Path from matplotlib.testing.decorators import image_comparison, check_figures_equal +from unittest.mock import MagicMock class TestAffine2D: @@ -1104,3 +1105,27 @@ def test_interval_contains_open(): assert not mtransforms.interval_contains_open((0, 1), -1) assert not mtransforms.interval_contains_open((0, 1), 2) assert mtransforms.interval_contains_open((1, 0), 0.5) + + +def test_scaledrotation_initialization(): + """Test that the ScaledRotation object is initialized correctly.""" + theta = 1.0 # Arbitrary theta value for testing + trans_shift = MagicMock() # Mock the trans_shift transformation + scaled_rot = _ScaledRotation(theta, trans_shift) + assert scaled_rot._theta == theta + assert scaled_rot._trans_shift == trans_shift + assert scaled_rot._mtx is None + + +def test_scaledrotation_get_matrix_invalid(): + """Test get_matrix when the matrix is invalid and needs recalculation.""" + theta = np.pi / 2 + trans_shift = MagicMock(transform=MagicMock(return_value=[[theta, 0]])) + scaled_rot = _ScaledRotation(theta, trans_shift) + scaled_rot._invalid = True + matrix = scaled_rot.get_matrix() + trans_shift.transform.assert_called_once_with([[theta, 0]]) + expected_rotation = np.array([[0, -1], + [1, 0]]) + assert matrix is not None + assert_allclose(matrix[:2, :2], expected_rotation, atol=1e-15) diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 15caff545e73..dbe6e85d367c 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -2685,6 +2685,25 @@ def get_matrix(self): return self._mtx +class _ScaledRotation(Affine2DBase): + """ + A transformation that applies rotation by *theta*, after transform by *trans_shift*. + """ + def __init__(self, theta, trans_shift): + super().__init__() + self._theta = theta + self._trans_shift = trans_shift + self._mtx = None + + def get_matrix(self): + if self._invalid: + transformed_coords = self._trans_shift.transform([[self._theta, 0]])[0] + adjusted_theta = transformed_coords[0] + rotation = Affine2D().rotate(adjusted_theta) + self._mtx = rotation.get_matrix() + return self._mtx + + class AffineDeltaTransform(Affine2DBase): r""" A transform wrapper for transforming displacements between pairs of points. diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index c87a965b1e4a..551487a11c60 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -334,3 +334,8 @@ def offset_copy( y: float = ..., units: Literal["inches", "points", "dots"] = ..., ) -> Transform: ... + + +class _ScaledRotation(Affine2DBase): + def __init__(self, theta: float, trans_shift: Transform) -> None: ... + def get_matrix(self) -> np.ndarray: ...