From 30167c852bbeb35b7026193d43a074642aebb900 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 23 Aug 2026 12:28:26 +1000 Subject: [PATCH 1/4] Fix an issue with tickspacing not syncing --- ultraplot/axes/geo.py | 59 +++++++++++++++++++++++++++++- ultraplot/tests/test_geographic.py | 31 ++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 786fbff54..e3cfacef0 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -898,6 +898,8 @@ def _pad_ticks(ticks: np.ndarray, vmin: float, vmax: float) -> np.ndarray: # giant lists of 10,000 gridline locations. if len(ticks) == 0: return ticks + if len(ticks) == 1: + return ticks range_ = np.max(ticks) - np.min(ticks) vmin = max(vmin, ticks[0] - range_) vmax = min(vmax, ticks[-1] + range_) @@ -985,7 +987,6 @@ def _copy_locator_properties(self, other: "_GeoAxis") -> None: setter(getter()) setattr(other, prop, this_prop) - class _GridlinerAdapter(Protocol): """ Lightweight facade used to normalize cartopy and basemap gridliner behavior. @@ -1595,6 +1596,46 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._edge_lat_labels: list[mtext.Text] = [] super().__init__(*args, **kwargs) + def _sync_shared_tick_state( + self, + which: str, + *, + copy_major_locator: bool = False, + copy_minor_locator: bool = False, + copy_major_formatter: bool = False, + ) -> None: + """ + Copy explicit tick-state changes from this axis to shared GeoAxes siblings. + """ + if which not in {"x", "y"}: + raise ValueError(f"Invalid axis: {which!r}") + if not any((copy_major_locator, copy_minor_locator, copy_major_formatter)): + return + if which == "x": + if self.figure._sharex < 2: + return + this_axis = self._lonaxis + siblings = list(self._shared_axes["x"].get_siblings(self)) + else: + if self.figure._sharey < 2: + return + this_axis = self._lataxis + siblings = list(self._shared_axes["y"].get_siblings(self)) + for sibling in siblings: + if sibling is self or not isinstance(sibling, GeoAxes): + continue + sibling_axis = sibling._lataxis if which == "y" else sibling._lonaxis + if copy_major_locator: + sibling_axis.set_major_locator(this_axis.get_major_locator()) + if copy_minor_locator: + sibling_axis.set_minor_locator(this_axis.get_minor_locator()) + if copy_major_formatter: + sibling_axis.set_major_formatter(this_axis.get_major_formatter()) + if copy_major_locator or copy_major_formatter: + sibling._update_major_gridlines() + if copy_minor_locator: + sibling._update_minor_gridlines() + @docstring._snippet_manager def hawkeye( self, @@ -3087,6 +3128,22 @@ def format( labelpad=labelpad, nsteps=nsteps, ) + if _not_none(lonlocator=lonlocator, lonlines=lonlines) is not None: + self._sync_shared_tick_state("x", copy_major_locator=True) + if _not_none(latlocator=latlocator, latlines=latlines) is not None: + self._sync_shared_tick_state("y", copy_major_locator=True) + if _not_none( + lonminorlocator=lonminorlocator, lonminorlines=lonminorlines + ) is not None: + self._sync_shared_tick_state("x", copy_minor_locator=True) + if _not_none( + latminorlocator=latminorlocator, latminorlines=latminorlines + ) is not None: + self._sync_shared_tick_state("y", copy_minor_locator=True) + if lonformatter is not None: + self._sync_shared_tick_state("x", copy_major_formatter=True) + if latformatter is not None: + self._sync_shared_tick_state("y", copy_major_formatter=True) self._format_apply_ticklen( lonlim=lonlim, latlim=latlim, diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 729f168a7..3f6e70529 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -795,6 +795,37 @@ def test_copy_locator_props(): assert getattr(g1, prop) == getattr(g2, prop) +def test_format_shared_ticks_sync(): + pytest.importorskip("cartopy") + fig, ax = uplt.subplots(ncols=2, proj="cyl", share="all") + ax.format(lonlim=(100, 105), latlim=(30, 35), labels=True) + + before_lon = ax[0]._get_lonticklocs() + before_lat = ax[0]._get_latticklocs() + + ax[1].format(lonlines=2, latlines=1) + + after_left_lon = ax[0]._get_lonticklocs() + after_left_lat = ax[0]._get_latticklocs() + after_right_lon = ax[1]._get_lonticklocs() + after_right_lat = ax[1]._get_latticklocs() + left_gridliner = ax[0]._gridliner_adapters["major"].gridliner + + assert np.asarray(after_left_lon).shape != np.asarray(before_lon).shape or not np.allclose( + after_left_lon, + before_lon, + ) + assert np.asarray(after_left_lat).shape != np.asarray(before_lat).shape or not np.allclose( + after_left_lat, + before_lat, + ) + assert np.allclose(after_left_lon, after_right_lon) + assert np.allclose(after_left_lat, after_right_lat) + assert np.allclose(left_gridliner.xlocator.tick_values(100, 105), after_left_lon) + assert np.allclose(left_gridliner.ylocator.tick_values(30, 35), after_left_lat) + uplt.close(fig) + + def test_turn_off_tick_labels_basemap(): """ Check if we can toggle the labels off for GeoAxes From 6d6a75097a763c233327527cce6f1635c180352a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 23 Aug 2026 12:30:33 +1000 Subject: [PATCH 2/4] black --- ultraplot/axes/geo.py | 15 +++++++++------ ultraplot/tests/test_geographic.py | 8 ++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index e3cfacef0..18fe9c49b 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -987,6 +987,7 @@ def _copy_locator_properties(self, other: "_GeoAxis") -> None: setter(getter()) setattr(other, prop, this_prop) + class _GridlinerAdapter(Protocol): """ Lightweight facade used to normalize cartopy and basemap gridliner behavior. @@ -3132,13 +3133,15 @@ def format( self._sync_shared_tick_state("x", copy_major_locator=True) if _not_none(latlocator=latlocator, latlines=latlines) is not None: self._sync_shared_tick_state("y", copy_major_locator=True) - if _not_none( - lonminorlocator=lonminorlocator, lonminorlines=lonminorlines - ) is not None: + if ( + _not_none(lonminorlocator=lonminorlocator, lonminorlines=lonminorlines) + is not None + ): self._sync_shared_tick_state("x", copy_minor_locator=True) - if _not_none( - latminorlocator=latminorlocator, latminorlines=latminorlines - ) is not None: + if ( + _not_none(latminorlocator=latminorlocator, latminorlines=latminorlines) + is not None + ): self._sync_shared_tick_state("y", copy_minor_locator=True) if lonformatter is not None: self._sync_shared_tick_state("x", copy_major_formatter=True) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 3f6e70529..505da6ffd 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -811,11 +811,15 @@ def test_format_shared_ticks_sync(): after_right_lat = ax[1]._get_latticklocs() left_gridliner = ax[0]._gridliner_adapters["major"].gridliner - assert np.asarray(after_left_lon).shape != np.asarray(before_lon).shape or not np.allclose( + assert np.asarray(after_left_lon).shape != np.asarray( + before_lon + ).shape or not np.allclose( after_left_lon, before_lon, ) - assert np.asarray(after_left_lat).shape != np.asarray(before_lat).shape or not np.allclose( + assert np.asarray(after_left_lat).shape != np.asarray( + before_lat + ).shape or not np.allclose( after_left_lat, before_lat, ) From cf392922bfb7d5f1cc1770b9eb36da15c7f577ea Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 23 Aug 2026 12:38:55 +1000 Subject: [PATCH 3/4] Small clean-up --- ultraplot/axes/geo.py | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 18fe9c49b..c6bd8eb5c 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -3129,24 +3129,26 @@ def format( labelpad=labelpad, nsteps=nsteps, ) - if _not_none(lonlocator=lonlocator, lonlines=lonlines) is not None: - self._sync_shared_tick_state("x", copy_major_locator=True) - if _not_none(latlocator=latlocator, latlines=latlines) is not None: - self._sync_shared_tick_state("y", copy_major_locator=True) - if ( - _not_none(lonminorlocator=lonminorlocator, lonminorlines=lonminorlines) - is not None - ): - self._sync_shared_tick_state("x", copy_minor_locator=True) - if ( - _not_none(latminorlocator=latminorlocator, latminorlines=latminorlines) - is not None - ): - self._sync_shared_tick_state("y", copy_minor_locator=True) - if lonformatter is not None: - self._sync_shared_tick_state("x", copy_major_formatter=True) - if latformatter is not None: - self._sync_shared_tick_state("y", copy_major_formatter=True) + self._sync_shared_tick_state( + "x", + copy_major_locator=_not_none(lonlocator=lonlocator, lonlines=lonlines) + is not None, + copy_minor_locator=_not_none( + lonminorlocator=lonminorlocator, lonminorlines=lonminorlines + ) + is not None, + copy_major_formatter=lonformatter is not None, + ) + self._sync_shared_tick_state( + "y", + copy_major_locator=_not_none(latlocator=latlocator, latlines=latlines) + is not None, + copy_minor_locator=_not_none( + latminorlocator=latminorlocator, latminorlines=latminorlines + ) + is not None, + copy_major_formatter=latformatter is not None, + ) self._format_apply_ticklen( lonlim=lonlim, latlim=latlim, From 2c1658d790f9bce49980e9216bc604930a10bba4 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 23 Aug 2026 12:42:33 +1000 Subject: [PATCH 4/4] Push coverage --- ultraplot/tests/test_geographic.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 505da6ffd..699800566 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -3,6 +3,7 @@ import numpy as np import pytest +from matplotlib import ticker as mticker import ultraplot as uplt @@ -827,6 +828,35 @@ def test_format_shared_ticks_sync(): assert np.allclose(after_left_lat, after_right_lat) assert np.allclose(left_gridliner.xlocator.tick_values(100, 105), after_left_lon) assert np.allclose(left_gridliner.ylocator.tick_values(30, 35), after_left_lat) + + ax[1].format(lonminorlines=0.5, latminorlines=0.5) + assert np.allclose( + ax[0]._lonaxis.get_minorticklocs(), ax[1]._lonaxis.get_minorticklocs() + ) + assert np.allclose( + ax[0]._lataxis.get_minorticklocs(), ax[1]._lataxis.get_minorticklocs() + ) + + formatter = mticker.FormatStrFormatter("%.1f") + ax[1].format(lonformatter=formatter, latformatter=formatter) + lonformatter = ax[1]._lonaxis.get_major_formatter() + latformatter = ax[1]._lataxis.get_major_formatter() + assert ax[0]._lonaxis.get_major_formatter() is lonformatter + assert ax[0]._lataxis.get_major_formatter() is latformatter + assert left_gridliner.xformatter is lonformatter + assert left_gridliner.yformatter is latformatter + uplt.close(fig) + + +def test_sync_shared_tick_state_guards(): + pytest.importorskip("cartopy") + fig, ax = uplt.subplots(ncols=2, proj="cyl") + + ax[0]._sync_shared_tick_state("x") + ax[0]._sync_shared_tick_state("x", copy_major_locator=True) + ax[0]._sync_shared_tick_state("y", copy_major_locator=True) + with pytest.raises(ValueError, match="Invalid axis"): + ax[0]._sync_shared_tick_state("z", copy_major_locator=True) uplt.close(fig)