Skip to content

Commit bb6e1aa

Browse files
authored
Fix polar error bar cap orientation (#28966)
1 parent 9489b93 commit bb6e1aa

File tree

6 files changed

+72
-3
lines changed

6 files changed

+72
-3
lines changed

lib/matplotlib/axes/_axes.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
_AxesBase, _TransformedBoundsLocator, _process_plot_format)
3737
from matplotlib.axes._secondary_axes import SecondaryAxis
3838
from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer
39+
from matplotlib.transforms import _ScaledRotation
3940

4041
_log = logging.getLogger(__name__)
4142

@@ -3783,13 +3784,14 @@ def apply_mask(arrays, mask):
37833784
caplines[dep_axis].append(mlines.Line2D(
37843785
x_masked, y_masked, marker=marker, **eb_cap_style))
37853786
if self.name == 'polar':
3787+
trans_shift = self.transShift
37863788
for axis in caplines:
37873789
for l in caplines[axis]:
37883790
# Rotate caps to be perpendicular to the error bars
37893791
for theta, r in zip(l.get_xdata(), l.get_ydata()):
3790-
rotation = mtransforms.Affine2D().rotate(theta)
3792+
rotation = _ScaledRotation(theta=theta, trans_shift=trans_shift)
37913793
if axis == 'y':
3792-
rotation.rotate(-np.pi / 2)
3794+
rotation += mtransforms.Affine2D().rotate(np.pi / 2)
37933795
ms = mmarkers.MarkerStyle(marker=marker,
37943796
transform=rotation)
37953797
self.add_line(mlines.Line2D([theta], [r], marker=ms,

lib/matplotlib/tests/test_polar.py

+18
Original file line numberDiff line numberDiff line change
@@ -481,3 +481,21 @@ def test_polar_neg_theta_lims():
481481
ax.set_thetalim(-np.pi, np.pi)
482482
labels = [l.get_text() for l in ax.xaxis.get_ticklabels()]
483483
assert labels == ['-180°', '-135°', '-90°', '-45°', '0°', '45°', '90°', '135°']
484+
485+
486+
@pytest.mark.parametrize("order", ["before", "after"])
487+
@image_comparison(baseline_images=['polar_errorbar'], remove_text=True,
488+
extensions=['png'], style='mpl20')
489+
def test_polar_errorbar(order):
490+
theta = np.arange(0, 2 * np.pi, np.pi / 8)
491+
r = theta / np.pi / 2 + 0.5
492+
fig = plt.figure(figsize=(5, 5))
493+
ax = fig.add_subplot(projection='polar')
494+
if order == "before":
495+
ax.set_theta_zero_location("N")
496+
ax.set_theta_direction(-1)
497+
ax.errorbar(theta, r, xerr=0.1, yerr=0.1, capsize=7, fmt="o", c="seagreen")
498+
else:
499+
ax.errorbar(theta, r, xerr=0.1, yerr=0.1, capsize=7, fmt="o", c="seagreen")
500+
ax.set_theta_zero_location("N")
501+
ax.set_theta_direction(-1)

lib/matplotlib/tests/test_transforms.py

+26-1
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
import matplotlib.pyplot as plt
1010
import matplotlib.patches as mpatches
1111
import matplotlib.transforms as mtransforms
12-
from matplotlib.transforms import Affine2D, Bbox, TransformedBbox
12+
from matplotlib.transforms import Affine2D, Bbox, TransformedBbox, _ScaledRotation
1313
from matplotlib.path import Path
1414
from matplotlib.testing.decorators import image_comparison, check_figures_equal
15+
from unittest.mock import MagicMock
1516

1617

1718
class TestAffine2D:
@@ -1104,3 +1105,27 @@ def test_interval_contains_open():
11041105
assert not mtransforms.interval_contains_open((0, 1), -1)
11051106
assert not mtransforms.interval_contains_open((0, 1), 2)
11061107
assert mtransforms.interval_contains_open((1, 0), 0.5)
1108+
1109+
1110+
def test_scaledrotation_initialization():
1111+
"""Test that the ScaledRotation object is initialized correctly."""
1112+
theta = 1.0 # Arbitrary theta value for testing
1113+
trans_shift = MagicMock() # Mock the trans_shift transformation
1114+
scaled_rot = _ScaledRotation(theta, trans_shift)
1115+
assert scaled_rot._theta == theta
1116+
assert scaled_rot._trans_shift == trans_shift
1117+
assert scaled_rot._mtx is None
1118+
1119+
1120+
def test_scaledrotation_get_matrix_invalid():
1121+
"""Test get_matrix when the matrix is invalid and needs recalculation."""
1122+
theta = np.pi / 2
1123+
trans_shift = MagicMock(transform=MagicMock(return_value=[[theta, 0]]))
1124+
scaled_rot = _ScaledRotation(theta, trans_shift)
1125+
scaled_rot._invalid = True
1126+
matrix = scaled_rot.get_matrix()
1127+
trans_shift.transform.assert_called_once_with([[theta, 0]])
1128+
expected_rotation = np.array([[0, -1],
1129+
[1, 0]])
1130+
assert matrix is not None
1131+
assert_allclose(matrix[:2, :2], expected_rotation, atol=1e-15)

lib/matplotlib/transforms.py

+19
Original file line numberDiff line numberDiff line change
@@ -2685,6 +2685,25 @@ def get_matrix(self):
26852685
return self._mtx
26862686

26872687

2688+
class _ScaledRotation(Affine2DBase):
2689+
"""
2690+
A transformation that applies rotation by *theta*, after transform by *trans_shift*.
2691+
"""
2692+
def __init__(self, theta, trans_shift):
2693+
super().__init__()
2694+
self._theta = theta
2695+
self._trans_shift = trans_shift
2696+
self._mtx = None
2697+
2698+
def get_matrix(self):
2699+
if self._invalid:
2700+
transformed_coords = self._trans_shift.transform([[self._theta, 0]])[0]
2701+
adjusted_theta = transformed_coords[0]
2702+
rotation = Affine2D().rotate(adjusted_theta)
2703+
self._mtx = rotation.get_matrix()
2704+
return self._mtx
2705+
2706+
26882707
class AffineDeltaTransform(Affine2DBase):
26892708
r"""
26902709
A transform wrapper for transforming displacements between pairs of points.

lib/matplotlib/transforms.pyi

+5
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,8 @@ def offset_copy(
334334
y: float = ...,
335335
units: Literal["inches", "points", "dots"] = ...,
336336
) -> Transform: ...
337+
338+
339+
class _ScaledRotation(Affine2DBase):
340+
def __init__(self, theta: float, trans_shift: Transform) -> None: ...
341+
def get_matrix(self) -> np.ndarray: ...

0 commit comments

Comments
 (0)