From ea6d34dee03c83b1042bc3b794a53e7dc16b20c7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 2 Jun 2025 12:58:38 +0200 Subject: [PATCH 01/62] fix twin sharing --- ultraplot/axes/cartesian.py | 74 +++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index df6c1a6c9..5f4940504 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -449,6 +449,40 @@ def _add_alt(self, sx, **kwargs): getattr(ax, "patch").set_visible(False) return ax + def _adjust_border_labels( + self, + border_axes, + labeltop=False, + labelright=False, + ): + """ + Cleanly adjust border axes to show or hide top/right tick labels + without breaking axis sharing. + """ + for side, axs in border_axes.items(): + for ax in axs: + # Skip twin axes that are already handled by main axes + if ax in self._twinned_axes: + continue + + if side == "right" and labelright: + # Show labels on the right, hide on the left + ax.tick_params(labelright=True, labelleft=False) + + elif side == "left" and labelright: + # Ensure we don’t also show labels on the right if not mirrored + if ax not in border_axes.get("right", []): + ax.tick_params(labelright=False, labelleft=True) + + elif side == "top" and labeltop: + # Show labels on top, hide on bottom + ax.tick_params(labeltop=True, labelbottom=False) + + elif side == "bottom" and labeltop: + # Ensure we don’t also show labels on top if not mirrored + if ax not in border_axes.get("top", []): + ax.tick_params(labeltop=False, labelbottom=True) + def _dual_scale(self, s, funcscale=None): """ Lock the child "dual" axis limits to the parent. @@ -921,46 +955,16 @@ def _validate_loc(loc, opts, descrip): # sharex/y off for those plots. We still # update the ticks with the prior step, and # keep the references in _shared_axes for all plots - labellright = kw.pop("labelright", None) + labelright = kw.pop("labelright", None) labeltop = kw.pop("labeltop", None) nrows, ncols = self.figure.gridspec.nrows, self.figure.gridspec.ncols border_axes = {} - if labellright and self.figure._sharey or labeltop and self.figure._sharex: + if labelright and self.figure._sharey or labeltop and self.figure._sharex: border_axes = self.figure._get_border_axes() - # Only update if above is conditions above are true - for side, axs in border_axes.items(): - for axi in axs: - if axi in self._twinned_axes: - continue - # Unset sharex/y otherwise ticks - # won't appear - if labellright and side == "right": - axi._sharey = None - siblings = list(axi._shared_axes["y"].get_siblings(axi)) - for sibling in siblings: - if sibling is axi: - continue - if sibling._sharey is not None: - continue - sibling._sharey = axi - axi.tick_params(labelright=labellright) - elif labellright and side == "left": - if axi not in border_axes["right"]: - axi.tick_params(labelright=not labellright) - elif labeltop and side == "top": - axi._sharex = None - siblings = list(axi._shared_axes["x"].get_siblings(axi)) - for sibling in siblings: - if sibling is axi: - continue - if sibling._sharex is not None: - continue - sibling._sharex = axi - axi.tick_params(labeltop=labeltop) - elif labeltop and side == "bottom": - if axi not in border_axes["top"]: - axi.tick_params(labeltop=not labeltop) + self._adjust_border_labels( + border_axes, labeltop=labeltop, labelright=labelright + ) # Apply the axis label and offset label locations # Uses ugly mpl 3.3+ tick_top() tick_bottom() kludge for offset location From f7323dddc63327a1897d3e425e2bb57be03edc27 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 3 Jun 2025 00:44:56 +0200 Subject: [PATCH 02/62] add typing to overrides --- ultraplot/axes/shared.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/shared.py b/ultraplot/axes/shared.py index 8fd252d05..4d032a747 100644 --- a/ultraplot/axes/shared.py +++ b/ultraplot/axes/shared.py @@ -11,6 +11,7 @@ from ..internals import _pop_kwargs from ..utils import _fontsize_to_pt, _not_none, units from ..axes import Axes +from typing import override class _SharedAxes(object): @@ -186,10 +187,11 @@ def _update_ticks( for lab in obj.get_ticklabels(): lab.update(kwtext_extra) - # Override matplotlib defaults to handle multiple axis sharing + @override def sharex(self, other): return self._share_axis_with(other, which="x") + @override def sharey(self, other): self._share_axis_with(other, which="y") From 75f195572d4d8b85f54ee89fc828a62b4051b9a9 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 3 Jun 2025 00:45:12 +0200 Subject: [PATCH 03/62] move around share and remove forcing of ticks on draw --- ultraplot/axes/cartesian.py | 87 ++++++++++++------------------------- 1 file changed, 28 insertions(+), 59 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 5f4940504..95b0ee4f2 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -387,10 +387,6 @@ def _apply_axis_sharing(self): if level > 0: labels._transfer_label(axis.label, self._sharex.xaxis.label) axis.label.set_visible(False) - if level > 2: - # WARNING: Cannot set NullFormatter because shared axes share the - # same Ticker(). Instead use approach copied from mpl subplots(). - axis.set_tick_params(which="both", labelbottom=False, labeltop=False) # Y axis axis = self.yaxis if self._sharey is not None and axis.get_visible(): @@ -398,8 +394,6 @@ def _apply_axis_sharing(self): if level > 0: labels._transfer_label(axis.label, self._sharey.yaxis.label) axis.label.set_visible(False) - if level > 2: - axis.set_tick_params(which="both", labelleft=False, labelright=False) axis.set_minor_formatter(mticker.NullFormatter()) def _add_alt(self, sx, **kwargs): @@ -449,40 +443,6 @@ def _add_alt(self, sx, **kwargs): getattr(ax, "patch").set_visible(False) return ax - def _adjust_border_labels( - self, - border_axes, - labeltop=False, - labelright=False, - ): - """ - Cleanly adjust border axes to show or hide top/right tick labels - without breaking axis sharing. - """ - for side, axs in border_axes.items(): - for ax in axs: - # Skip twin axes that are already handled by main axes - if ax in self._twinned_axes: - continue - - if side == "right" and labelright: - # Show labels on the right, hide on the left - ax.tick_params(labelright=True, labelleft=False) - - elif side == "left" and labelright: - # Ensure we don’t also show labels on the right if not mirrored - if ax not in border_axes.get("right", []): - ax.tick_params(labelright=False, labelleft=True) - - elif side == "top" and labeltop: - # Show labels on top, hide on bottom - ax.tick_params(labeltop=True, labelbottom=False) - - elif side == "bottom" and labeltop: - # Ensure we don’t also show labels on top if not mirrored - if ax not in border_axes.get("top", []): - ax.tick_params(labeltop=False, labelbottom=True) - def _dual_scale(self, s, funcscale=None): """ Lock the child "dual" axis limits to the parent. @@ -671,6 +631,19 @@ def _sharex_setup(self, sharex, *, labels=True, limits=True): # labels. But this is done after the fact -- tickers are still shared. if level > 1 and limits: self._sharex_limits(sharex) + # Add the tick label visibility control here for higher sharing levels + if level > 2: + # Check if this is a border axis + border_axes = self.figure._get_border_axes() + is_top_border = self in border_axes.get("top", []) + + # Only hide labels based on border status + if is_top_border: + # For top border axes, only hide bottom labels + self.xaxis.set_tick_params(which="both", labelbottom=False) + else: + # For non-border axes, hide both top and bottom labels + self.xaxis.set_tick_params(which="both", labelbottom=False) def _sharey_setup(self, sharey, *, labels=True, limits=True): """ @@ -692,6 +665,21 @@ def _sharey_setup(self, sharey, *, labels=True, limits=True): self._sharey = sharey if level > 1 and limits: self._sharey_limits(sharey) + # Add the tick label visibility control here for higher sharing levels + if level > 2: + # Check if this is a border axis + border_axes = self.figure._get_border_axes() + is_right_border = self in border_axes.get("right", []) + + # Only hide labels based on border status + if is_right_border: + # For right border axes, only hide left labels + self.yaxis.set_tick_params(which="both", labelleft=False) + else: + # For non-border axes, hide both left and right labels + self.yaxis.set_tick_params( + which="both", labelleft=False, labelright=False + ) def _update_formatter( self, @@ -947,25 +935,6 @@ def _validate_loc(loc, opts, descrip): kw.update({"label" + side: False for side in sides if side not in tickloc}) self.tick_params(axis=s, which="both", **kw) - # When axes are shared, the reference - # is the top left and bottom left plot - # for labels that are top or right - # this will plot them on those first two plots - # we can fool mpl to share them by turning - # sharex/y off for those plots. We still - # update the ticks with the prior step, and - # keep the references in _shared_axes for all plots - labelright = kw.pop("labelright", None) - labeltop = kw.pop("labeltop", None) - - nrows, ncols = self.figure.gridspec.nrows, self.figure.gridspec.ncols - border_axes = {} - if labelright and self.figure._sharey or labeltop and self.figure._sharex: - border_axes = self.figure._get_border_axes() - self._adjust_border_labels( - border_axes, labeltop=labeltop, labelright=labelright - ) - # Apply the axis label and offset label locations # Uses ugly mpl 3.3+ tick_top() tick_bottom() kludge for offset location # See: https://matplotlib.org/3.3.1/users/whats_new.html From 9e951033160c6a42e09b1b818a3bebfbf5295312 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 3 Jun 2025 00:48:36 +0200 Subject: [PATCH 04/62] add two unittests for visual fidelity --- ultraplot/tests/test_axes.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index 8c6bf89d4..e400dadce 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -306,3 +306,27 @@ def check_state(numbers: list, state: bool, which: str): check_state([0, 1, 2], True, which="x") check_state([3, 4], True, which="x") uplt.close(fig) + + +@pytest.mark.mpl_image_compare +def test_alt_axes_y_shared(): + layout = [[1, 2], [3, 4]] + fig, ax = uplt.subplots(ncols=2, nrows=2) + + for axi in ax: + alt = axi.alty() + alt.set_ylabel("Alt Y") + axi.set_ylabel("Y") + return fig + + +@pytest.mark.mpl_image_compare +def test_alt_axes_x_shared(): + layout = [[1, 2], [3, 4]] + fig, ax = uplt.subplots(ncols=2, nrows=2) + + for axi in ax: + alt = axi.altx() + alt.set_xlabel("Alt X") + axi.set_xlabel("X") + return fig From 9a8600dcf8f7f2c94f440b702781b73ef2cda136 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 3 Jun 2025 00:53:57 +0200 Subject: [PATCH 05/62] conditional import on override --- ultraplot/axes/shared.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/shared.py b/ultraplot/axes/shared.py index 4d032a747..57d5abe0b 100644 --- a/ultraplot/axes/shared.py +++ b/ultraplot/axes/shared.py @@ -11,7 +11,13 @@ from ..internals import _pop_kwargs from ..utils import _fontsize_to_pt, _not_none, units from ..axes import Axes -from typing import override + +try: + # From python 3.12 + from typing import override +except ImportError: + # From Python 3.5 + from typing_extensions import override class _SharedAxes(object): From 5a8b936be520ce29c6ea6b20ede8bc395a7ac9f0 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 3 Jun 2025 01:04:44 +0200 Subject: [PATCH 06/62] refator test case --- ultraplot/tests/test_axes.py | 112 ++++++++++++++++++++++------------- 1 file changed, 72 insertions(+), 40 deletions(-) diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index e400dadce..74ccdb17c 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -259,52 +259,84 @@ def test_sharing_labels_top_right(): assert i == j -def test_sharing_labels_top_right_odd_layout(): +@pytest.mark.parametrize( + "layout, share, tick_loc, y_visible_indices, x_visible_indices", + [ + # Test case 1: Irregular layout with share=3 (default) + ( + [ + [1, 2, 0], + [1, 2, 5], + [3, 4, 5], + [3, 4, 0], + ], + 3, # default sharing level + {"xticklabelloc": "t", "yticklabelloc": "r"}, + [1, 3, 4], # y-axis labels visible indices + [0, 1, 4], # x-axis labels visible indices + ), + # Test case 2: Irregular layout with share=1 + ( + [ + [1, 0, 2], + [0, 3, 0], + [4, 0, 5], + ], + 1, # share only labels, not tick labels + {"xticklabelloc": "t", "yticklabelloc": "r"}, + [0, 1, 2, 3, 4], # all y-axis labels visible + [0, 1, 2, 3, 4], # all x-axis labels visible + ), + ], +) +def test_sharing_labels_with_layout( + layout, share, tick_loc, y_visible_indices, x_visible_indices +): + """ + Test if tick labels are correctly visible or hidden based on layout and sharing. + + Parameters + ---------- + layout : list of list of int + The layout configuration for the subplots + share : int + The sharing level (0-4) + tick_loc : dict + Tick label location settings + y_visible_indices : list + Indices in the axes array where y-tick labels should be visible + x_visible_indices : list + Indices in the axes array where x-tick labels should be visible + """ - # Helper function to check if the labels - # on an axis direction is visible - def check_state(numbers: list, state: bool, which: str): + # Helper function to check if the labels on an axis direction are visible + def check_state(ax, numbers, state, which): for number in numbers: for label in getattr(ax[number], f"get_{which}ticklabels")(): - assert label.get_visible() == state - - layout = [ - [1, 2, 0], - [1, 2, 5], - [3, 4, 5], - [3, 4, 0], - ] - fig, ax = uplt.subplots(layout) - ax.format( - xticklabelloc="t", - yticklabelloc="r", - ) + assert label.get_visible() == state, ( + f"Expected {which}-tick label visibility to be {state} " + f"for axis {number}, but got {not state}" + ) - # these correspond to the indices of the axis - # in the axes array (so the grid number minus 1) - check_state([0, 2], False, which="y") - check_state([1, 3, 4], True, which="y") - check_state([2, 3], False, which="x") - check_state([0, 1, 4], True, which="x") - uplt.close(fig) + # Create figure with the specified layout and sharing level + fig, ax = uplt.subplots(layout, share=share) - layout = [ - [1, 0, 2], - [0, 3, 0], - [4, 0, 5], - ] + # Format axes with the specified tick label locations + ax.format(**tick_loc) + + uplt.show(block=1) + + # Calculate the indices where labels should be hidden + all_indices = list(range(len(ax))) + y_hidden_indices = [i for i in all_indices if i not in y_visible_indices] + x_hidden_indices = [i for i in all_indices if i not in x_visible_indices] + + # Check that labels are visible or hidden as expected + check_state(ax, y_visible_indices, True, which="y") + check_state(ax, y_hidden_indices, False, which="y") + check_state(ax, x_visible_indices, True, which="x") + check_state(ax, x_hidden_indices, False, which="x") - fig, ax = uplt.subplots(layout, hspace=0.2, wspace=0.2, share=1) - ax.format( - xticklabelloc="t", - yticklabelloc="r", - ) - # these correspond to the indices of the axis - # in the axes array (so the grid number minus 1) - check_state([0, 3], True, which="y") - check_state([1, 2, 4], True, which="y") - check_state([0, 1, 2], True, which="x") - check_state([3, 4], True, which="x") uplt.close(fig) From ce599bb3e314ae0b5216dd005fa199421e7c536d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 3 Jun 2025 01:21:34 +0200 Subject: [PATCH 07/62] fix minor issue --- ultraplot/axes/cartesian.py | 40 ++++++++++++++++++++++-------------- ultraplot/figure.py | 10 +++++++++ ultraplot/tests/test_axes.py | 2 -- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 95b0ee4f2..5e8e3a856 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -633,17 +633,7 @@ def _sharex_setup(self, sharex, *, labels=True, limits=True): self._sharex_limits(sharex) # Add the tick label visibility control here for higher sharing levels if level > 2: - # Check if this is a border axis - border_axes = self.figure._get_border_axes() - is_top_border = self in border_axes.get("top", []) - - # Only hide labels based on border status - if is_top_border: - # For top border axes, only hide bottom labels - self.xaxis.set_tick_params(which="both", labelbottom=False) - else: - # For non-border axes, hide both top and bottom labels - self.xaxis.set_tick_params(which="both", labelbottom=False) + self._configure_border_axes_tick_visibility(which="x") def _sharey_setup(self, sharey, *, labels=True, limits=True): """ @@ -667,11 +657,31 @@ def _sharey_setup(self, sharey, *, labels=True, limits=True): self._sharey_limits(sharey) # Add the tick label visibility control here for higher sharing levels if level > 2: - # Check if this is a border axis - border_axes = self.figure._get_border_axes() - is_right_border = self in border_axes.get("right", []) + self._configure_border_axes_tick_visibility(which="y") + + def _configure_border_axes_tick_visibility(self, *, which: str): + """ + Configure the tick visibility for border axes. - # Only hide labels based on border status + Parameters: + which (str): The axis to configure ('x' or 'y'). + """ + # Check if this is a border axis + border_axes = self.figure._get_border_axes() + is_right_border = self in border_axes.get("right", []) + is_top_border = self in border_axes.get("top", []) + + # Only hide labels based on border status + if "x" in which: + if is_top_border: + # For top border axes, only hide bottom labels + self.xaxis.set_tick_params(which="both", labelbottom=False) + else: + # For non-border axes, hide both top and bottom labels + self.xaxis.set_tick_params( + which="both", labelbottom=False, labeltop=False + ) + if "y" in which: if is_right_border: # For right border axes, only hide left labels self.yaxis.set_tick_params(which="both", labelleft=False) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 9c4a480ab..3f92951c8 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1907,6 +1907,16 @@ def format( ax.format(rc_kw=rc_kw, rc_mode=rc_mode, skip_figure=True, **kw, **kwargs) ax.number = store_old_number + # If we are updating all axes; we recompute + # which labels should be showing and which should be + # off + if len(axs) == len(self.axes): + for which in "xy": + labelloc = f"{which}ticklabelloc" + if labelloc not in kw: + continue + ax._configure_border_axes_tick_visibility(which=which) + # Warn unused keyword argument(s) kw = { key: value diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index 74ccdb17c..0714cdacb 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -324,8 +324,6 @@ def check_state(ax, numbers, state, which): # Format axes with the specified tick label locations ax.format(**tick_loc) - uplt.show(block=1) - # Calculate the indices where labels should be hidden all_indices = list(range(len(ax))) y_hidden_indices = [i for i in all_indices if i not in y_visible_indices] From ed44313884e3dbe08081e281ae4b0e26a77f6ad2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 3 Jun 2025 13:57:23 +0200 Subject: [PATCH 08/62] updated tests --- ultraplot/axes/base.py | 17 +++ ultraplot/axes/cartesian.py | 37 ----- ultraplot/axes/geo.py | 218 ++++++++++++++--------------- ultraplot/figure.py | 144 +++++++++---------- ultraplot/tests/test_geographic.py | 72 ++++++++-- ultraplot/utils.py | 134 ++++++++++++++++++ 6 files changed, 389 insertions(+), 233 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 89b1c4662..525b403f5 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3185,6 +3185,23 @@ def _is_panel_group_member(self, other: "Axes") -> bool: return True # Not in the same panel group + + def _is_ticklabel_on(self, side: str) -> bool: + """ + Check if tick labels are on for the specified sides. + """ + # NOTE: This is a helper function to check if tick labels are on + # for the specified sides. It returns True if any of the specified + # sides have tick labels turned on. + axis = self.xaxis + if side in ["labelleft", "labelright"]: + axis = self.yaxis + label = "label1" + if side in ["labelright", "labeltop"]: + label = "label2" + for tick in axis.get_major_ticks(): + if getattr(tick, label).get_visible(): + return True return False @docstring._snippet_manager diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index b9c822076..31d2bfddd 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -620,8 +620,6 @@ def _sharex_setup(self, sharex, *, labels=True, limits=True): if level > 1 and limits: self._sharex_limits(sharex) # Add the tick label visibility control here for higher sharing levels - if level > 2: - self._configure_border_axes_tick_visibility(which="x") def _sharey_setup(self, sharey, *, labels=True, limits=True): """ @@ -643,41 +641,6 @@ def _sharey_setup(self, sharey, *, labels=True, limits=True): self._sharey = sharey if level > 1 and limits: self._sharey_limits(sharey) - # Add the tick label visibility control here for higher sharing levels - if level > 2: - self._configure_border_axes_tick_visibility(which="y") - - def _configure_border_axes_tick_visibility(self, *, which: str): - """ - Configure the tick visibility for border axes. - - Parameters: - which (str): The axis to configure ('x' or 'y'). - """ - # Check if this is a border axis - border_axes = self.figure._get_border_axes() - is_right_border = self in border_axes.get("right", []) - is_top_border = self in border_axes.get("top", []) - - # Only hide labels based on border status - if "x" in which: - if is_top_border: - # For top border axes, only hide bottom labels - self.xaxis.set_tick_params(which="both", labelbottom=False) - else: - # For non-border axes, hide both top and bottom labels - self.xaxis.set_tick_params( - which="both", labelbottom=False, labeltop=False - ) - if "y" in which: - if is_right_border: - # For right border axes, only hide left labels - self.yaxis.set_tick_params(which="both", labelleft=False) - else: - # For non-border axes, hide both left and right labels - self.yaxis.set_tick_params( - which="both", labelleft=False, labelright=False - ) def _update_formatter( self, diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 57d3bc892..234a206de 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -585,9 +585,6 @@ def __share_axis_setup( if level > 1 and limits: self._share_limits_with(other, which=which) - if level >= 1 and labels: - self._share_labels_with_others() - @override def _sharey_setup(self, sharey, *, labels=True, limits=True): """ @@ -676,9 +673,6 @@ def _apply_axis_sharing(self): return if self.figure._get_sharing_level() == 0: return - # Share labels with all levels higher or equal - # to 1. - self._share_labels_with_others() def _get_gridliner_labels( self, @@ -687,33 +681,30 @@ def _get_gridliner_labels( left=None, right=None, ): - assert NotImplementedError("Should be implemented by Cartopy or Basemap Axes") + raise NotImplementedError("Should be implemented by Cartopy or Basemap Axes") def _toggle_gridliner_labels( self, - top=None, - bottom=None, - left=None, - right=None, + labeltop=None, + labelbottom=None, + labelleft=None, + labelright=None, geo=None, ): # For BasemapAxes the gridlines are dicts with key as the coordinate and keys the line and label # We override the dict here assuming the labels are mut excl due to the N S E W extra chars - if self.gridlines_major is None: - return if any(i is None for i in self.gridlines_major): return gridlabels = self._get_gridliner_labels( - bottom=bottom, top=top, left=left, right=right + bottom=labelbottom, top=labeltop, left=labelleft, right=labelright ) - for direction, toggle in zip( - "bottom top left right".split(), - [bottom, top, left, right], - ): - if toggle is not None: - for label in gridlabels.get(direction, []): - label.set_visible(toggle) - self.stale = True + bools = [labelbottom, labeltop, labelleft, labelright] + directions = "bottom top left right".split() + for direction, toggle in zip(directions, bools): + if toggle is None: + continue + for label in gridlabels.get(direction, []): + label.set_visible(toggle) def _handle_axis_sharing( self, @@ -733,48 +724,6 @@ def _handle_axis_sharing( target_axis.set_view_interval(*source_axis.get_view_interval()) target_axis.set_minor_locator(source_axis.get_minor_locator()) - def _share_labels_with_others(self): - """ - Helpers function to ensure the labels - are shared for rectilinear GeoAxes. - """ - # Turn all labels off - # Note: this action performs it for all the axes in - # the figure. We use the stale here to only perform - # it once as it is an expensive action. - border_axes = self.figure._get_border_axes() - # Recode: - recoded = {} - for direction, axes in border_axes.items(): - for axi in axes: - recoded[axi] = recoded.get(axi, []) + [direction] - - # We turn off the tick labels when the scale and - # ticks are shared (level >= 3) - are_ticks_on = False - default = dict( - left=are_ticks_on, - right=are_ticks_on, - top=are_ticks_on, - bottom=are_ticks_on, - ) - for axi in self.figure.axes: - # If users call colorbar on the figure - # an axis is added which needs to skip the - # sharing that is specific for the GeoAxes. - if not isinstance(axi, GeoAxes): - continue - gridlabels = self._get_gridliner_labels( - bottom=True, top=True, left=True, right=True - ) - sides = recoded.get(axi, []) - tmp = default.copy() - for side in sides: - if side in gridlabels and gridlabels[side]: - tmp[side] = True - axi._toggle_gridliner_labels(**tmp) - self.stale = False - @override def draw(self, renderer=None, *args, **kwargs): # Perform extra post-processing steps @@ -1465,12 +1414,31 @@ def _get_side_labels() -> tuple: top_labels = "xlabels_top" return (left_labels, right_labels, bottom_labels, top_labels) + @override + def _is_ticklabel_on(self, side: str) -> bool: + """ + Helper function to check if tick labels are on for a given side. + """ + if self.gridlines_major is None: + return False + elif side == "labelleft": + return self.gridlines_major.left_labels + elif side == "labelright": + return self.gridlines_major.right_labels + elif side == "labelbottom": + return self.gridlines_major.bottom_labels + elif side == "labeltop": + return self.gridlines_major.top_labels + else: + raise ValueError(f"Invalid side: {side}") + + @override def _toggle_gridliner_labels( self, - left=None, - right=None, - bottom=None, - top=None, + labelleft=None, + labelright=None, + labelbottom=None, + labeltop=None, geo=None, ): """ @@ -1480,14 +1448,14 @@ def _toggle_gridliner_labels( _CartopyAxes._get_side_labels() ) gl = self.gridlines_major - if left is not None: - setattr(gl, left_labels, left) - if right is not None: - setattr(gl, right_labels, right) - if bottom is not None: - setattr(gl, bottom_labels, bottom) - if top is not None: - setattr(gl, top_labels, top) + if labelleft is not None: + setattr(gl, left_labels, labelleft) + if labelright is not None: + setattr(gl, right_labels, labelright) + if labelbottom is not None: + setattr(gl, bottom_labels, labelbottom) + if labeltop is not None: + setattr(gl, top_labels, labeltop) if geo is not None: # only cartopy 0.20 supported but harmless setattr(gl, "geo_labels", geo) @@ -1779,7 +1747,7 @@ def _update_major_gridlines( sides = dict() # The ordering of these sides are important. The arrays are ordered lrbtg for side, lon, lat in zip( - "left right bottom top geo".split(), lonarray, latarray + "labelleft labelright labelbottom labeltop geo".split(), lonarray, latarray ): if lon and lat: sides[side] = True @@ -1976,6 +1944,40 @@ def _turnoff_tick_labels(self, locator: mticker.Formatter): if isinstance(object, mtext.Text): object.set_visible(False) + def _get_gridliner_labels( + self, + bottom=None, + top=None, + left=None, + right=None, + ): + directions = "left right top bottom".split() + bools = [left, right, top, bottom] + sides = {} + for direction, is_on in zip(directions, bools): + if is_on is None: + continue + gl = self.gridlines_major[0] + if direction in ["left", "right"]: + gl = self.gridlines_major[1] + for loc, (lines, labels) in gl.items(): + for label in labels: + position = label.get_position() + match direction: + case "top" if position[1] > 0: + add = True + case "bottom" if position[1] < 0: + add = True + case "left" if position[0] < 0: + add = True + case "right" if position[0] > 0: + add = True + case _: + add = False + if add: + sides.setdefault(direction, []).append(label) + return sides + def _get_lon0(self): """ Get the central longitude. @@ -2196,7 +2198,7 @@ def _update_major_gridlines( ) sides = {} for side, lonon, laton in zip( - "left right top bottom geo".split(), lonarray, latarray + "labelleft labelright labeltop labelbottom geo".split(), lonarray, latarray ): if lonon or laton: sides[side] = True @@ -2225,13 +2227,7 @@ def _update_minor_gridlines(self, longrid=None, latgrid=None, nsteps=None): axis.isDefault_minloc = True @override - def _get_gridliner_labels( - self, - bottom=None, - top=None, - left=None, - right=None, - ): + def _is_ticklabel_on(self, side: str) -> bool: # For basemap object, the text is organized # as a dictionary. The keys are the numerical # location values, and the values are a list @@ -2245,10 +2241,10 @@ def _get_gridliner_labels( def group_labels( labels: list[mtext.Text], which: str, - bottom=None, - top=None, - left=None, - right=None, + labelbottom=None, + labeltop=None, + labelleft=None, + labelright=None, ) -> dict[str, list[mtext.Text]]: group = {} # We take zero here as a baseline @@ -2256,34 +2252,36 @@ def group_labels( position = label.get_position() target = None if which == "x": - if bottom is not None and position[1] < 0: - target = "bottom" - elif top is not None and position[1] >= 0: - target = "top" + if labelbottom is not None and position[1] < 0: + target = "labelbottom" + elif labeltop is not None and position[1] >= 0: + target = "labeltop" else: - if left is not None and position[0] < 0: - target = "left" - elif right is not None and position[0] >= 0: - target = "right" + if labelleft is not None and position[0] < 0: + target = "labelleft" + elif labelright is not None and position[0] >= 0: + target = "labelright" if target is not None: group[target] = group.get(target, []) + [label] return group + gl = self.gridlines_major[0] + which = "x" + if side in ["labelleft", "labelright"]: + gl = self.gridlines_major[1] + which = "y" # Group the text object based on their location grouped = {} - for which, gl in zip("xy", self.gridlines_major): - for loc, (line, labels) in gl.items(): - tmp = group_labels( - labels=labels, - which=which, - bottom=bottom, - top=top, - left=left, - right=right, - ) - for key, values in tmp.items(): - grouped[key] = grouped.get(key, []) + values - return grouped + for loc, (line, labels) in gl.items(): + labels = group_labels( + labels=labels, + which=which, + **{side: True}, + ) + for label in labels[side]: + if label.get_visible(): + return True + return False # Apply signature obfuscation after storing previous signature diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 3f92951c8..e84064c99 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -35,7 +35,7 @@ labels, warnings, ) -from .utils import units +from .utils import units, _get_subplot_layout, Crawler __all__ = [ "Figure", @@ -905,7 +905,7 @@ def _get_align_axes(self, side): axs = [ax for ax in axs if ax.get_visible()] return axs - def _get_border_axes(self) -> dict[str, list[paxes.Axes]]: + def _get_border_axes(self, *, same_type=False) -> dict[str, list[paxes.Axes]]: """ Identifies axes located on the outer boundaries of the GridSpec layout. @@ -929,68 +929,28 @@ def _get_border_axes(self) -> dict[str, list[paxes.Axes]]: # Reconstruct the grid based on axis locations. Note that # spanning axes will fit into one of the boxes. Check # this with unittest to see how empty axes are handles - grid = np.zeros((gs.nrows, gs.ncols)) - for axi in all_axes: - # Infer coordinate from grdispec - spec = axi.get_subplotspec() - spans = spec._get_rows_columns() - rowspans = spans[:2] - colspans = spans[-2:] - - grid[ - rowspans[0] : rowspans[1] + 1, - colspans[0] : colspans[1] + 1, - ] = axi.number - directions = { - "left": (0, -1), - "right": (0, 1), - "top": (-1, 0), - "bottom": (1, 0), - } - - def is_border(pos, grid, target, direction): - x, y = pos - # Check if we are at an edge of the grid (out-of-bounds). - if x < 0: - return True - elif x > grid.shape[0] - 1: - return True - - if y < 0: - return True - elif y > grid.shape[1] - 1: - return True - - # Check if we reached a plot or an internal edge - if grid[x, y] != target and grid[x, y] > 0: - return False - if grid[x, y] == 0: - return True - dx, dy = direction - new_pos = (x + dx, y + dy) - return is_border(new_pos, grid, target, direction) - - from itertools import product + grid, grid_axis_type, seen_axis_type = _get_subplot_layout( + gs, + all_axes, + same_type=same_type, + ) + # We check for all axes is they are a border or not + # Note we could also write the crawler in a way where + # it find the borders by moving around in the grid, without spawning on each axis point. We may change + # this in the future for axi in all_axes: - spec = axi.get_subplotspec() - spans = spec._get_rows_columns() - rowspan = spans[:2] - colspan = spans[-2:] - # Check all cardinal directions. When we find a - # border for any starting conditions we break and - # consider it a border. This could mean that for some - # partial overlaps we consider borders that should - # not be borders -- we are conservative in this - # regard - for direction, d in directions.items(): - xs = range(rowspan[0], rowspan[1] + 1) - ys = range(colspan[0], colspan[1] + 1) - for x, y in product(xs, ys): - pos = (x, y) - if is_border(pos=pos, grid=grid, target=axi.number, direction=d): - border_axes[direction].append(axi) - break + axis_type = seen_axis_type.get(type(axi), 1) + crawler = Crawler( + ax=axi, + grid=grid, + target=axi.number, + axis_type=axis_type, + grid_axis_type=grid_axis_type, + ) + for direction, is_border in crawler.find_edges(): + if is_border: + border_axes[direction].append(axi) return border_axes def _get_align_coord(self, side, axs, includepanels=False): @@ -1907,15 +1867,10 @@ def format( ax.format(rc_kw=rc_kw, rc_mode=rc_mode, skip_figure=True, **kw, **kwargs) ax.number = store_old_number - # If we are updating all axes; we recompute - # which labels should be showing and which should be - # off - if len(axs) == len(self.axes): - for which in "xy": - labelloc = f"{which}ticklabelloc" - if labelloc not in kw: - continue - ax._configure_border_axes_tick_visibility(which=which) + # When we apply formatting to all axes, we need + # to potentially adjust the labels. + if len(axs) == len(self.axes) and self._get_sharing_level() > 0: + self._share_labels_with_others() # Warn unused keyword argument(s) kw = { @@ -1928,6 +1883,53 @@ def format( f"Ignoring unused projection-specific format() keyword argument(s): {kw}" # noqa: E501 ) + def _share_labels_with_others(self, *, which="both"): + """ + Helpers function to ensure the labels + are shared for rectilinear GeoAxes. + """ + # Turn all labels off + # Note: this action performs it for all the axes in + # the figure. We use the stale here to only perform + # it once as it is an expensive action. + border_axes = self._get_border_axes(same_type=False) + # Recode: + recoded = {} + for direction, axes in border_axes.items(): + for axi in axes: + recoded[axi] = recoded.get(axi, []) + [direction] + + # We turn off the tick labels when the scale and + # ticks are shared (level >= 3) + are_ticks_on = False + default = dict( + labelleft=are_ticks_on, + labelright=are_ticks_on, + labeltop=are_ticks_on, + labelbottom=are_ticks_on, + ) + for axi in self._iter_axes(hidden=False, panels=False, children=False): + # Turn the ticks on or off depending on the position + sides = recoded.get(axi, []) + turn_on_or_off = default.copy() + # The axis will be a border if it is either + # (a) on the edge + # (b) not next to a subplot + # (c) not next to a subplot of the same kind + for side in sides: + sidelabel = f"label{side}" + is_label_on = axi._is_ticklabel_on(sidelabel) + if is_label_on: + # When we are a border an the labels are on + # we keep them on + assert sidelabel in turn_on_or_off + turn_on_or_off[sidelabel] = True + + if isinstance(axi, paxes.GeoAxes): + axi._toggle_gridliner_labels(**turn_on_or_off) + else: + axi.tick_params(which=which, **turn_on_or_off) + @docstring._concatenate_inherited @docstring._snippet_manager def colorbar( diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 4a737a013..e7f792e5a 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -253,14 +253,7 @@ def are_labels_on(ax, which=["top", "bottom", "right", "left"]) -> tuple[bool]: n = 3 settings = dict(land=True, ocean=True, labels="both") fig, ax = uplt.subplots(ncols=n, nrows=n, share="all", proj="cyl") - # Add data and ensure the tests still hold - # Adding a colorbar will change the underlying gridspec, the - # labels should still be correctly treated. - data = np.random.rand(10, 10) - h = ax.imshow(data)[0] - fig.colorbar(h, loc="r") ax.format(**settings) - fig.canvas.draw() # need a draw to trigger ax.draw for sharing expectations = ( [True, False, False, True], @@ -304,27 +297,76 @@ def are_labels_on(ax, which=["top", "bottom", "right", "left"]) -> tuple[bool]: return fig +@pytest.mark.mpl_image_compare +def test_sharing_cartopy_with_colorbar(): + + def are_labels_on(ax, which=["top", "bottom", "right", "left"]) -> tuple[bool]: + gl = ax.gridlines_major + + on = [False, False, False, False] + for idx, labeler in enumerate(which): + if getattr(gl, f"{labeler}_labels"): + on[idx] = True + return on + + n = 3 + settings = dict(land=True, ocean=True, labels="both") + fig, ax = uplt.subplots(ncols=n, nrows=n, share="all", proj="cyl") + ax.format(**settings) + fig, ax = uplt.subplots( + ncols=3, + nrows=3, + proj="cyl", + share="all", + ) + + data = np.random.rand(10, 10) + h = ax.imshow(data)[0] + ax.format(land=True, labels="both") # need this otherwise no labels are printed + fig.colorbar(h, loc="r") + + expectations = ( + [True, False, False, True], + [True, False, False, False], + [True, False, True, False], + [False, False, False, True], + [False, False, False, False], + [False, False, True, False], + [False, True, False, True], + [False, True, False, False], + [False, True, True, False], + ) + for axi in ax: + state = are_labels_on(axi) + expectation = expectations[axi.number - 1] + for i, j in zip(state, expectation): + assert i == j + return fig + + def test_toggle_gridliner_labels(): """ Test whether we can toggle the labels on or off """ # Cartopy backend fig, ax = uplt.subplots(proj="cyl", backend="cartopy") - ax[0]._toggle_gridliner_labels(left=False, bottom=False) + ax[0]._toggle_gridliner_labels(labelleft=False, labelbottom=False) gl = ax[0].gridlines_major assert gl.left_labels == False assert gl.right_labels == False assert gl.top_labels == False assert gl.bottom_labels == False - ax[0]._toggle_gridliner_labels(top=True) + ax[0]._toggle_gridliner_labels(labeltop=True) assert gl.top_labels == True uplt.close(fig) # Basemap backend fig, ax = uplt.subplots(proj="cyl", backend="basemap") ax.format(land=True, labels="both") # need this otherwise no labels are printed - ax[0]._toggle_gridliner_labels(left=False, bottom=False, right=False, top=False) + ax[0]._toggle_gridliner_labels( + labelleft=False, labelbottom=False, labelright=False, labeltop=False + ) gl = ax[0].gridlines_major # All label are off @@ -334,7 +376,7 @@ def test_toggle_gridliner_labels(): assert label.get_visible() == False # Should be off - ax[0]._toggle_gridliner_labels(top=True) + ax[0]._toggle_gridliner_labels(labeltop=True) # Gridliner labels are not added for the top (and I guess right for GeoAxes). # Need to figure out how this is set in matplotlib dir_labels = ax[0]._get_gridliner_labels( @@ -464,10 +506,10 @@ def test_get_gridliner_labels_cartopy(): for bottom, top, left, right in product(bools, bools, bools, bools): ax[0]._toggle_gridliner_labels( - left=left, - right=right, - top=top, - bottom=bottom, + labelleft=left, + labelright=right, + labeltop=top, + labelbottom=bottom, ) fig.canvas.draw() # need draw to retrieve the labels labels = ax[0]._get_gridliner_labels( diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 5e3a7b0fb..dfa657673 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -7,9 +7,12 @@ import functools import re from numbers import Integral, Real +from dataclasses import dataclass +from typing import Generator import matplotlib.colors as mcolors import matplotlib.font_manager as mfonts +from matplotlib.gridspec import GridSpec import numpy as np from matplotlib import rcParams as rc_matplotlib @@ -904,6 +907,137 @@ def units( return result[0] if singleton else result +def _get_subplot_layout( + gs: "GridSpec", + all_axes: list["paxes.Axes"], + same_type=True, +) -> tuple[np.ndarray[int, int], np.ndarray[int, int], dict[type, int]]: + """ + Helper function to determine the grid layout of axes in a GridSpec. It returns a grid of axis numbers and a grid of axis types. This function is used internally to determine the layout of axes in a GridSpec. + """ + grid = np.zeros((gs.nrows, gs.ncols)) + grid_axis_type = np.zeros((gs.nrows, gs.ncols)) + # Collect grouper based on kinds of axes. This + # would allow us to share labels across types + seen_axis_types = {type(axi) for axi in all_axes} + seen_axis_types = {type: idx for idx, type in enumerate(seen_axis_types)} + + for axi in all_axes: + # Infer coordinate from grdispec + spec = axi.get_subplotspec() + spans = spec._get_rows_columns() + rowspans = spans[:2] + colspans = spans[-2:] + + grid[ + rowspans[0] : rowspans[1] + 1, + colspans[0] : colspans[1] + 1, + ] = axi.number + + # Allow grouping of mixed types + axis_type = 1 + if not same_type: + axis_type = seen_axis_types.get(type(axi), 1) + + grid_axis_type[rowspans[0] : rowspans[1] + 1, colspans[0] : colspans[1] + 1] = ( + axis_type + ) + return grid, grid_axis_type, seen_axis_types + + +@dataclass +class Crawler: + """ + A crawler is used to find edges of axes in a grid layout. + This is useful for determining whether to turn shared labels on or depending on the position of an axis in the grispec. + It crawls over the grid in all four cardinal directions and checks whether it reaches a border of the grid or an axis of a different type. It was created as adding colorbars will + change the underlying gridspec and therefore we cannot rely + on the original gridspec to determine whether an axis is a border or not. + """ + + ax: object + grid: np.ndarray[int, int] + grid_axis_type: np.ndarray[int, int] + # The axis number + target: int + # The kind of axis, e.g. 1 for CartesianAxes, 2 for + # PolarAxes, etc. + axis_type: int + directions = { + "left": (0, -1), + "right": (0, 1), + "top": (-1, 0), + "bottom": (1, 0), + } + + def find_edges(self) -> Generator[tuple[str, bool], None, None]: + """ + Check all cardinal directions. When we find a + border for any starting conditions we break and + consider it a border. This could mean that for some + partial overlaps we consider borders that should + not be borders -- we are conservative in this + regard. + """ + for direction, d in self.directions.items(): + yield self.find_edge_for(direction, d) + + def find_edge_for( + self, + direction: str, + d: tuple[int, int], + ) -> tuple[str, bool]: + from itertools import product + + """ + Setup search for a specific direction. + """ + + # Retrieve where the axis is in the grid + spec = self.ax.get_subplotspec() + spans = spec._get_rows_columns() + rowspan = spans[:2] + colspan = spans[-2:] + xs = range(rowspan[0], rowspan[1] + 1) + ys = range(colspan[0], colspan[1] + 1) + is_border = False + for x, y in product(xs, ys): + pos = (x, y) + if self.is_border(pos, d): + is_border = True + break + return direction, is_border + + def is_border( + self, + pos: tuple[int, int], + direction: tuple[int, int], + ) -> bool: + """ + Recursively move over the grid by following the direction. + """ + x, y = pos + # Check if we are at an edge of the grid (out-of-bounds). + if x < 0: + return True + elif x > self.grid.shape[0] - 1: + return True + + if y < 0: + return True + elif y > self.grid.shape[1] - 1: + return True + + # Check if we reached a plot or an internal edge + if self.grid[x, y] != self.target and self.grid[x, y] > 0: + return False + if self.grid[x, y] == 0 or self.grid_axis_type[x, y] != self.axis_type: + return True + dx, dy = direction + pos = (x + dx, y + dy) + return self.is_border(pos, direction) + + # Deprecations shade, saturate = warnings._rename_objs( "0.6.0", From fde835e701bf6d7c50efb1957e0ea3617b4cee2a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 3 Jun 2025 14:18:45 +0200 Subject: [PATCH 09/62] minor fixes --- ultraplot/figure.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index e84064c99..8a9f6ed09 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1215,6 +1215,8 @@ def _add_subplot(self, *args, **kwargs): if ax.number: self._subplot_dict[ax.number] = ax + if self._get_sharing_level() > 2: + self._share_labels_with_others() return ax def _unshare_axes(self): From 6b82dee8aa9b8fd8c6826e4c44c022001b98395f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 3 Jun 2025 23:09:30 +0200 Subject: [PATCH 10/62] this may work --- ultraplot/axes/cartesian.py | 23 ++++++++++++++++++++++- ultraplot/axes/geo.py | 2 +- ultraplot/figure.py | 12 +++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 31d2bfddd..1c8d82208 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -387,6 +387,18 @@ def _apply_axis_sharing(self): if level > 0: labels._transfer_label(axis.label, self._sharex.xaxis.label) axis.label.set_visible(False) + if level > 2: + # WARNING: Cannot set NullFormatter because shared axes share the + # same Ticker(). Instead use approach copied from mpl subplots(). + ticks = axis.get_tick_params() + labeltop = labelbottom = False + + border_axes = self.figure._get_border_axes() + if ticks["top"] and self in border_axes["top"]: + labeltop = True + axis.set_tick_params( + which="both", labeltop=labeltop, labelbottom=labelbottom + ) # Y axis axis = self.yaxis if self._sharey is not None and axis.get_visible(): @@ -394,6 +406,16 @@ def _apply_axis_sharing(self): if level > 0: labels._transfer_label(axis.label, self._sharey.yaxis.label) axis.label.set_visible(False) + if level > 2: + ticks = axis.get_tick_params() + labelright = labelleft = False + border_axes = self.figure._get_border_axes() + if ticks["right"] and self in border_axes["right"]: + labelright = True + + axis.set_tick_params( + which="both", labelleft=labelleft, labelright=labelright + ) axis.set_minor_formatter(mticker.NullFormatter()) def _add_alt(self, sx, **kwargs): @@ -619,7 +641,6 @@ def _sharex_setup(self, sharex, *, labels=True, limits=True): # labels. But this is done after the fact -- tickers are still shared. if level > 1 and limits: self._sharex_limits(sharex) - # Add the tick label visibility control here for higher sharing levels def _sharey_setup(self, sharey, *, labels=True, limits=True): """ diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 234a206de..eda02a917 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -2278,7 +2278,7 @@ def group_labels( which=which, **{side: True}, ) - for label in labels[side]: + for label in labels.get(side, []): if label.get_visible(): return True return False diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 8a9f6ed09..027f8d2e4 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -905,7 +905,9 @@ def _get_align_axes(self, side): axs = [ax for ax in axs if ax.get_visible()] return axs - def _get_border_axes(self, *, same_type=False) -> dict[str, list[paxes.Axes]]: + def _get_border_axes( + self, *, same_type=False, force_recalculate=False + ) -> dict[str, list[paxes.Axes]]: """ Identifies axes located on the outer boundaries of the GridSpec layout. @@ -913,6 +915,8 @@ def _get_border_axes(self, *, same_type=False) -> dict[str, list[paxes.Axes]]: containing a list of axes on that border. """ + if hasattr(self, "_cached_border_axes") and not force_recalculate: + return self._cached_border_axes gs = self.gridspec # Skip colorbars or panels etc @@ -951,6 +955,7 @@ def _get_border_axes(self, *, same_type=False) -> dict[str, list[paxes.Axes]]: for direction, is_border in crawler.find_edges(): if is_border: border_axes[direction].append(axi) + self._cached_border_axes = border_axes return border_axes def _get_align_coord(self, side, axs, includepanels=False): @@ -1215,8 +1220,9 @@ def _add_subplot(self, *args, **kwargs): if ax.number: self._subplot_dict[ax.number] = ax - if self._get_sharing_level() > 2: - self._share_labels_with_others() + # Invalidate border axes cache + if hasattr(self, "_cached_border_axes"): + delattr(self, "_cached_border_axes") return ax def _unshare_axes(self): From 378324caa5e9406f2f15acfa2dc445e15fd5995a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 3 Jun 2025 23:19:20 +0200 Subject: [PATCH 11/62] more fixes --- ultraplot/axes/cartesian.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 1c8d82208..f203cbcdc 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -396,6 +396,8 @@ def _apply_axis_sharing(self): border_axes = self.figure._get_border_axes() if ticks["top"] and self in border_axes["top"]: labeltop = True + if ticks["bottom"] and self in border_axes["bottom"]: + labelbottom = True axis.set_tick_params( which="both", labeltop=labeltop, labelbottom=labelbottom ) @@ -412,7 +414,8 @@ def _apply_axis_sharing(self): border_axes = self.figure._get_border_axes() if ticks["right"] and self in border_axes["right"]: labelright = True - + if ticks["left"] and self in border_axes["left"]: + labelleft = True axis.set_tick_params( which="both", labelleft=labelleft, labelright=labelright ) From 49a073f1910446cb5bd9496984f19df81db21d9d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 4 Jun 2025 11:10:49 +0200 Subject: [PATCH 12/62] also include spanning for determining subplot borders --- ultraplot/figure.py | 1 - ultraplot/utils.py | 20 ++++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 027f8d2e4..bb0e6640e 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -938,7 +938,6 @@ def _get_border_axes( all_axes, same_type=same_type, ) - # We check for all axes is they are a border or not # Note we could also write the crawler in a way where # it find the borders by moving around in the grid, without spawning on each axis point. We may change diff --git a/ultraplot/utils.py b/ultraplot/utils.py index dfa657673..3f5eae584 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1030,7 +1030,27 @@ def is_border( # Check if we reached a plot or an internal edge if self.grid[x, y] != self.target and self.grid[x, y] > 0: + # check if we reached a border that has the same x and y span + ispan = self.ax.get_subplotspec()._get_rows_columns() + onumber = int(self.grid[x, y]) + if onumber == 0: + return True + other = self.ax.figure.axes[onumber - 1].get_subplotspec() + ospan = other._get_rows_columns() + + rowspan = ispan[1] + 1 - ispan[0] + colspan = ispan[3] + 1 - ispan[2] + orowspan = ospan[1] + 1 - ospan[0] + ocolspan = ospan[3] + 1 - ospan[2] + dx, dy = direction + if dx == 0: + if rowspan != orowspan: + return True + elif dy == 0: + if colspan != ocolspan: + return True return False + if self.grid[x, y] == 0 or self.grid_axis_type[x, y] != self.axis_type: return True dx, dy = direction From 13d0f6d366c3cc83b52f121f7743d49de5123967 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 4 Jun 2025 11:17:48 +0200 Subject: [PATCH 13/62] also include spanning for determining subplot borders --- ultraplot/utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 3f5eae584..c0638229e 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1038,10 +1038,11 @@ def is_border( other = self.ax.figure.axes[onumber - 1].get_subplotspec() ospan = other._get_rows_columns() - rowspan = ispan[1] + 1 - ispan[0] - colspan = ispan[3] + 1 - ispan[2] - orowspan = ospan[1] + 1 - ospan[0] - ocolspan = ospan[3] + 1 - ospan[2] + # Check if our spans are the same + rowspan = ispan[1] - ispan[0] + colspan = ispan[3] - ispan[2] + orowspan = ospan[1] - ospan[0] + ocolspan = ospan[3] - ospan[2] dx, dy = direction if dx == 0: if rowspan != orowspan: From 1ab03258bb55f07f3f771b3a62db9fbbbd6087dc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 4 Jun 2025 14:58:55 +0200 Subject: [PATCH 14/62] stash --- ultraplot/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index c0638229e..271fad0d3 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1037,19 +1037,22 @@ def is_border( return True other = self.ax.figure.axes[onumber - 1].get_subplotspec() ospan = other._get_rows_columns() + print(self.grid) # Check if our spans are the same rowspan = ispan[1] - ispan[0] colspan = ispan[3] - ispan[2] orowspan = ospan[1] - ospan[0] ocolspan = ospan[3] - ospan[2] - dx, dy = direction + print(rowspan, colspan, orowspan, ocolspan) + dy, dx = direction if dx == 0: if rowspan != orowspan: return True elif dy == 0: if colspan != ocolspan: return True + print("here") return False if self.grid[x, y] == 0 or self.grid_axis_type[x, y] != self.axis_type: From 94598f4cb6d09ef2f5cab9d87729a23afb1eeebf Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 10:55:35 +0200 Subject: [PATCH 15/62] spelling --- ultraplot/axes/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 42cba3aec..2728607c9 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -1589,7 +1589,7 @@ def shared(paxs): return [pax for pax in paxs if not pax._panel_hidden and pax._panel_share] # Internal axis sharing, share stacks of panels and main axes with each other - # NOTE: This is called on the main axes whenver a panel is created. + # NOTE: This is called on the main axes whenever a panel is created. # NOTE: This block is why, even though we have figure-wide share[xy], we # still need the axes-specific _share[xy]_override attribute. if not self._panel_side: # this is a main axes From 4629a3fdc447bfdcea36b2e06d64ede2e07a1fba Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 10:56:14 +0200 Subject: [PATCH 16/62] clean up logic for apply sharing and add label handler --- ultraplot/axes/cartesian.py | 163 +++++++++++++++++++++++++++--------- 1 file changed, 125 insertions(+), 38 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index f203cbcdc..2bff94b2d 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -16,6 +16,7 @@ from ..internals import ic # noqa: F401 from ..internals import _not_none, _pop_rc, _version_mpl, docstring, labels, warnings from . import plot, shared +import matplotlib.axis as maxis __all__ = ["CartesianAxes"] @@ -381,46 +382,132 @@ def _apply_axis_sharing(self): # NOTE: The "panel sharing group" refers to axes and panels *above* the # bottommost or to the *right* of the leftmost panel. But the sharing level # used for the leftmost and bottommost is the *figure* sharing level. - axis = self.xaxis - if self._sharex is not None and axis.get_visible(): - level = 3 if self._panel_sharex_group else self.figure._sharex - if level > 0: - labels._transfer_label(axis.label, self._sharex.xaxis.label) - axis.label.set_visible(False) - if level > 2: - # WARNING: Cannot set NullFormatter because shared axes share the - # same Ticker(). Instead use approach copied from mpl subplots(). - ticks = axis.get_tick_params() - labeltop = labelbottom = False - - border_axes = self.figure._get_border_axes() - if ticks["top"] and self in border_axes["top"]: - labeltop = True - if ticks["bottom"] and self in border_axes["bottom"]: - labelbottom = True - axis.set_tick_params( - which="both", labeltop=labeltop, labelbottom=labelbottom - ) - # Y axis - axis = self.yaxis - if self._sharey is not None and axis.get_visible(): - level = 3 if self._panel_sharey_group else self.figure._sharey - if level > 0: - labels._transfer_label(axis.label, self._sharey.yaxis.label) - axis.label.set_visible(False) - if level > 2: - ticks = axis.get_tick_params() - labelright = labelleft = False - border_axes = self.figure._get_border_axes() - if ticks["right"] and self in border_axes["right"]: - labelright = True - if ticks["left"] and self in border_axes["left"]: - labelleft = True - axis.set_tick_params( - which="both", labelleft=labelleft, labelright=labelright - ) + + # Get border axes once for efficiency + border_axes = self.figure._get_border_axes() + + # Apply X axis sharing + self._apply_axis_sharing_for_axis("x", border_axes) + + # Apply Y axis sharing + self._apply_axis_sharing_for_axis("y", border_axes) + + def _apply_axis_sharing_for_axis(self, axis_name, border_axes): + """ + Apply axis sharing for a specific axis (x or y). + + Parameters + ---------- + axis_name : str + Either 'x' or 'y' + border_axes : dict + Dictionary from _get_border_axes() containing border information + """ + if axis_name == "x": + axis = self.xaxis + shared_axis = self._sharex + panel_group = self._panel_sharex_group + sharing_level = self.figure._sharex + label_params = ["labeltop", "labelbottom"] + border_sides = ["top", "bottom"] + else: # axis_name == 'y' + axis = self.yaxis + shared_axis = self._sharey + panel_group = self._panel_sharey_group + sharing_level = self.figure._sharey + label_params = ["labelleft", "labelright"] + border_sides = ["left", "right"] + + if shared_axis is None or not axis.get_visible(): + return + + level = 3 if panel_group else sharing_level + + # Handle axis label sharing (level > 0) + if level > 0: + shared_axis_obj = getattr(shared_axis, f"{axis_name}axis") + labels._transfer_label(axis.label, shared_axis_obj.label) + axis.label.set_visible(False) + + # Handle tick label sharing (level > 2) + if level > 2: + label_visibility = self._determine_tick_label_visibility( + axis, + shared_axis, + axis_name, + label_params, + border_sides, + border_axes, + ) + axis.set_tick_params(which="both", **label_visibility) + + # Set minor formatter for last processed axis axis.set_minor_formatter(mticker.NullFormatter()) + def _determine_tick_label_visibility( + self, + axis: maxis.Axis, + shared_axis: maxis.Axis, + axis_name: str, + label_params: list, + border_sides: list, + border_axes: dict, + ) -> dict: + """ + Determine which tick labels should be visible based on sharing rules and borders. + + Parameters + ---------- + axis : matplotlib axis + The current axis object + shared_axis : Axes + The axes this one shares with + axis_name : str + Either 'x' or 'y' + label_params : list + List of label parameter names (e.g., ['labeltop', 'labelbottom']) + border_sides : list + List of border side names (e.g., ['top', 'bottom']) + border_axes : dict + Dictionary from _get_border_axes() + + Returns + ------- + dict + Dictionary of label visibility parameters + """ + ticks = axis.get_tick_params() + shared_axis_obj = getattr(shared_axis, f"{axis_name}axis") + sharing_ticks = shared_axis_obj.get_tick_params() + + label_visibility = {} + + for label_param, border_side in zip(label_params, border_sides): + # Check if user has explicitly set label location via format() + user_override = getattr(self, f"_user_{axis_name}ticklabelloc", None) + + if self._panel_dict[border_side]: + label_visibility[label_param] = False + elif user_override is not None: + # Use user's explicit choice - handle different formats + side_name = border_side # 'top', 'bottom', 'left', 'right' + # Handle short forms: 't', 'b', 'l', 'r' + side_short = side_name[0] # 't', 'b', 'l', 'r' + + label_visibility[label_param] = ( + user_override == side_name + or user_override == side_short + or user_override == "both" + or user_override == "all" + ) + else: + # Use automatic border detection logic + label_visibility[label_param] = ( + ticks[label_param] or sharing_ticks[label_param] + ) and self in border_axes.get(border_side, []) + + return label_visibility + def _add_alt(self, sx, **kwargs): """ Add an alternate axes. From 8eb854072653ce8785b519c8348c84bcf853c733 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 10:56:30 +0200 Subject: [PATCH 17/62] set default for border_axes --- ultraplot/figure.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 7102b3e0f..e7e8b95af 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -917,6 +917,13 @@ def _get_border_axes( if hasattr(self, "_cached_border_axes") and not force_recalculate: return self._cached_border_axes + + border_axes = dict( + left=[], + right=[], + top=[], + bottom=[], + ) gs = self.gridspec if gs is None: return border_axes From 8b42a6ec67d2a9e856c35d0e394e75d8abaf1523 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 10:59:32 +0200 Subject: [PATCH 18/62] merge continue --- ultraplot/axes/cartesian.py | 1 - ultraplot/tests/test_geographic.py | 4 ---- 2 files changed, 5 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 2bff94b2d..7b0accd4d 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -374,7 +374,6 @@ def _apply_axis_sharing(self): Enforce the "shared" axis labels and axis tick labels. If this is not called at drawtime, "shared" labels can be inadvertantly turned off. """ - # X axis # NOTE: Critical to apply labels to *shared* axes attributes rather # than testing extents or we end up sharing labels with twin axes. # NOTE: Similar to how _align_super_labels() calls _apply_title_above() this diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index f39f5029e..19379b66d 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -367,14 +367,10 @@ def test_toggle_gridliner_labels(): fig, ax = uplt.subplots(proj="cyl", backend="basemap") ax.format(land=True, labels="both") # need this otherwise no labels are printed ax[0]._toggle_gridliner_labels( -<<<<<<< HEAD - labelleft=False, labelbottom=False, labelright=False, labeltop=False -======= labelleft=False, labelbottom=False, labelright=False, labeltop=False, ->>>>>>> main ) gl = ax[0].gridlines_major From 4da6a73f988f3101df39da49406a783fefbefead Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 10:59:55 +0200 Subject: [PATCH 19/62] add sharing tests --- ultraplot/tests/test_subplots.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 05e609f46..6c71e4443 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -215,3 +215,33 @@ def test_axis_sharing(share): assert ax[2].get_ylabel() == "D" return fig + + +@pytest.parametrize( + "layout", + [ + [[1, 2], [3, 4]], # simple 2x2 + [[1, 0, 2], [0, 3, 0], [4, 0, 5]], # complex 3x3 with independent plots + [[0, 0, 1, 1, 0, 0], [0, 2, 2, 3, 3, 0]], # 1 spanning 2 different plot + ], +) +@pytest.mark.mpl_image_compare +def test_check_label_sharing_top_right(layout): + fig, ax = uplt.subplots(layout) + ax.format( + xticklabelloc="t", + yticklabelloc="r", + xlabel="xlabel", + ylabel="ylabel", + title="Test Title", + ) + return fig + + +@pytest.mark.parametrize("layout", [[1, 2], [3, 4]]) +@pytest.mark.mpl_image_compare +def test_panel_sharing_top_right(): + fig, ax = uplt.subplots(layout) + for dir in "left right top bottom".split(): + ax[0].panel(dir) + return fig From f2390b06831efe2de2b1061264300ac25d9017ed Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 11:00:06 +0200 Subject: [PATCH 20/62] fix typo --- ultraplot/tests/test_subplots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 6c71e4443..fb73520d2 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -217,7 +217,7 @@ def test_axis_sharing(share): return fig -@pytest.parametrize( +@pytest.mark.parametrize( "layout", [ [[1, 2], [3, 4]], # simple 2x2 From 069e730cd25531128c0b37b6842f154d0357a5a2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 11:00:16 +0200 Subject: [PATCH 21/62] rm debug --- ultraplot/utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 271fad0d3..9680a5446 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1037,14 +1037,12 @@ def is_border( return True other = self.ax.figure.axes[onumber - 1].get_subplotspec() ospan = other._get_rows_columns() - print(self.grid) # Check if our spans are the same rowspan = ispan[1] - ispan[0] colspan = ispan[3] - ispan[2] orowspan = ospan[1] - ospan[0] ocolspan = ospan[3] - ospan[2] - print(rowspan, colspan, orowspan, ocolspan) dy, dx = direction if dx == 0: if rowspan != orowspan: @@ -1052,7 +1050,6 @@ def is_border( elif dy == 0: if colspan != ocolspan: return True - print("here") return False if self.grid[x, y] == 0 or self.grid_axis_type[x, y] != self.axis_type: From fc9652d7f37a6ec7d28687f4af0e0d234dafba87 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 11:04:14 +0200 Subject: [PATCH 22/62] add missing param for unittest --- ultraplot/tests/test_subplots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index fb73520d2..8ba80cb0d 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -240,7 +240,7 @@ def test_check_label_sharing_top_right(layout): @pytest.mark.parametrize("layout", [[1, 2], [3, 4]]) @pytest.mark.mpl_image_compare -def test_panel_sharing_top_right(): +def test_panel_sharing_top_right(layout): fig, ax = uplt.subplots(layout) for dir in "left right top bottom".split(): ax[0].panel(dir) From 29cf749336a755aba76783df0acdeb7074e543db Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 11:11:35 +0200 Subject: [PATCH 23/62] turn on axis sharing from figure control --- ultraplot/figure.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index e7e8b95af..6e1216ff2 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1291,18 +1291,7 @@ def _share_labels_with_others(self, *, which="both"): if isinstance(axi, paxes.GeoAxes): axi._toggle_gridliner_labels(**turn_on_or_off) else: - # TODO: we need to replace the - # _apply_axis_sharing with something that is - # more profound. Currently, it removes the - # ticklabels in all directions independent - # of the position of the subplot. This means - # that for top right subplots, the labels - # will always be off. Furthermore, - # this is handled in the draw sequence - # which is not necessary, and we should - # add it to _add_subplot of the figure class - continue - # axi.tick_params(which=which, **turn_on_or_off) + axi._apply_axis_sharing() def _toggle_axis_sharing( self, From 53fe647304317d30618ed28061775f87f84c3961 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 11:11:48 +0200 Subject: [PATCH 24/62] only share when we are actually sharing --- ultraplot/figure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 6e1216ff2..70ef89121 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1933,7 +1933,7 @@ def format( ax.number = store_old_number # When we apply formatting to all axes, we need # to potentially adjust the labels. - if len(axs) == len(self.axes): + if len(axs) == len(self.axes) and self._get_sharing_level() > 0: self._share_labels_with_others() # When we apply formatting to all axes, we need From d81a46599cf4150f163cdb795030523102f78dc9 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 11:34:27 +0200 Subject: [PATCH 25/62] update type hinting --- ultraplot/axes/cartesian.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 7b0accd4d..7e4fa9088 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -448,9 +448,9 @@ def _determine_tick_label_visibility( axis: maxis.Axis, shared_axis: maxis.Axis, axis_name: str, - label_params: list, - border_sides: list, - border_axes: dict, + label_params: list[str], + border_sides: list[str], + border_axes: dict[str, list[plot.PlotAxes]], ) -> dict: """ Determine which tick labels should be visible based on sharing rules and borders. From 7f383b2b284ac6b9a6c28dd898ac0724f79e7ddf Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 11:36:05 +0200 Subject: [PATCH 26/62] update type hinting --- ultraplot/axes/cartesian.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 7e4fa9088..23ccbb193 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -391,7 +391,11 @@ def _apply_axis_sharing(self): # Apply Y axis sharing self._apply_axis_sharing_for_axis("y", border_axes) - def _apply_axis_sharing_for_axis(self, axis_name, border_axes): + def _apply_axis_sharing_for_axis( + self, + axis_name: str, + border_axes: dict[str, plot.PlotAxes], + ) -> None: """ Apply axis sharing for a specific axis (x or y). @@ -451,7 +455,7 @@ def _determine_tick_label_visibility( label_params: list[str], border_sides: list[str], border_axes: dict[str, list[plot.PlotAxes]], - ) -> dict: + ) -> dict[str, bool]: """ Determine which tick labels should be visible based on sharing rules and borders. From 41ba8f1d41980890498ebee0bc43bbbc5c19d26d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 11:39:31 +0200 Subject: [PATCH 27/62] update call count due to internal changes --- ultraplot/tests/test_geographic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 19379b66d..5b3779ba9 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -659,7 +659,7 @@ def test_cartesian_and_geo(): ax[0].pcolormesh(np.random.rand(10, 10)) ax[1].scatter(*np.random.rand(2, 100)) ax[0]._apply_axis_sharing() - assert mocked.call_count == 1 + assert mocked.call_count == 2 return fig From 79fb5a48c4bd382fcdaac2e37442b1d83aa4aa48 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 13:08:24 +0200 Subject: [PATCH 28/62] make crawler private --- ultraplot/figure.py | 4 ++-- ultraplot/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 70ef89121..67b8dad9e 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -35,7 +35,7 @@ labels, warnings, ) -from .utils import units, _get_subplot_layout, Crawler +from .utils import units, _get_subplot_layout, _Crawler __all__ = [ "Figure", @@ -952,7 +952,7 @@ def _get_border_axes( # this in the future for axi in all_axes: axis_type = seen_axis_type.get(type(axi), 1) - crawler = Crawler( + crawler = _Crawler( ax=axi, grid=grid, target=axi.number, diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 9680a5446..8bf4eb51c 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -946,7 +946,7 @@ def _get_subplot_layout( @dataclass -class Crawler: +class _Crawler: """ A crawler is used to find edges of axes in a grid layout. This is useful for determining whether to turn shared labels on or depending on the position of an axis in the grispec. From c465cff7027d469c08c28bf60b9cd4574c3963ad Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 13:54:16 +0200 Subject: [PATCH 29/62] update gridspec to retrieve grid position of main plots --- ultraplot/gridspec.py | 19 +++++++++++++++++++ ultraplot/utils.py | 10 ++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 164b34dbc..5dcd70f7d 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -149,6 +149,25 @@ def _get_rows_columns(self, ncols=None): row2, col2 = divmod(self.num2, ncols) return row1, row2, col1, col2 + def _get_grid_span(self, hidden=False) -> (int, int, int, int): + """ + Retrieve the location of the subplot within the + gridspec. When hidden is False we only consider + the main plots, not the panels or colorbars. + """ + gs = self.get_gridspec() + nrows, ncols = gs.nrows_total, gs.ncols_total + if not hidden: + nrows, ncols = gs.nrows, gs.ncols + # Use num1 or num2 + decoded = gs._decode_indices(self.num1) + x, y = np.unravel_index(decoded, (nrows, ncols)) + span = self._get_rows_columns() + + xspan = span[1] - span[0] + 1 # inclusive + yspan = span[3] - span[2] + 1 # inclusive + return (x, x + xspan, y, y + yspan) + def get_position(self, figure, return_all=False): # Silent override. Older matplotlib versions can create subplots # with negative heights and widths that crash on instantiation. diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 8bf4eb51c..7e4f8eeb7 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -925,13 +925,13 @@ def _get_subplot_layout( for axi in all_axes: # Infer coordinate from grdispec spec = axi.get_subplotspec() - spans = spec._get_rows_columns() + spans = spec._get_grid_span(hidden=False) rowspans = spans[:2] colspans = spans[-2:] grid[ - rowspans[0] : rowspans[1] + 1, - colspans[0] : colspans[1] + 1, + rowspans[0] : rowspans[1], + colspans[0] : colspans[1], ] = axi.number # Allow grouping of mixed types @@ -939,9 +939,7 @@ def _get_subplot_layout( if not same_type: axis_type = seen_axis_types.get(type(axi), 1) - grid_axis_type[rowspans[0] : rowspans[1] + 1, colspans[0] : colspans[1] + 1] = ( - axis_type - ) + grid_axis_type[rowspans[0] : rowspans[1], colspans[0] : colspans[1]] = axis_type return grid, grid_axis_type, seen_axis_types From 3a6024bf1d25daf8e5a0261858c5e0c4670415b1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 13:56:40 +0200 Subject: [PATCH 30/62] update crawler to use grid coordinate --- ultraplot/utils.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 7e4f8eeb7..760e5ecd9 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -928,7 +928,6 @@ def _get_subplot_layout( spans = spec._get_grid_span(hidden=False) rowspans = spans[:2] colspans = spans[-2:] - grid[ rowspans[0] : rowspans[1], colspans[0] : colspans[1], @@ -993,11 +992,11 @@ def find_edge_for( # Retrieve where the axis is in the grid spec = self.ax.get_subplotspec() - spans = spec._get_rows_columns() + spans = spec._get_grid_span(hidden=False) rowspan = spans[:2] colspan = spans[-2:] - xs = range(rowspan[0], rowspan[1] + 1) - ys = range(colspan[0], colspan[1] + 1) + xs = range(rowspan[0], rowspan[1]) + ys = range(colspan[0], colspan[1]) is_border = False for x, y in product(xs, ys): pos = (x, y) From c06d60372bb354b117c15eec7c7fdb13b2e554f9 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 13:57:03 +0200 Subject: [PATCH 31/62] simplify label logic --- ultraplot/axes/cartesian.py | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 23ccbb193..596593cc4 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -487,28 +487,12 @@ def _determine_tick_label_visibility( for label_param, border_side in zip(label_params, border_sides): # Check if user has explicitly set label location via format() - user_override = getattr(self, f"_user_{axis_name}ticklabelloc", None) - + label_visibility[label_param] = False if self._panel_dict[border_side]: - label_visibility[label_param] = False - elif user_override is not None: - # Use user's explicit choice - handle different formats - side_name = border_side # 'top', 'bottom', 'left', 'right' - # Handle short forms: 't', 'b', 'l', 'r' - side_short = side_name[0] # 't', 'b', 'l', 'r' - - label_visibility[label_param] = ( - user_override == side_name - or user_override == side_short - or user_override == "both" - or user_override == "all" - ) - else: - # Use automatic border detection logic - label_visibility[label_param] = ( - ticks[label_param] or sharing_ticks[label_param] - ) and self in border_axes.get(border_side, []) - + continue + # Use automatic border detection logic + if self in border_axes.get(border_side, []): + label_visibility[label_param] = ticks.get(label_param, False) return label_visibility def _add_alt(self, sx, **kwargs): From 71909f7c27add6a66612ef00d2a4e2ee60d91f95 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Jun 2025 14:07:19 +0200 Subject: [PATCH 32/62] minor refactor to improve readability --- ultraplot/utils.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 760e5ecd9..391bf6e5c 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1028,24 +1028,30 @@ def is_border( # Check if we reached a plot or an internal edge if self.grid[x, y] != self.target and self.grid[x, y] > 0: # check if we reached a border that has the same x and y span - ispan = self.ax.get_subplotspec()._get_rows_columns() + ispan = self.ax.get_subplotspec()._get_grid_span(hidden=False) onumber = int(self.grid[x, y]) if onumber == 0: return True other = self.ax.figure.axes[onumber - 1].get_subplotspec() - ospan = other._get_rows_columns() + ospan = other._get_grid_span(hidden=False) # Check if our spans are the same - rowspan = ispan[1] - ispan[0] - colspan = ispan[3] - ispan[2] - orowspan = ospan[1] - ospan[0] - ocolspan = ospan[3] - ospan[2] + irowspan, icolspan = ( + (ispan[1] - ispan[0]), + (ispan[3] - ispan[2]), + ) + orowspan, ocolspan = ( + (ospan[1] - ospan[0]), + (ospan[3] - ospan[2]), + ) dy, dx = direction + # Check in which way we are moving + # and check the span for that direction if dx == 0: - if rowspan != orowspan: + if irowspan != orowspan: return True elif dy == 0: - if colspan != ocolspan: + if icolspan != ocolspan: return True return False From c566e4b7ba00cc7d107f6e7be953f1eb50ab0ebc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 14 Jun 2025 10:14:33 +0200 Subject: [PATCH 33/62] update comment to reflect why it is happening --- ultraplot/axes/cartesian.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 596593cc4..944ad9820 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -443,8 +443,7 @@ def _apply_axis_sharing_for_axis( border_axes, ) axis.set_tick_params(which="both", **label_visibility) - - # Set minor formatter for last processed axis + # Turn minor ticks off axis.set_minor_formatter(mticker.NullFormatter()) def _determine_tick_label_visibility( From a9b9b08437d00924b90309d698226bcd5084d152 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 14 Jun 2025 10:15:03 +0200 Subject: [PATCH 34/62] update logic to include axis but ignore colorbars and update the parent --- ultraplot/axes/cartesian.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 944ad9820..ce3c9e63e 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -487,11 +487,29 @@ def _determine_tick_label_visibility( for label_param, border_side in zip(label_params, border_sides): # Check if user has explicitly set label location via format() label_visibility[label_param] = False - if self._panel_dict[border_side]: + is_panel = False + for panel in self._panel_dict[border_side]: + # Check if the panel is a colorbar + colorbars = ( + values + for key, values in self._colorbar_dict.items() + if border_side in key + ) + if panel in colorbars: + # If the panel is a colorbar, skip it + is_panel = True + break + if is_panel: continue + + # Check if the panel is not a colorbar + # continue # Use automatic border detection logic if self in border_axes.get(border_side, []): - label_visibility[label_param] = ticks.get(label_param, False) + getattr(shared_axis, f"{axis_name}axis").set_tick_params( + **{label_param: False} + ) + label_visibility[label_param] = True return label_visibility def _add_alt(self, sx, **kwargs): From 9ac418d7cdeae685006d0fb7749f05f0c4e7e9e7 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Mon, 16 Jun 2025 09:37:36 +0200 Subject: [PATCH 35/62] Update return statements of tests to be compliant with pytest 8.4.0 (#265) --- ultraplot/tests/test_1dplots.py | 2 +- ultraplot/tests/test_format.py | 2 +- ultraplot/tests/test_geographic.py | 4 ++-- ultraplot/tests/test_plot.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index 6d4f03cb2..db00c7aff 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -615,7 +615,7 @@ def test_bar_alpha(): y = [2] ax.bar(x, y, alphas=[0.2]) ax.bar(x, y, alphas=0.2) - return fig + uplt.close(fig) @pytest.mark.mpl_image_compare diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index 6086c1475..5cb232534 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -405,7 +405,7 @@ def test_scaler(): fig, ax = uplt.subplots(ncols=2, share=0) ax[0].set_yscale("mercator") ax[1].set_yscale("asinh") - return fig + uplt.close(fig) @pytest.mark.mpl_image_compare diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 5b3779ba9..138931a9f 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -238,7 +238,7 @@ def test_lon0_shifts(): n = len(str_loc) assert str_loc == format[:n] assert locs[0] != 0 # we should not be a 0 anymore - return fig + uplt.close(fig) def test_sharing_cartopy(): @@ -296,7 +296,7 @@ def are_labels_on(ax, which=["top", "bottom", "right", "left"]) -> tuple[bool]: state = are_labels_on(axi) expectation = expectations[axi.number - 1] assert all([i == j for i, j in zip(state, expectation)]) - return fig + uplt.close(fig) @pytest.mark.mpl_image_compare diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 1242573e3..5f92fefd1 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -226,7 +226,7 @@ def test_quiver_discrete_colors(): C = np.random.rand(3, 4) ax.quiver(X - 2, Y, U, V, C) ax.quiver(X - 3, Y, U, V, color="red", infer_rgb=True) - return fig + uplt.close(fig) def test_setting_log_with_rc(): @@ -281,7 +281,7 @@ def reset(ax): axi = getattr(ax, f"{target}axis") check_ticks(axi, target=False) - return fig + uplt.close(fig) def test_shading_pcolor(): @@ -324,7 +324,7 @@ def wrapped_parse_2d_args(x, y, z, *args, **kwargs): else: assert x.shape[0] == z.shape[0] assert x.shape[1] == z.shape[1] - return fig + uplt.close(fig) def test_cycle_with_singular_column(): From de262c4b19b48709f1fa5fb876854a87088d473f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 10:20:17 +0200 Subject: [PATCH 36/62] looks good by eye --- ultraplot/axes/cartesian.py | 32 ++++++++++--------- ultraplot/figure.py | 4 --- ultraplot/tests/test_geographic.py | 2 +- ultraplot/utils.py | 49 +++++++++++++++++------------- 4 files changed, 47 insertions(+), 40 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index ce3c9e63e..69157ae7a 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -487,29 +487,33 @@ def _determine_tick_label_visibility( for label_param, border_side in zip(label_params, border_sides): # Check if user has explicitly set label location via format() label_visibility[label_param] = False - is_panel = False + has_panel = False for panel in self._panel_dict[border_side]: # Check if the panel is a colorbar - colorbars = ( + colorbars = [ values for key, values in self._colorbar_dict.items() - if border_side in key - ) - if panel in colorbars: - # If the panel is a colorbar, skip it - is_panel = True + if border_side in key # key is tuple (side, top | center | lower) + ] + if not panel in colorbars: + # Skip colorbar as their + # yaxis is not shared + has_panel = True break - if is_panel: + # When we have a panel, let the panel have + # the labels and turn-off for this axis + side. + if has_panel: continue - # Check if the panel is not a colorbar - # continue # Use automatic border detection logic if self in border_axes.get(border_side, []): - getattr(shared_axis, f"{axis_name}axis").set_tick_params( - **{label_param: False} - ) - label_visibility[label_param] = True + is_this_tick_on = ticks[label_param] + is_parent_tick_on = sharing_ticks[label_param] + if is_this_tick_on or is_parent_tick_on: + getattr(shared_axis, f"{axis_name}axis").set_tick_params( + **{label_param: False} + ) + label_visibility[label_param] = True return label_visibility def _add_alt(self, sx, **kwargs): diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 67b8dad9e..8474c0980 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1933,11 +1933,7 @@ def format( ax.number = store_old_number # When we apply formatting to all axes, we need # to potentially adjust the labels. - if len(axs) == len(self.axes) and self._get_sharing_level() > 0: - self._share_labels_with_others() - # When we apply formatting to all axes, we need - # to potentially adjust the labels. if len(axs) == len(self.axes) and self._get_sharing_level() > 0: self._share_labels_with_others() diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 138931a9f..3ac3b893c 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -659,7 +659,7 @@ def test_cartesian_and_geo(): ax[0].pcolormesh(np.random.rand(10, 10)) ax[1].scatter(*np.random.rand(2, 100)) ax[0]._apply_axis_sharing() - assert mocked.call_count == 2 + assert mocked.call_count == 1 return fig diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 391bf6e5c..4ccf9fe75 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -925,12 +925,14 @@ def _get_subplot_layout( for axi in all_axes: # Infer coordinate from grdispec spec = axi.get_subplotspec() - spans = spec._get_grid_span(hidden=False) - rowspans = spans[:2] - colspans = spans[-2:] + spans = spec._get_grid_span() + rowspan = spans[:2] + colspan = spans[-2:] + + x, y, xspan, yspan = spans grid[ - rowspans[0] : rowspans[1], - colspans[0] : colspans[1], + slice(*rowspan), + slice(*colspan), ] = axi.number # Allow grouping of mixed types @@ -938,7 +940,10 @@ def _get_subplot_layout( if not same_type: axis_type = seen_axis_types.get(type(axi), 1) - grid_axis_type[rowspans[0] : rowspans[1], colspans[0] : colspans[1]] = axis_type + grid_axis_type[ + slice(*rowspan), + slice(*colspan), + ] = axis_type return grid, grid_axis_type, seen_axis_types @@ -992,11 +997,11 @@ def find_edge_for( # Retrieve where the axis is in the grid spec = self.ax.get_subplotspec() - spans = spec._get_grid_span(hidden=False) + spans = spec._get_grid_span() rowspan = spans[:2] colspan = spans[-2:] - xs = range(rowspan[0], rowspan[1]) - ys = range(colspan[0], colspan[1]) + xs = range(*rowspan) + ys = range(*colspan) is_border = False for x, y in product(xs, ys): pos = (x, y) @@ -1028,30 +1033,32 @@ def is_border( # Check if we reached a plot or an internal edge if self.grid[x, y] != self.target and self.grid[x, y] > 0: # check if we reached a border that has the same x and y span - ispan = self.ax.get_subplotspec()._get_grid_span(hidden=False) + ispan = self.ax.get_subplotspec()._get_grid_span() onumber = int(self.grid[x, y]) if onumber == 0: return True other = self.ax.figure.axes[onumber - 1].get_subplotspec() - ospan = other._get_grid_span(hidden=False) + ospan = other._get_grid_span() # Check if our spans are the same - irowspan, icolspan = ( - (ispan[1] - ispan[0]), - (ispan[3] - ispan[2]), - ) - orowspan, ocolspan = ( - (ospan[1] - ospan[0]), - (ospan[3] - ospan[2]), - ) + irowspan, icolspan = ispan[:2], ispan[-2:] + orowspan, ocolspan = ospan[:2], ospan[-2:] + + # Compute the span size + irowspan_size = irowspan[1] - irowspan[0] + icolspan_size = icolspan[1] - icolspan[0] + orowspan_size = orowspan[1] - orowspan[0] + ocolspan_size = ocolspan[1] - ocolspan[0] + dy, dx = direction + # Check in which way we are moving # and check the span for that direction if dx == 0: - if irowspan != orowspan: + if irowspan_size != orowspan_size: return True elif dy == 0: - if icolspan != ocolspan: + if icolspan_size != ocolspan_size: return True return False From 2571f175ee3717457ab96903886f178c7fefb198 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 10:50:01 +0200 Subject: [PATCH 37/62] add label parsing for python 3.9 and below --- ultraplot/axes/cartesian.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 69157ae7a..0460dee8b 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -9,6 +9,8 @@ import matplotlib.ticker as mticker import numpy as np +from packaging import version + from .. import constructor from .. import scale as pscale from .. import ticker as pticker @@ -507,6 +509,14 @@ def _determine_tick_label_visibility( # Use automatic border detection logic if self in border_axes.get(border_side, []): + # Deal with logic not being consistent + # in prior mpl versions + if version.parse(str(_version_mpl)) <= version.parse("3.9"): + if label_param == "labeltop" and axis_name == "x": + label_param = "labelright" + elif label_params == "labelleft" and axis_name == "x": + label_param = "labelleft" + is_this_tick_on = ticks[label_param] is_parent_tick_on = sharing_ticks[label_param] if is_this_tick_on or is_parent_tick_on: From 4e9bb26b7bb4b8d5926ebb23c6d216882bbcf99e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 10:51:16 +0200 Subject: [PATCH 38/62] correction --- ultraplot/axes/cartesian.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 0460dee8b..0383d1f6f 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -514,7 +514,7 @@ def _determine_tick_label_visibility( if version.parse(str(_version_mpl)) <= version.parse("3.9"): if label_param == "labeltop" and axis_name == "x": label_param = "labelright" - elif label_params == "labelleft" and axis_name == "x": + elif label_params == "labelbottom" and axis_name == "x": label_param = "labelleft" is_this_tick_on = ticks[label_param] From cb8d50533e074831b8b8b5a689dff233daa20ffb Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 11:59:24 +0200 Subject: [PATCH 39/62] update logic to check for adjacent plots --- ultraplot/utils.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 4ccf9fe75..960282d5b 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1044,21 +1044,18 @@ def is_border( irowspan, icolspan = ispan[:2], ispan[-2:] orowspan, ocolspan = ospan[:2], ospan[-2:] - # Compute the span size - irowspan_size = irowspan[1] - irowspan[0] - icolspan_size = icolspan[1] - icolspan[0] - orowspan_size = orowspan[1] - orowspan[0] - ocolspan_size = ocolspan[1] - ocolspan[0] - dy, dx = direction # Check in which way we are moving # and check the span for that direction + is_x_adjacent = irowspan[0] == orowspan[-1] + is_y_adjacent = icolspan[0] == ocolspan[-1] + if dx == 0: - if irowspan_size != orowspan_size: + if not is_x_adjacent: return True elif dy == 0: - if icolspan_size != ocolspan_size: + if not is_y_adjacent: return True return False From be7d464679d5bfa542bc5379e8465eee328c2111 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 12:52:35 +0200 Subject: [PATCH 40/62] refactor logic --- ultraplot/utils.py | 174 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 148 insertions(+), 26 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 960282d5b..c3f3b8d15 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1032,32 +1032,7 @@ def is_border( # Check if we reached a plot or an internal edge if self.grid[x, y] != self.target and self.grid[x, y] > 0: - # check if we reached a border that has the same x and y span - ispan = self.ax.get_subplotspec()._get_grid_span() - onumber = int(self.grid[x, y]) - if onumber == 0: - return True - other = self.ax.figure.axes[onumber - 1].get_subplotspec() - ospan = other._get_grid_span() - - # Check if our spans are the same - irowspan, icolspan = ispan[:2], ispan[-2:] - orowspan, ocolspan = ospan[:2], ospan[-2:] - - dy, dx = direction - - # Check in which way we are moving - # and check the span for that direction - is_x_adjacent = irowspan[0] == orowspan[-1] - is_y_adjacent = icolspan[0] == ocolspan[-1] - - if dx == 0: - if not is_x_adjacent: - return True - elif dy == 0: - if not is_y_adjacent: - return True - return False + return self._check_plot_boundary(x, y, direction) if self.grid[x, y] == 0 or self.grid_axis_type[x, y] != self.axis_type: return True @@ -1065,6 +1040,153 @@ def is_border( pos = (x + dx, y + dy) return self.is_border(pos, direction) + def _check_plot_boundary(self, x: int, y: int, direction: tuple[int, int]) -> bool: + """ + Check if encountering another plot at position (x, y) constitutes a border. + + Returns True (border detected) if: + 1. Axis crosses multiple different plots in the movement direction + 2. Axis has empty spaces adjacent in perpendicular direction + 3. Plots are not properly adjacent (have gaps between them) + """ + # Get current axis span + ispan = self.ax.get_subplotspec()._get_grid_span() + irowspan, icolspan = ispan[:2], ispan[-2:] + dy, dx = direction + + # Check if this axis crosses multiple different plots + if self._crosses_multiple_plots(x, y, direction, irowspan, icolspan): + return True + + # Check single plot adjacency and empty space conditions + return self._check_single_plot_adjacency(x, y, direction, irowspan, icolspan) + + def _crosses_multiple_plots( + self, + x: int, + y: int, + direction: tuple[int, int], + irowspan: tuple[int, int], + icolspan: tuple[int, int], + ) -> bool: + """Check if axis crosses multiple different plots in the movement direction.""" + dy, dx = direction + encountered_plots = set() + + if dx == 0: # moving vertically + # Check all columns of this axis at the encountered row + for col in range(icolspan[0], icolspan[-1]): + if 0 <= col < self.grid.shape[1]: + plot_at_pos = self.grid[x, col] + if plot_at_pos != self.target and plot_at_pos > 0: + encountered_plots.add(plot_at_pos) + + elif dy == 0: # moving horizontally + # Check all rows of this axis at the encountered column + for row in range(irowspan[0], irowspan[-1]): + if 0 <= row < self.grid.shape[0]: + plot_at_pos = self.grid[row, y] + if plot_at_pos != self.target and plot_at_pos > 0: + encountered_plots.add(plot_at_pos) + + return len(encountered_plots) >= 2 + + def _check_single_plot_adjacency( + self, + x: int, + y: int, + direction: tuple[int, int], + irowspan: tuple[int, int], + icolspan: tuple[int, int], + ) -> bool: + """Check adjacency conditions for a single encountered plot.""" + onumber = int(self.grid[x, y]) + if onumber == 0: + return True + + # Get other plot's span + other = self.ax.figure.axes[onumber - 1].get_subplotspec() + ospan = other._get_grid_span() + orowspan, ocolspan = ospan[:2], ospan[-2:] + dy, dx = direction + + if dx == 0: # moving vertically + return self._check_vertical_adjacency( + x, y, irowspan, icolspan, orowspan, ocolspan + ) + elif dy == 0: # moving horizontally + return self._check_horizontal_adjacency( + x, y, irowspan, icolspan, orowspan, ocolspan + ) + + return False + + def _check_vertical_adjacency( + self, + x: int, + y: int, + irowspan: tuple[int, int], + icolspan: tuple[int, int], + orowspan: tuple[int, int], + ocolspan: tuple[int, int], + ) -> bool: + """Check adjacency conditions for vertical movement.""" + # Check if rows are adjacent + rows_adjacent = (irowspan[-1] == orowspan[0]) or (orowspan[-1] == irowspan[0]) + if not rows_adjacent: + return True + + # Check if there's column overlap for the shared boundary + col_overlap = max(icolspan[0], ocolspan[0]) < min(icolspan[-1], ocolspan[-1]) + if not col_overlap: + return True + + # Check for empty spaces adjacent to this axis + return self._has_empty_spaces_horizontal(x, icolspan) + + def _check_horizontal_adjacency( + self, + x: int, + y: int, + irowspan: tuple[int, int], + icolspan: tuple[int, int], + orowspan: tuple[int, int], + ocolspan: tuple[int, int], + ) -> bool: + """Check adjacency conditions for horizontal movement.""" + # Check if columns are adjacent + cols_adjacent = (icolspan[-1] == ocolspan[0]) or (ocolspan[-1] == icolspan[0]) + if not cols_adjacent: + return True + + # Check if there's row overlap for the shared boundary + row_overlap = max(irowspan[0], orowspan[0]) < min(irowspan[-1], orowspan[-1]) + if not row_overlap: + return True + + # Check for empty spaces adjacent to this axis + return self._has_empty_spaces_vertical(y, irowspan) + + def _has_empty_spaces_horizontal(self, x: int, icolspan: tuple[int, int]) -> bool: + """Check if there are empty spaces horizontally adjacent to the axis.""" + for col in range(icolspan[0], icolspan[-1]): + # Check if there are empty spaces on either side + if col > 0 and self.grid[x, col - 1] == 0: + return True + if col < self.grid.shape[1] - 1 and self.grid[x, col + 1] == 0: + return True + return False + + def _has_empty_spaces_vertical(self, y: int, irowspan: tuple[int, int]) -> bool: + """Check if there are empty spaces vertically adjacent to the axis.""" + for row in range(irowspan[0], irowspan[-1]): + # Check if there are empty spaces above or below + if row > 0 and self.grid[row - 1, y] == 0: + return True + if row < self.grid.shape[0] - 1 and self.grid[row + 1, y] == 0: + return True + return False + # Deprecations shade, saturate = warnings._rename_objs( From edbb7af28c23ca40a40a1f480d2cea0027f2329d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 13:12:50 +0200 Subject: [PATCH 41/62] refactor test --- ultraplot/tests/test_geographic.py | 83 ++++++++++++++++-------------- 1 file changed, 44 insertions(+), 39 deletions(-) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 7bd9b61f3..29f928ba9 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -241,8 +241,49 @@ def test_lon0_shifts(): uplt.close(fig) -def test_sharing_cartopy(): - +@pytest.mark.parametrize( + "layout, expectations", + [ + ( + # layout 1: 3x3 grid with unique IDs + [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + # expectations: per element ID (1-9), four booleans: [top, right, bottom, left] + [ + [True, False, False, True], # 1 + [True, False, False, False], # 2 + [True, False, True, False], # 3 + [False, False, False, True], # 4 + [False, False, False, False], # 5 + [False, False, True, False], # 6 + [False, True, False, True], # 7 + [False, True, False, False], # 8 + [False, True, True, False], # 9 + ], + ), + ( + # layout 2: shared IDs (merged subplots?) + [ + [1, 2, 0], + [1, 2, 5], + [3, 4, 5], + [3, 4, 0], + ], + # expectations for IDs 1–5: [top, right, bottom, left] + [ + [True, False, False, True], # 1 + [True, False, True, False], # 2 + [False, True, False, True], # 3 + [False, True, True, False], # 4 + [True, True, True, True], # 5 + ], + ), + ], +) +def test_sharing_cartopy(layout, expectations): def are_labels_on(ax, which=["top", "bottom", "right", "left"]) -> tuple[bool]: gl = ax.gridlines_major @@ -252,50 +293,14 @@ def are_labels_on(ax, which=["top", "bottom", "right", "left"]) -> tuple[bool]: on[idx] = True return on - n = 3 settings = dict(land=True, ocean=True, labels="both") - fig, ax = uplt.subplots(ncols=n, nrows=n, share="all", proj="cyl") + fig, ax = uplt.subplots(layout, share="all", proj="cyl") ax.format(**settings) - - expectations = ( - [True, False, False, True], - [True, False, False, False], - [True, False, True, False], - [False, False, False, True], - [False, False, False, False], - [False, False, True, False], - [False, True, False, True], - [False, True, False, False], - [False, True, True, False], - ) for axi in ax: state = are_labels_on(axi) expectation = expectations[axi.number - 1] for i, j in zip(state, expectation): assert i == j - - layout = [ - [1, 2, 0], - [1, 2, 5], - [3, 4, 5], - [3, 4, 0], - ] - - fig, ax = uplt.subplots(layout, share="all", proj="cyl") - ax.format(**settings) - fig.canvas.draw() # need a draw to trigger ax.draw for sharing - - expectations = ( - [True, False, False, True], # top left - [True, False, True, False], # top right - [False, True, False, True], # bottom left - [False, True, True, False], # bottom right - [True, True, True, False], # right plot (5) - ) - for axi in ax: - state = are_labels_on(axi) - expectation = expectations[axi.number - 1] - assert all([i == j for i, j in zip(state, expectation)]) uplt.close(fig) From d44e30ee79af3a393ad88771f6c18432e0d7fc15 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 14:34:00 +0200 Subject: [PATCH 42/62] rm junk and simplify logic --- ultraplot/tests/test_subplots.py | 68 ++++++++---- ultraplot/utils.py | 179 ++++++------------------------- 2 files changed, 80 insertions(+), 167 deletions(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 8ba80cb0d..9d5faf2f2 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -141,34 +141,60 @@ def test_aligned_outer_guides(): return fig +@pytest.mark.parametrize( + "test_case,refwidth,kwargs,setup_func,ref", + [ + ( + "simple", + 1.5, + {"ncols": 2}, + None, + None, + ), + ( + "funky_layout", + 1.5, + {"array": [[1, 1, 2, 2], [0, 3, 3, 0]]}, + lambda fig, axs: ( + axs[1].panel_axes("left"), + axs.format(xlocator=0.2, ylocator=0.2), + ), + 3, + ), + ( + "with_panels", + 2.0, + {"array": [[1, 1, 2], [3, 4, 5], [3, 4, 6]], "hratios": (2, 1, 1)}, + lambda fig, axs: ( + axs[2].panel_axes("right", width=0.5), + axs[0].panel_axes("bottom", width=0.5), + axs[3].panel_axes("left", width=0.5), + ), + None, + ), + ], +) @pytest.mark.mpl_image_compare -def test_reference_aspect(): +def test_reference_aspect(test_case, refwidth, kwargs, setup_func, ref): """ Rigorous test of reference aspect ratio accuracy. """ - # A simple test - refwidth = 1.5 - fig, axs = uplt.subplots(ncols=2, refwidth=refwidth) - fig.auto_layout() - assert np.isclose(refwidth, axs[fig._refnum - 1]._get_size_inches()[0]) + # Add ref and refwidth to kwargs + subplot_kwargs = kwargs.copy() + subplot_kwargs["refwidth"] = refwidth + if ref is not None: + subplot_kwargs["ref"] = ref - # A test with funky layout - refwidth = 1.5 - fig, axs = uplt.subplots([[1, 1, 2, 2], [0, 3, 3, 0]], ref=3, refwidth=refwidth) - axs[1].panel_axes("left") - axs.format(xlocator=0.2, ylocator=0.2) - fig.auto_layout() - assert np.isclose(refwidth, axs[fig._refnum - 1]._get_size_inches()[0]) + # Create subplots + fig, axs = uplt.subplots(**subplot_kwargs) - # A test with panels - refwidth = 2.0 - fig, axs = uplt.subplots( - [[1, 1, 2], [3, 4, 5], [3, 4, 6]], hratios=(2, 1, 1), refwidth=refwidth - ) - axs[2].panel_axes("right", width=0.5) - axs[0].panel_axes("bottom", width=0.5) - axs[3].panel_axes("left", width=0.5) + # Run setup function if provided + if setup_func is not None: + setup_func(fig, axs) + + # Apply auto layout fig.auto_layout() + # Assert reference width accuracy assert np.isclose(refwidth, axs[fig._refnum - 1]._get_size_inches()[0]) return fig diff --git a/ultraplot/utils.py b/ultraplot/utils.py index c3f3b8d15..6c3815a2c 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1030,162 +1030,49 @@ def is_border( elif y > self.grid.shape[1] - 1: return True + if self.grid[x, y] == 0 or self.grid_axis_type[x, y] != self.axis_type: + return True + # Check if we reached a plot or an internal edge if self.grid[x, y] != self.target and self.grid[x, y] > 0: - return self._check_plot_boundary(x, y, direction) + return self._check_ranges(direction, other=self.grid[x, y]) - if self.grid[x, y] == 0 or self.grid_axis_type[x, y] != self.axis_type: - return True dx, dy = direction pos = (x + dx, y + dy) return self.is_border(pos, direction) - def _check_plot_boundary(self, x: int, y: int, direction: tuple[int, int]) -> bool: - """ - Check if encountering another plot at position (x, y) constitutes a border. - - Returns True (border detected) if: - 1. Axis crosses multiple different plots in the movement direction - 2. Axis has empty spaces adjacent in perpendicular direction - 3. Plots are not properly adjacent (have gaps between them) - """ - # Get current axis span - ispan = self.ax.get_subplotspec()._get_grid_span() - irowspan, icolspan = ispan[:2], ispan[-2:] - dy, dx = direction - - # Check if this axis crosses multiple different plots - if self._crosses_multiple_plots(x, y, direction, irowspan, icolspan): - return True - - # Check single plot adjacency and empty space conditions - return self._check_single_plot_adjacency(x, y, direction, irowspan, icolspan) - - def _crosses_multiple_plots( + def _check_ranges( self, - x: int, - y: int, direction: tuple[int, int], - irowspan: tuple[int, int], - icolspan: tuple[int, int], + other: int, ) -> bool: - """Check if axis crosses multiple different plots in the movement direction.""" - dy, dx = direction - encountered_plots = set() - - if dx == 0: # moving vertically - # Check all columns of this axis at the encountered row - for col in range(icolspan[0], icolspan[-1]): - if 0 <= col < self.grid.shape[1]: - plot_at_pos = self.grid[x, col] - if plot_at_pos != self.target and plot_at_pos > 0: - encountered_plots.add(plot_at_pos) - - elif dy == 0: # moving horizontally - # Check all rows of this axis at the encountered column - for row in range(irowspan[0], irowspan[-1]): - if 0 <= row < self.grid.shape[0]: - plot_at_pos = self.grid[row, y] - if plot_at_pos != self.target and plot_at_pos > 0: - encountered_plots.add(plot_at_pos) - - return len(encountered_plots) >= 2 - - def _check_single_plot_adjacency( - self, - x: int, - y: int, - direction: tuple[int, int], - irowspan: tuple[int, int], - icolspan: tuple[int, int], - ) -> bool: - """Check adjacency conditions for a single encountered plot.""" - onumber = int(self.grid[x, y]) - if onumber == 0: - return True - - # Get other plot's span - other = self.ax.figure.axes[onumber - 1].get_subplotspec() - ospan = other._get_grid_span() - orowspan, ocolspan = ospan[:2], ospan[-2:] - dy, dx = direction - - if dx == 0: # moving vertically - return self._check_vertical_adjacency( - x, y, irowspan, icolspan, orowspan, ocolspan - ) - elif dy == 0: # moving horizontally - return self._check_horizontal_adjacency( - x, y, irowspan, icolspan, orowspan, ocolspan - ) - - return False - - def _check_vertical_adjacency( - self, - x: int, - y: int, - irowspan: tuple[int, int], - icolspan: tuple[int, int], - orowspan: tuple[int, int], - ocolspan: tuple[int, int], - ) -> bool: - """Check adjacency conditions for vertical movement.""" - # Check if rows are adjacent - rows_adjacent = (irowspan[-1] == orowspan[0]) or (orowspan[-1] == irowspan[0]) - if not rows_adjacent: - return True - - # Check if there's column overlap for the shared boundary - col_overlap = max(icolspan[0], ocolspan[0]) < min(icolspan[-1], ocolspan[-1]) - if not col_overlap: - return True - - # Check for empty spaces adjacent to this axis - return self._has_empty_spaces_horizontal(x, icolspan) - - def _check_horizontal_adjacency( - self, - x: int, - y: int, - irowspan: tuple[int, int], - icolspan: tuple[int, int], - orowspan: tuple[int, int], - ocolspan: tuple[int, int], - ) -> bool: - """Check adjacency conditions for horizontal movement.""" - # Check if columns are adjacent - cols_adjacent = (icolspan[-1] == ocolspan[0]) or (ocolspan[-1] == icolspan[0]) - if not cols_adjacent: - return True - - # Check if there's row overlap for the shared boundary - row_overlap = max(irowspan[0], orowspan[0]) < min(irowspan[-1], orowspan[-1]) - if not row_overlap: - return True - - # Check for empty spaces adjacent to this axis - return self._has_empty_spaces_vertical(y, irowspan) - - def _has_empty_spaces_horizontal(self, x: int, icolspan: tuple[int, int]) -> bool: - """Check if there are empty spaces horizontally adjacent to the axis.""" - for col in range(icolspan[0], icolspan[-1]): - # Check if there are empty spaces on either side - if col > 0 and self.grid[x, col - 1] == 0: - return True - if col < self.grid.shape[1] - 1 and self.grid[x, col + 1] == 0: - return True - return False - - def _has_empty_spaces_vertical(self, y: int, irowspan: tuple[int, int]) -> bool: - """Check if there are empty spaces vertically adjacent to the axis.""" - for row in range(irowspan[0], irowspan[-1]): - # Check if there are empty spaces above or below - if row > 0 and self.grid[row - 1, y] == 0: - return True - if row < self.grid.shape[0] - 1 and self.grid[row + 1, y] == 0: - return True - return False + this_spec = self.ax.get_subplotspec() + other_spec = self.ax.figure._subplot_dict[other].get_subplotspec() + + # Get the row and column spans of both axes + this_span = this_spec._get_grid_span() + this_rowspan = this_span[:2] + this_colspan = this_span[-2:] + + other_span = other_spec._get_grid_span() + other_rowspan = other_span[:2] + other_colspan = other_span[-2:] + + # We can share labels if the ranges are the same + # in the direction we are moving + dy, dx = direction # note columns are x and rows are y + if dx == 0: + # Check the y range + this_start, this_stop = this_colspan + other_start, other_stop = other_colspan + if dy == 0: + # Check the x range + this_start, this_stop = this_rowspan + other_start, other_stop = other_rowspan + + if this_start == other_start and this_stop == other_stop: + return False + return True # Deprecations From 786842d74a9ce72f9ca7eefcac05b3d81c7b5ac9 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 15:38:03 +0200 Subject: [PATCH 43/62] add panel logic --- ultraplot/axes/cartesian.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 0383d1f6f..3389f7cbb 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -486,6 +486,16 @@ def _determine_tick_label_visibility( label_visibility = {} + def _convert_label_param(label_param: str) -> str: + # Deal with logic not being consistent + # in prior mpl versions + if version.parse(str(_version_mpl)) <= version.parse("3.9"): + if label_param == "labeltop" and axis_name == "x": + label_param = "labelright" + elif label_params == "labelbottom" and axis_name == "x": + label_param = "labelleft" + return label_param + for label_param, border_side in zip(label_params, border_sides): # Check if user has explicitly set label location via format() label_visibility[label_param] = False @@ -506,17 +516,17 @@ def _determine_tick_label_visibility( # the labels and turn-off for this axis + side. if has_panel: continue + is_border = self in border_axes.get(border_side, []) + is_panel = ( + self in shared_axis._panel_dict[border_side] + and self == shared_axis._panel_dict[border_side][-1] + ) # Use automatic border detection logic - if self in border_axes.get(border_side, []): - # Deal with logic not being consistent - # in prior mpl versions - if version.parse(str(_version_mpl)) <= version.parse("3.9"): - if label_param == "labeltop" and axis_name == "x": - label_param = "labelright" - elif label_params == "labelbottom" and axis_name == "x": - label_param = "labelleft" - + # if we are a panel we "push" the labels outwards + if is_border or is_panel: + # Deal with mpl version for label_param + label_param = _convert_label_param(label_param) is_this_tick_on = ticks[label_param] is_parent_tick_on = sharing_ticks[label_param] if is_this_tick_on or is_parent_tick_on: From cc207b5059681520aa48a2dd8b67fe9d2cba2ed2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 15:38:15 +0200 Subject: [PATCH 44/62] add comment and logic comment --- ultraplot/utils.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 6c3815a2c..5faad8ccb 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1046,6 +1046,17 @@ def _check_ranges( direction: tuple[int, int], other: int, ) -> bool: + """ + Helper function to determined whether a subplot + is enclosed or enclosed another subplot. This is + key to know where a border is, e.g. + + 1 2 + 1 3 + + Implies that 1 cannot share y with 2 and 3, but 2, and 3 + can share x. + """ this_spec = self.ax.get_subplotspec() other_spec = self.ax.figure._subplot_dict[other].get_subplotspec() @@ -1071,8 +1082,8 @@ def _check_ranges( other_start, other_stop = other_rowspan if this_start == other_start and this_stop == other_stop: - return False - return True + return False # not a border + return True # Deprecations From 6c1c0b6f524166dc50d8879ada4bdf021863e416 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 15:38:32 +0200 Subject: [PATCH 45/62] rm unncessary draw --- ultraplot/tests/test_imshow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/tests/test_imshow.py b/ultraplot/tests/test_imshow.py index 559890c32..c6bda968e 100644 --- a/ultraplot/tests/test_imshow.py +++ b/ultraplot/tests/test_imshow.py @@ -81,7 +81,6 @@ def test_inbounds_data(): ylabel="ylabel", suptitle="Default vmin/vmax restricted to in-bounds data", ) - fig.show() return fig From d89c744d6e795f2b8e29da972cbfb1e4354a16c3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:42:10 +0000 Subject: [PATCH 46/62] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ultraplot/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 5faad8ccb..62675af4f 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1082,8 +1082,8 @@ def _check_ranges( other_start, other_stop = other_rowspan if this_start == other_start and this_stop == other_stop: - return False # not a border - return True + return False # not a border + return True # Deprecations From eace59382de86b4847b8141c9db0803f06da69aa Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 17:51:52 +0200 Subject: [PATCH 47/62] update panel logic --- ultraplot/axes/cartesian.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 6cc0ac30c..e3903158b 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -529,7 +529,22 @@ def _convert_label_param(label_param: str) -> str: label_param = _convert_label_param(label_param) is_this_tick_on = ticks[label_param] is_parent_tick_on = sharing_ticks[label_param] - if is_this_tick_on or is_parent_tick_on: + # Only turn on the labels for the current axis + # if the axis it is sharing with is a main + # and we are not panel + turn_on_label = False + if is_panel: + # When we are a panel only turn on ticks + # if the parent has the labels on + # Note: on panel creation we ensure + # the ticks are correctly parsed. + if is_parent_tick_on: + turn_on_label = True + # For shared axes we turn them on if either or are on, but turn off the parent + elif is_this_tick_on or is_parent_tick_on: + turn_on_label = True + + if turn_on_label: getattr(shared_axis, f"{axis_name}axis").set_tick_params( **{label_param: False} ) From c1c462e5fe655cb6dae3c13cbb5b6ba4c09e105e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 18:39:57 +0200 Subject: [PATCH 48/62] restore behavior and add comment --- ultraplot/axes/cartesian.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index e3903158b..428ee0b0a 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -532,19 +532,11 @@ def _convert_label_param(label_param: str) -> str: # Only turn on the labels for the current axis # if the axis it is sharing with is a main # and we are not panel - turn_on_label = False - if is_panel: - # When we are a panel only turn on ticks - # if the parent has the labels on - # Note: on panel creation we ensure - # the ticks are correctly parsed. - if is_parent_tick_on: - turn_on_label = True # For shared axes we turn them on if either or are on, but turn off the parent - elif is_this_tick_on or is_parent_tick_on: - turn_on_label = True - - if turn_on_label: + if is_this_tick_on or is_parent_tick_on: + # Note: we set the current axis to visible + # as we are dealing with borders + # or panels getattr(shared_axis, f"{axis_name}axis").set_tick_params( **{label_param: False} ) From 2d2793155dc735dd582d58f7fe794888d671c33c Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Mon, 16 Jun 2025 18:49:16 +0200 Subject: [PATCH 49/62] Update ultraplot/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ultraplot/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 62675af4f..3612b2d4c 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -951,7 +951,7 @@ def _get_subplot_layout( class _Crawler: """ A crawler is used to find edges of axes in a grid layout. - This is useful for determining whether to turn shared labels on or depending on the position of an axis in the grispec. + This is useful for determining whether to turn shared labels on or depending on the position of an axis in the gridspec. It crawls over the grid in all four cardinal directions and checks whether it reaches a border of the grid or an axis of a different type. It was created as adding colorbars will change the underlying gridspec and therefore we cannot rely on the original gridspec to determine whether an axis is a border or not. From 800aeadd92455eeca9537c4ecbb66cb54f985598 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Mon, 16 Jun 2025 18:49:31 +0200 Subject: [PATCH 50/62] Update ultraplot/axes/cartesian.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ultraplot/axes/cartesian.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 428ee0b0a..80e2a0882 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -492,7 +492,7 @@ def _convert_label_param(label_param: str) -> str: if version.parse(str(_version_mpl)) <= version.parse("3.9"): if label_param == "labeltop" and axis_name == "x": label_param = "labelright" - elif label_params == "labelbottom" and axis_name == "x": + elif label_param == "labelbottom" and axis_name == "x": label_param = "labelleft" return label_param From ac08772e970b7fbc72d930db6d12a564d79ca805 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Jun 2025 18:50:41 +0200 Subject: [PATCH 51/62] remove duplicate --- ultraplot/axes/base.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 2728607c9..668893e79 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3196,26 +3196,6 @@ def _is_panel_group_member(self, other: "Axes") -> bool: ): return True - # Not in the same panel group - - def _is_ticklabel_on(self, side: str) -> bool: - """ - Check if tick labels are on for the specified sides. - """ - # NOTE: This is a helper function to check if tick labels are on - # for the specified sides. It returns True if any of the specified - # sides have tick labels turned on. - axis = self.xaxis - if side in ["labelleft", "labelright"]: - axis = self.yaxis - label = "label1" - if side in ["labelright", "labeltop"]: - label = "label2" - for tick in axis.get_major_ticks(): - if getattr(tick, label).get_visible(): - return True - return False - def _is_ticklabel_on(self, side: str) -> bool: """ Check if tick labels are on for the specified sides. From 1d04aee512cc9d53a57335d48c17098769aeb07e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 17 Jun 2025 00:06:28 +0200 Subject: [PATCH 52/62] restore removed return --- ultraplot/axes/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 668893e79..bbd656215 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3195,6 +3195,8 @@ def _is_panel_group_member(self, other: "Axes") -> bool: and self._panel_parent is other._panel_parent ): return True + # Not in the same panel group + return False def _is_ticklabel_on(self, side: str) -> bool: """ From 75ddfd3a403f9a5b97d0ab2c3a47e1a1387689fc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 10:53:09 +0200 Subject: [PATCH 53/62] tmp set debug to see if dirs are still in the PR --- .github/workflows/build-ultraplot.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 736ecc485..ea74dde68 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -72,6 +72,7 @@ jobs: - name: Generate baseline from main run: | + ls baseline mkdir -p baseline git fetch origin ${{ github.event.pull_request.base.sha }} git checkout ${{ github.event.pull_request.base.sha }} @@ -81,6 +82,7 @@ jobs: - name: Image Comparison Ultraplot run: | + ls results mkdir -p results python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')" pytest -n auto -W ignore --mpl --mpl-baseline-path=baseline --mpl-generate-summary=html --mpl-results-path=./results/ --mpl-default-style="./ultraplot.yml" --store-failed-only ultraplot/tests From d49424e12cfb2664a0d8f0a62a9425ba9eca8903 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 19 Jun 2025 10:54:25 +0200 Subject: [PATCH 54/62] resetting --- .github/workflows/build-ultraplot.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index ea74dde68..736ecc485 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -72,7 +72,6 @@ jobs: - name: Generate baseline from main run: | - ls baseline mkdir -p baseline git fetch origin ${{ github.event.pull_request.base.sha }} git checkout ${{ github.event.pull_request.base.sha }} @@ -82,7 +81,6 @@ jobs: - name: Image Comparison Ultraplot run: | - ls results mkdir -p results python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')" pytest -n auto -W ignore --mpl --mpl-baseline-path=baseline --mpl-generate-summary=html --mpl-results-path=./results/ --mpl-default-style="./ultraplot.yml" --store-failed-only ultraplot/tests From 2a90800a9ace198ca2a7be3d57565337c23ae1e9 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 24 Jun 2025 22:32:21 +0200 Subject: [PATCH 55/62] add tick checking --- ultraplot/tests/test_subplots.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 9d5faf2f2..814eb56ad 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -264,10 +264,34 @@ def test_check_label_sharing_top_right(layout): return fig -@pytest.mark.parametrize("layout", [[1, 2], [3, 4]]) +@pytest.mark.parametrize("layout", [[[1, 2], [3, 4]]]) @pytest.mark.mpl_image_compare def test_panel_sharing_top_right(layout): fig, ax = uplt.subplots(layout) for dir in "left right top bottom".split(): - ax[0].panel(dir) + pax = ax[0].panel(dir) + fig.canvas.draw() # force redraw tick labels + for dir, paxs in ax[0]._panel_dict.items(): + # Since we are sharing some of the ticks + # should be hidden depending on where the panel is + # in the grid + for pax in paxs: + match dir: + case "left": + assert pax._is_ticklabel_on("labelleft") + assert pax._is_ticklabel_on("labelbottom") + case "top": + assert pax._is_ticklabel_on("labeltop") == False + assert pax._is_ticklabel_on("labelbottom") == False + assert pax._is_ticklabel_on("labelleft") + case "right": + print(pax._is_ticklabel_on("labelright")) + assert pax._is_ticklabel_on("labelright") == False + assert pax._is_ticklabel_on("labelbottom") + case "bottom": + assert pax._is_ticklabel_on("labelleft") + assert pax._is_ticklabel_on("labelbottom") == False + + # The sharing axis is not showing any ticks + assert ax[0]._is_ticklabel_on(dir) == False return fig From 08ed28af008fa8a0d543b99ab41a644089b0cc9b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 24 Jun 2025 22:40:05 +0200 Subject: [PATCH 56/62] rn test and add label checking --- ultraplot/tests/test_subplots.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 814eb56ad..61e3f5d87 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -252,7 +252,7 @@ def test_axis_sharing(share): ], ) @pytest.mark.mpl_image_compare -def test_check_label_sharing_top_right(layout): +def test_label_sharing_top_right(layout): fig, ax = uplt.subplots(layout) ax.format( xticklabelloc="t", @@ -261,6 +261,19 @@ def test_check_label_sharing_top_right(layout): ylabel="ylabel", title="Test Title", ) + fig.canvas.draw() # force redraw tick labels + uplt.show(block=1) + for axi in ax: + assert axi._is_ticklabel_on("labelleft") == False + assert axi._is_ticklabel_on("labelbottom") == False + + for side, axs in fig._get_border_axes().items(): + for axi in axs: + if side == "top": + assert axi._is_ticklabel_on("labeltop") == True + if side == "right": + assert axi._is_ticklabel_on("labelright") == True + return fig From 728ed9db73a845c15f5f22f734ca77a2d1a8e7ed Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 24 Jun 2025 22:40:27 +0200 Subject: [PATCH 57/62] rm debug --- ultraplot/tests/test_subplots.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 61e3f5d87..207ca0d68 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -262,7 +262,6 @@ def test_label_sharing_top_right(layout): title="Test Title", ) fig.canvas.draw() # force redraw tick labels - uplt.show(block=1) for axi in ax: assert axi._is_ticklabel_on("labelleft") == False assert axi._is_ticklabel_on("labelbottom") == False From c30953f7d209354c0cc374f7bb7b3d866151a183 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 24 Jun 2025 22:44:28 +0200 Subject: [PATCH 58/62] add asserts to altx and y --- ultraplot/tests/test_axes.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index f16d1e7a5..fdd021579 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -346,6 +346,8 @@ def test_alt_axes_y_shared(): for axi in ax: alt = axi.alty() alt.set_ylabel("Alt Y") + assert alt.get_ylabel() == "Alt Y" + assert alt.get_xlabel() == "" axi.set_ylabel("Y") return fig @@ -358,5 +360,7 @@ def test_alt_axes_x_shared(): for axi in ax: alt = axi.altx() alt.set_xlabel("Alt X") + assert alt.get_xlabel() == "Alt X" + assert alt.get_ylabel() == "" axi.set_xlabel("X") return fig From 4b0fa30b625e85553b939be2505a4bf83dbabaea Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 25 Jun 2025 16:10:35 +0200 Subject: [PATCH 59/62] wrap _get_subplots_layout --- ultraplot/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 3612b2d4c..885a21095 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -913,7 +913,10 @@ def _get_subplot_layout( same_type=True, ) -> tuple[np.ndarray[int, int], np.ndarray[int, int], dict[type, int]]: """ - Helper function to determine the grid layout of axes in a GridSpec. It returns a grid of axis numbers and a grid of axis types. This function is used internally to determine the layout of axes in a GridSpec. + Helper function to determine the grid layout of axes in a + GridSpec. It returns a grid of axis numbers and a grid of + axis types. This function is used internally to determine + the layout of axes in a GridSpec. """ grid = np.zeros((gs.nrows, gs.ncols)) grid_axis_type = np.zeros((gs.nrows, gs.ncols)) From a106cdc7fe267561877a0cfc31c99d1b8ad51241 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 25 Jun 2025 16:15:53 +0200 Subject: [PATCH 60/62] wrap crawler docstring --- ultraplot/utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 885a21095..1b1b97a95 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -954,10 +954,14 @@ def _get_subplot_layout( class _Crawler: """ A crawler is used to find edges of axes in a grid layout. - This is useful for determining whether to turn shared labels on or depending on the position of an axis in the gridspec. - It crawls over the grid in all four cardinal directions and checks whether it reaches a border of the grid or an axis of a different type. It was created as adding colorbars will + This is useful for determining whether to turn shared labels + on or depending on the position of an axis in the gridspec. + It crawls over the grid in all four cardinal directions and + checks whether it reaches a border of the grid or an axis of + a different type. It was created as adding colorbars will change the underlying gridspec and therefore we cannot rely - on the original gridspec to determine whether an axis is a border or not. + on the original gridspec to determine whether an axis is a + border or not. """ ax: object From ab0075231bbd49db1e0f5d72404e58eab28eeec5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 25 Jun 2025 16:22:54 +0200 Subject: [PATCH 61/62] mv comment up --- ultraplot/figure.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 8474c0980..c2ff06802 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1257,6 +1257,10 @@ def _share_labels_with_others(self, *, which="both"): # Note: this action performs it for all the axes in # the figure. We use the stale here to only perform # it once as it is an expensive action. + # The axis will be a border if it is either + # (a) on the edge + # (b) not next to a subplot + # (c) not next to a subplot of the same kind border_axes = self._get_border_axes() # Recode: recoded = {} @@ -1275,10 +1279,7 @@ def _share_labels_with_others(self, *, which="both"): # Turn the ticks on or off depending on the position sides = recoded.get(axi, []) turn_on_or_off = default.copy() - # The axis will be a border if it is either - # (a) on the edge - # (b) not next to a subplot - # (c) not next to a subplot of the same kind + for side in sides: sidelabel = f"label{side}" is_label_on = axi._is_ticklabel_on(sidelabel) From f2ddbdfb41daf0e5f1903f5decdfa9463961d9a7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 6 Jul 2025 20:58:02 +0200 Subject: [PATCH 62/62] fixed comment --- ultraplot/figure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index c2ff06802..b319a82e1 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1966,7 +1966,7 @@ def _share_labels_with_others(self, *, which="both"): recoded[axi] = recoded.get(axi, []) + [direction] # We turn off the tick labels when the scale and - # ticks are shared (level >= 3) + # ticks are shared (level > 0) are_ticks_on = False default = dict( labelleft=are_ticks_on,