Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions lib/matplotlib/projections/polar.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,48 @@ def view_limits(self, vmin, vmax):
return np.deg2rad(self.base.view_limits(vmin, vmax))


class ChoiceLocator(mticker.Locator):
def __init__(self, base=None, choices=None):
if choices is None:
choices = [
np.arange(-360, 360, 30.0),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using 30 degree steps as first priority causes a lot of tests in test_polar.py to fail. It looks like the previous behavior was to use 45 degree intervals. So, it would be better to remove 30 degree intervals.

Suggested change
np.arange(-360, 360, 30.0),

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous behavior uses 45 degree intervals only for full circle. for other arcs with angles (< 360),
30 degree intervals are also used. I think adding a check for full circle might be better.

np.arange(-360, 360, 45.0),
np.arange(-360, 360, 60.0),
np.arange(-360, 360, 90.0),
]
if base is None:
base = mticker.AutoLocator()
self.choices = choices
self.base = base
self.axis = self.base.axis = _AxisWrapper(self.base.axis)

def set_axis(self, axis):
self.axis = _AxisWrapper(axis)
self.base.set_axis(self.axis)

def __call__(self):
lim = self.axis.get_view_interval()
vmin = min(lim[0], lim[1])
vmax = max(lim[0], lim[1])
max_ticks = self.axis.get_tick_space()
tol = 1e-12
if (vmax - vmin > 60):
for ticks in self.choices:
in_range = (ticks >= vmin - tol) & (ticks <= vmax + tol)
ticks = ticks[in_range]
if len(ticks) <= max_ticks:
return np.deg2rad(ticks)
else:
return np.deg2rad(self.base())
ticks = self.choices[-1]
ticks = ticks[(ticks >= vmin - tol) & (ticks <= vmax + tol)]
return ticks

def view_limits(self, vmin, vmax):
vmin, vmax = np.rad2deg((vmin, vmax))
return np.deg2rad(self.base.view_limits(vmin, vmax))


class ThetaTick(maxis.XTick):
"""
A theta-axis tick.
Expand Down Expand Up @@ -387,7 +429,7 @@ class ThetaAxis(maxis.XAxis):
_tick_class = ThetaTick

def _wrap_locator_formatter(self):
self.set_major_locator(ThetaLocator(self.get_major_locator()))
self.set_major_locator(ChoiceLocator(self.get_major_locator()))
self.set_major_formatter(ThetaFormatter())
self.isDefault_majloc = True
self.isDefault_majfmt = True
Expand All @@ -406,7 +448,6 @@ def _set_scale(self, value, **kwargs):
# LinearScale.set_default_locators_and_formatters just set the major
# locator to be an AutoLocator, so we customize it here to have ticks
# at sensible degree multiples.
self.get_major_locator().set_params(steps=[1, 1.5, 3, 4.5, 9, 10])
self._wrap_locator_formatter()

def _copy_tick_props(self, src, dest):
Expand All @@ -421,6 +462,23 @@ def _copy_tick_props(self, src, dest):
trans = dest._get_text2_transform()[0]
dest.label2.set_transform(trans + dest._text2_translate)

def get_tick_space(self):
ends = mtransforms.Bbox.unit().transformed(
self.axes.transAxes - self.get_figure(root=False).dpi_scale_trans)

thetamin, thetamax = self.axes._realViewLim.intervalx
radius = min(ends.height, ends.width) * 72
if abs(thetamax - thetamin) > np.pi / 2:
radius /=2
angle = abs(thetamax - thetamin)
arc_length = radius * angle
size = self._get_tick_label_size('x') * 3
if size > 0:
return int(np.floor(arc_length / size))
else:
return 2**31 - 1



class RadialLocator(mticker.Locator):
"""
Expand Down Expand Up @@ -690,6 +748,23 @@ def clear(self):
super().clear()
self.set_ticks_position('none')

def get_tick_space(self):
ends = mtransforms.Bbox.unit().transformed(
self.axes.transAxes - self.get_figure(root=False).dpi_scale_trans)
thetamin, thetamax = self.axes._realViewLim.intervalx
rmin, rmax = self.axes._realViewLim.intervaly
rorigin = self.axes.get_rorigin()
radius = min(ends.height, ends.width) * 72
actual_ratio = rmax / (rmax - rorigin)
if abs(thetamax - thetamin) > np.pi / 2:
radius /=2
# Having a spacing of at least 3 just looks good
size = self._get_tick_label_size('y') * 3
if size > 0:
return int(np.floor(radius * actual_ratio / size))
else:
return 2**31 - 1

Comment on lines +751 to +767
Copy link
Owner

@r3kste r3kste Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the behavior of radial ticks causes a lot of tests to fail.

Now that I think about it, the issue doesn't really expect us to change Radial Ticks. It only mentions about the issue with Theta ticks. So I think it would be better if we stick to just Theta Ticks for this PR.


def _is_full_circle_deg(thetamin, thetamax):
"""
Expand Down
6 changes: 6 additions & 0 deletions lib/matplotlib/projections/polar.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ class ThetaLocator(mticker.Locator):
axis: _AxisWrapper | None
def __init__(self, base: mticker.Locator) -> None: ...

class ChoiceLocator(mticker.Locator):
choices: list[np.ndarray]
base: mticker.Locator | None
axis: _AxisWrapper | None
def __init__(self, base: mticker.Locator | None = ..., choices: list[np.ndarray] | None = ...) -> None: ...

class ThetaTick(maxis.XTick):
def __init__(self, axes: PolarAxes, *args, **kwargs) -> None: ...

Expand Down