From 732b0044f2af24d80a52146062cddab0d605fffc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 08:05:53 +0200 Subject: [PATCH 01/75] restore sharing behavior --- ultraplot/axes/cartesian.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index f5538050..251bf9af 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -2,6 +2,7 @@ """ The standard Cartesian axes used for most ultraplot figures. """ +from cProfile import label import copy import inspect @@ -434,8 +435,7 @@ def _apply_axis_sharing_for_axis( labels._transfer_label(axis.label, shared_axis_obj.label) axis.label.set_visible(False) - # Handle tick label sharing (level > 2) - if level > 2: + if level >= 1: label_visibility = self._determine_tick_label_visibility( axis, shared_axis, @@ -528,8 +528,10 @@ def _convert_label_param(label_param: str) -> str: is_parent_tick_on = sharing_ticks[label_param_trans] if is_panel: label_visibility[label_param] = is_parent_tick_on - elif is_border: - label_visibility[label_param] = is_this_tick_on + elif is_border or getattr(self.figure, f"_share{axis_name}") < 3: + label_visibility[label_param] = ( + is_this_tick_on or sharing_ticks[label_param_trans] + ) return label_visibility def _add_alt(self, sx, **kwargs): From fbd946a3ca48c6c344f8b731b49c9dd6977beb31 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 08:18:31 +0200 Subject: [PATCH 02/75] add unittest submodule intended for testing sharing related functions --- ultraplot/axes/cartesian.py | 2 + ultraplot/figure.py | 2 +- ultraplot/tests/test_sharing.py | 86 +++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 ultraplot/tests/test_sharing.py diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 251bf9af..935453c2 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -529,6 +529,8 @@ def _convert_label_param(label_param: str) -> str: if is_panel: label_visibility[label_param] = is_parent_tick_on elif is_border or getattr(self.figure, f"_share{axis_name}") < 3: + # turn on sharing when on border + # or sharing is below 3 label_visibility[label_param] = ( is_this_tick_on or sharing_ticks[label_param_trans] ) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index d44f31e6..9170b81d 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1058,7 +1058,7 @@ def _get_sharing_level(self): """ We take the average here as the sharex and sharey should be the same value. In case this changes in the future we can track down the error easily """ - return 0.5 * (self.figure._sharex + self.figure._sharey) + return min(self.figure._sharex, self.figure._sharey) def _add_axes_panel(self, ax, side=None, **kwargs): """ diff --git a/ultraplot/tests/test_sharing.py b/ultraplot/tests/test_sharing.py new file mode 100644 index 00000000..9ce7433a --- /dev/null +++ b/ultraplot/tests/test_sharing.py @@ -0,0 +1,86 @@ +import pytest, ultraplot as uplt + +""" +Sharing levels for subplots determine the visbility of the axis labels and tick labels. + +Axis labels are pushed to the border subplots when the sharing level is greater than 1. + +Ticks are visible only on the border plots when the sharing levels is greater than 2. + +Or more verbosely: + sharey = 0: no sharing, all labels and ticks visible + sharey = 1: share axis, all labels and ticks visible + sharey = 2: share limits + sharey = 3 or True, share both ticks and labels +A similar story holds for sharex. +""" + + +@pytest.mark.parametrize("share_level", [0, "labels", "labs", 1, True]) +@pytest.mark.mpl_image_compare +def test_sharing_levels_y(share_level): + """ + Test sharing levels for y-axis: left and right ticks/labels. + """ + fig, axs = uplt.subplots(None, 2, 3, sharey=share_level) + axs.format(ylabel="Y") + axs.format(title=f"sharey = {share_level}") + fig.canvas.draw() # needed for checks + + if fig._sharey < 3: + border_axes = set(axs) + else: + # Reduce border_axes to a set of axes for left and right + border_axes = set() + for direction in ["left", "right"]: + axes = fig._get_border_axes().get(direction, []) + if isinstance(axes, (list, tuple, set)): + border_axes.update(axes) + else: + border_axes.add(axes) + for axi in axs: + tick_params = axi.yaxis.get_tick_params() + for direction in ["left", "right"]: + label_key = f"label{direction}" + visible = tick_params.get(label_key, False) + is_border = axi in fig._get_border_axes().get(direction, []) + if direction == "left" and (fig._sharey < 3 or is_border): + assert visible + else: + assert not visible + return fig + + +@pytest.mark.parametrize("share_level", [0, "labels", "labs", 1, True]) +@pytest.mark.mpl_image_compare +def test_sharing_levels_x(share_level): + """ + Test sharing levels for x-axis: top and bottom ticks/labels. + """ + fig, axs = uplt.subplots(None, 2, 3, sharex=share_level) + axs.format(xlabel="X") + axs.format(title=f"sharex = {share_level}") + fig.canvas.draw() # needed for checks + + if fig._sharex < 3: + border_axes = set(axs) + else: + # Reduce border_axes to a set of axes for top and bottom + border_axes = set() + for direction in ["top", "bottom"]: + axes = fig._get_border_axes().get(direction, []) + if isinstance(axes, (list, tuple, set)): + border_axes.update(axes) + else: + border_axes.add(axes) + for axi in axs: + tick_params = axi.xaxis.get_tick_params() + for direction in ["top", "bottom"]: + label_key = f"label{direction}" + visible = tick_params.get(label_key, False) + is_border = axi in fig._get_border_axes().get(direction, []) + if direction == "bottom" and (fig._sharex < 3 or is_border): + assert visible + else: + assert not visible + return fig From 923f509f2a3bb7c34d3259a57441ee4520e71e70 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 08:49:34 +0200 Subject: [PATCH 03/75] rm auto add import --- ultraplot/axes/cartesian.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 935453c2..f81bd2ec 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -2,7 +2,6 @@ """ The standard Cartesian axes used for most ultraplot figures. """ -from cProfile import label import copy import inspect From 639695d1c8dc171e48e8544493739716f4292bb7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 08:50:47 +0200 Subject: [PATCH 04/75] restore figure share --- ultraplot/figure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 9170b81d..d44f31e6 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1058,7 +1058,7 @@ def _get_sharing_level(self): """ We take the average here as the sharex and sharey should be the same value. In case this changes in the future we can track down the error easily """ - return min(self.figure._sharex, self.figure._sharey) + return 0.5 * (self.figure._sharex + self.figure._sharey) def _add_axes_panel(self, ax, side=None, **kwargs): """ From 6b1dffe133db56374bac3a9ccdf9ad101d8109e1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 08:58:23 +0200 Subject: [PATCH 05/75] rm _get_sharing_level --- ultraplot/axes/geo.py | 22 +++++++++++++++++----- ultraplot/figure.py | 10 ++-------- ultraplot/tests/test_geographic.py | 2 +- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index ab95a661..d2fb2a44 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -671,7 +671,7 @@ def _apply_axis_sharing(self): # build chain. if not self.stale: return - if self.figure._get_sharing_level() == 0: + if self.figure._sharex == 0 and self.figure._sharey == 0: return def _get_gridliner_labels( @@ -719,10 +719,22 @@ def _handle_axis_sharing( target_axis: The target axis to apply sharing to """ # Copy view interval and minor locator from source to target - - if self.figure._get_sharing_level() >= 2: - target_axis.set_view_interval(*source_axis.get_view_interval()) - target_axis.set_minor_locator(source_axis.get_minor_locator()) + source_view_interval = source_axis.get_view_interval() + source_locator = source_axis.get_minor_locator() + + target_view_interval = target_axis.get_view_interval() + target_locator = target_axis.get_minor_locator() + if self.figure._sharex >= 2: + target_view_interval[0] = source_view_interval[0] + target_view_interval[1] = source_view_interval[1] + target_locator = source_locator + if self.figure._sharey >= 2: + target_view_interval[0] = source_view_interval[1] + target_view_interval[1] = source_view_interval[1] + + target_locator = source_locator + target_axis.set_view_interval(*target_view_interval) + target_axis.set_minor_locator(target_locator) @override def draw(self, renderer=None, *args, **kwargs): diff --git a/ultraplot/figure.py b/ultraplot/figure.py index d44f31e6..97bc7d08 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1054,12 +1054,6 @@ def _get_renderer(self): renderer = canvas.get_renderer() return renderer - def _get_sharing_level(self): - """ - We take the average here as the sharex and sharey should be the same value. In case this changes in the future we can track down the error easily - """ - return 0.5 * (self.figure._sharex + self.figure._sharey) - def _add_axes_panel(self, ax, side=None, **kwargs): """ Add an axes panel. @@ -1270,7 +1264,7 @@ def _share_labels_with_others(self, *, which="both"): """ # Only apply sharing of labels when we are # actually sharing labels. - if self._get_sharing_level() == 0: + if self._sharex == 0 and self._sharey == 0: return # Turn all labels off # Note: this action performs it for all the axes in @@ -1971,7 +1965,7 @@ def format( # 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: + if len(axs) == len(self.axes) and (self._sharex or self._sharey): self._share_labels_with_others() # Warn unused keyword argument(s) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 4b95a938..05e1a4c9 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -551,7 +551,7 @@ def assert_views_are_sharing(ax): l2 = np.linalg.norm( np.asarray(latview) - np.asarray(target_lat), ) - level = ax.figure._get_sharing_level() + level = ax.figure._sharex if level <= 1: share_x = share_y = False assert np.allclose(l1, 0) == share_x From b3a37ae56430753554805624c1bc81b10309d3b3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 09:04:10 +0200 Subject: [PATCH 06/75] update figure format --- ultraplot/figure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 97bc7d08..04d7eb9b 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1965,7 +1965,7 @@ def format( # When we apply formatting to all axes, we need # to potentially adjust the labels. - if len(axs) == len(self.axes) and (self._sharex or self._sharey): + if len(axs) == len(self.axes) and (self._sharex >= 3 and self._sharey >= 3): self._share_labels_with_others() # Warn unused keyword argument(s) From 724d0da437ea3be2c2983549ae86533db6a51e86 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 09:09:28 +0200 Subject: [PATCH 07/75] bump handle axis sharing geo --- ultraplot/axes/geo.py | 40 +++++++++++++--------------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index d2fb2a44..fa134f95 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -653,18 +653,15 @@ def _apply_axis_sharing(self): the leftmost and bottommost is the *figure* sharing level. """ # Handle X axis sharing - if self._sharex: - self._handle_axis_sharing( - source_axis=self._sharex._lonaxis, - target_axis=self._lonaxis, - ) - + self._handle_axis_sharing( + source_axis=self._sharex._lonaxis, + target_axis=self._lonaxis, + which="x", + ) # Handle Y axis sharing - if self._sharey: - self._handle_axis_sharing( - source_axis=self._sharey._lataxis, - target_axis=self._lataxis, - ) + self._handle_axis_sharing( + source_axis=self._sharey._lataxis, target_axis=self._lataxis, which="y" + ) # This block is apart of the draw sequence as the # gridliner object is created late in the @@ -710,6 +707,8 @@ def _handle_axis_sharing( self, source_axis: "GeoAxes", target_axis: "GeoAxes", + *, + which: str, ): """ Helper method to handle axis sharing for both X and Y axes. @@ -719,22 +718,9 @@ def _handle_axis_sharing( target_axis: The target axis to apply sharing to """ # Copy view interval and minor locator from source to target - source_view_interval = source_axis.get_view_interval() - source_locator = source_axis.get_minor_locator() - - target_view_interval = target_axis.get_view_interval() - target_locator = target_axis.get_minor_locator() - if self.figure._sharex >= 2: - target_view_interval[0] = source_view_interval[0] - target_view_interval[1] = source_view_interval[1] - target_locator = source_locator - if self.figure._sharey >= 2: - target_view_interval[0] = source_view_interval[1] - target_view_interval[1] = source_view_interval[1] - - target_locator = source_locator - target_axis.set_view_interval(*target_view_interval) - target_axis.set_minor_locator(target_locator) + if getattr(self.figure, f"_share{which}") >= 2: + target_axis.set_view_interval(*source_axis.get_view_interval()) + target_axis.set_minor_locator(source_axis.get_minor_locator()) @override def draw(self, renderer=None, *args, **kwargs): From 60a28487a35349442acf482719947d68c9765242 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 09:18:29 +0200 Subject: [PATCH 08/75] bump handle axis sharing geo --- ultraplot/axes/geo.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index fa134f95..675396a2 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -653,15 +653,19 @@ def _apply_axis_sharing(self): the leftmost and bottommost is the *figure* sharing level. """ # Handle X axis sharing - self._handle_axis_sharing( - source_axis=self._sharex._lonaxis, - target_axis=self._lonaxis, - which="x", - ) + # + if self._sharex: + self._handle_axis_sharing( + source_axis=self._sharex._lonaxis, + target_axis=self._lonaxis, + which="x", + ) + # Handle Y axis sharing - self._handle_axis_sharing( - source_axis=self._sharey._lataxis, target_axis=self._lataxis, which="y" - ) + if self._sharey: + self._handle_axis_sharing( + source_axis=self._sharey._lataxis, target_axis=self._lataxis, which="y" + ) # This block is apart of the draw sequence as the # gridliner object is created late in the From 010001e76c4e23012ff3395196678c3a8b8320f1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 09:31:51 +0200 Subject: [PATCH 09/75] refactor and update test --- ultraplot/tests/test_geographic.py | 69 +++++++++++++++--------------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 05e1a4c9..5c8cc159 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -491,7 +491,8 @@ def test_get_gridliner_labels_cartopy(): uplt.close(fig) -def test_sharing_levels(): +@pytest.mark.parametrize("level", [0, 1, 2, 3, 4]) +def test_sharing_levels(level): """ We can share limits or labels. We check if we can do both for the GeoAxes. @@ -515,7 +516,6 @@ def test_sharing_levels(): x = np.array([0, 10]) y = np.array([0, 10]) - sharing_levels = [0, 1, 2, 3, 4] lonlim = latlim = np.array((-10, 10)) def assert_views_are_sharing(ax): @@ -557,40 +557,39 @@ def assert_views_are_sharing(ax): assert np.allclose(l1, 0) == share_x assert np.allclose(l2, 0) == share_y - for level in sharing_levels: - fig, ax = uplt.subplots(ncols=2, nrows=2, proj="cyl", share=level) - ax.format(labels="both") - for axi in ax: - axi.format( - lonlim=lonlim * axi.number, - latlim=latlim * axi.number, - ) + fig, ax = uplt.subplots(ncols=2, nrows=2, proj="cyl", share=level) + ax.format(labels="both") + for axi in ax: + axi.format( + lonlim=lonlim * axi.number, + latlim=latlim * axi.number, + ) - fig.canvas.draw() - for idx, axi in enumerate(ax): - axi.plot(x * (idx + 1), y * (idx + 1)) - - fig.canvas.draw() # need this to update the labels - # All the labels should be on - for axi in ax: - side_labels = axi._get_gridliner_labels( - left=True, - right=True, - top=True, - bottom=True, - ) - s = 0 - for dir, labels in side_labels.items(): - s += any([label.get_visible() for label in labels]) - - assert_views_are_sharing(axi) - # When we share the labels but not the limits, - # we expect all ticks to be on - if level == 0: - assert s == 4 - else: - assert s == 2 - uplt.close(fig) + fig.canvas.draw() + for idx, axi in enumerate(ax): + axi.plot(x * (idx + 1), y * (idx + 1)) + + fig.canvas.draw() # need this to update the labels + # All the labels should be on + for axi in ax: + side_labels = axi._get_gridliner_labels( + left=True, + right=True, + top=True, + bottom=True, + ) + s = 0 + for dir, labels in side_labels.items(): + s += any([label.get_visible() for label in labels]) + + assert_views_are_sharing(axi) + # When we share the labels but not the limits, + # we expect all ticks to be on + if level < 3: + assert s == 4 + else: + assert s == 2 + uplt.close(fig) @pytest.mark.mpl_image_compare From d63d9330ecc0f6b6845226b59f9feeb554163526 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 09:47:09 +0200 Subject: [PATCH 10/75] change x test to be mpl specific --- ultraplot/tests/test_sharing.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ultraplot/tests/test_sharing.py b/ultraplot/tests/test_sharing.py index 9ce7433a..925c7fe3 100644 --- a/ultraplot/tests/test_sharing.py +++ b/ultraplot/tests/test_sharing.py @@ -75,6 +75,14 @@ def test_sharing_levels_x(share_level): border_axes.add(axes) for axi in axs: tick_params = axi.xaxis.get_tick_params() + from ultraplot.internals.versions import _mpl_version + from packaging import version + + directions = ( + ["top", "bottom"] + if version.parse(str(_mpl_version)) < version.parse("3.10") + else ["left", "right"] + ) for direction in ["top", "bottom"]: label_key = f"label{direction}" visible = tick_params.get(label_key, False) From 452533e0386e87fca2d173e3a27084c89e5bf72f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 09:48:10 +0200 Subject: [PATCH 11/75] change x test to be mpl specific --- ultraplot/tests/test_sharing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ultraplot/tests/test_sharing.py b/ultraplot/tests/test_sharing.py index 925c7fe3..e0a6e402 100644 --- a/ultraplot/tests/test_sharing.py +++ b/ultraplot/tests/test_sharing.py @@ -75,12 +75,12 @@ def test_sharing_levels_x(share_level): border_axes.add(axes) for axi in axs: tick_params = axi.xaxis.get_tick_params() - from ultraplot.internals.versions import _mpl_version + from ultraplot.internals.versions import _version_mpl from packaging import version directions = ( ["top", "bottom"] - if version.parse(str(_mpl_version)) < version.parse("3.10") + if version.parse(str(_version_mpl)) < version.parse("3.10") else ["left", "right"] ) for direction in ["top", "bottom"]: From 7394633ef87c7a4cc9003ef19a3989cf48bd79a0 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 17:41:33 +0200 Subject: [PATCH 12/75] revert check --- ultraplot/axes/cartesian.py | 170 +++++------------------------------- ultraplot/tests/conftest.py | 3 + 2 files changed, 23 insertions(+), 150 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index f81bd2ec..03811e9e 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -2,6 +2,7 @@ """ The standard Cartesian axes used for most ultraplot figures. """ +from cProfile import label import copy import inspect @@ -384,156 +385,25 @@ def _apply_axis_sharing(self): # bottommost or to the *right* of the leftmost panel. But the sharing level # used for the leftmost and bottommost is the *figure* sharing level. - # 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: str, - border_axes: dict[str, plot.PlotAxes], - ) -> None: - """ - 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) - - if level >= 1: - 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) - # Turn minor ticks off - 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[str], - border_sides: list[str], - border_axes: dict[str, list[plot.PlotAxes]], - ) -> dict[str, bool]: - """ - 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 = {} - - 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_param == "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 - has_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 # key is tuple (side, top | center | lower) - ] - if not panel in colorbars: - # Skip colorbar as their - # yaxis is not shared - has_panel = True - break - # When we have a panel, let the panel have - # 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 we are a panel we "push" the labels outwards - label_param_trans = _convert_label_param(label_param) - is_this_tick_on = ticks[label_param_trans] - is_parent_tick_on = sharing_ticks[label_param_trans] - if is_panel: - label_visibility[label_param] = is_parent_tick_on - elif is_border or getattr(self.figure, f"_share{axis_name}") < 3: - # turn on sharing when on border - # or sharing is below 3 - label_visibility[label_param] = ( - is_this_tick_on or sharing_ticks[label_param_trans] - ) - return label_visibility + if self._sharex is not None and self.xaxis.get_visible(): + # If we are sharing with a panel + # turn all labels off + level = 3 if self._panel_sharex_group else self.figure._sharex + if level > 0: + labels._transfer_label(self.xaxis.label, self._sharex.xaxis.label) + if level > 2: + # WARNING: Cannot set NullFormatter because shared axes share the + # same Ticker(). Instead use approach copied from mpl subplots(). + ticks_visible = dict(labeltop=False, labelbottom=False) + self.xaxis.set_tick_params(which="both", **ticks_visible) + + if self._sharey is not None and self.yaxis.get_visible(): + level = 3 if self._panel_sharey_group else self.figure._sharey + if level > 0: + labels._transfer_label(self.yaxis.label, self._sharey.yaxis.label) + if level > 2: + ticks_visible = dict(labelleft=False, labelright=False) + self.yaxis.set_tick_params(which="both", **ticks_visible) def _add_alt(self, sx, **kwargs): """ diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 0a76ac24..1dff5edb 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -2,6 +2,9 @@ from pathlib import Path import warnings, logging +logging.getLogger("matplotlib").setLevel(logging.ERROR) + + SEED = 51423 From cdc220cd42cb07d054afdb040b6b895fce70b1eb Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 22:37:34 +0200 Subject: [PATCH 13/75] reversion to new --- ultraplot/axes/cartesian.py | 166 +++++++++++++++++++++++++++++++----- 1 file changed, 147 insertions(+), 19 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 03811e9e..02167fcb 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -385,25 +385,153 @@ def _apply_axis_sharing(self): # bottommost or to the *right* of the leftmost panel. But the sharing level # used for the leftmost and bottommost is the *figure* sharing level. - if self._sharex is not None and self.xaxis.get_visible(): - # If we are sharing with a panel - # turn all labels off - level = 3 if self._panel_sharex_group else self.figure._sharex - if level > 0: - labels._transfer_label(self.xaxis.label, self._sharex.xaxis.label) - if level > 2: - # WARNING: Cannot set NullFormatter because shared axes share the - # same Ticker(). Instead use approach copied from mpl subplots(). - ticks_visible = dict(labeltop=False, labelbottom=False) - self.xaxis.set_tick_params(which="both", **ticks_visible) - - if self._sharey is not None and self.yaxis.get_visible(): - level = 3 if self._panel_sharey_group else self.figure._sharey - if level > 0: - labels._transfer_label(self.yaxis.label, self._sharey.yaxis.label) - if level > 2: - ticks_visible = dict(labelleft=False, labelright=False) - self.yaxis.set_tick_params(which="both", **ticks_visible) + # 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: str, + border_axes: dict[str, plot.PlotAxes], + ) -> None: + """ + 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) + # Turn minor ticks off + 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[str], + border_sides: list[str], + border_axes: dict[str, list[plot.PlotAxes]], + ) -> dict[str, bool]: + """ + 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 = {} + + 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_param == "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 + has_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 # key is tuple (side, top | center | lower) + ] + if not panel in colorbars: + # Skip colorbar as their + # yaxis is not shared + has_panel = True + break + # When we have a panel, let the panel have + # 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 we are a panel we "push" the labels outwards + label_param_trans = _convert_label_param(label_param) + is_this_tick_on = ticks[label_param_trans] + is_parent_tick_on = sharing_ticks[label_param_trans] + if is_panel: + label_visibility[label_param] = is_parent_tick_on + elif is_border: + label_visibility[label_param] = is_this_tick_on + return label_visibility def _add_alt(self, sx, **kwargs): """ From 10e329a10c3d05516bd907006b4603bd11db3ff5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 22:44:21 +0200 Subject: [PATCH 14/75] formatting --- ultraplot/axes/geo.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 675396a2..8c901d93 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -664,7 +664,9 @@ def _apply_axis_sharing(self): # Handle Y axis sharing if self._sharey: self._handle_axis_sharing( - source_axis=self._sharey._lataxis, target_axis=self._lataxis, which="y" + source_axis=self._sharey._lataxis, + target_axis=self._lataxis, + which="y", ) # This block is apart of the draw sequence as the From 13702c061b29b6094b20bd45c8fcb770e129c09c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 22:44:43 +0200 Subject: [PATCH 15/75] rm added import --- ultraplot/axes/cartesian.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 02167fcb..f5538050 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -2,7 +2,6 @@ """ The standard Cartesian axes used for most ultraplot figures. """ -from cProfile import label import copy import inspect From 08e77e3596019ec2b8955331d8d6c5c8d424dfce Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 23:01:47 +0200 Subject: [PATCH 16/75] fix test --- ultraplot/tests/test_sharing.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ultraplot/tests/test_sharing.py b/ultraplot/tests/test_sharing.py index e0a6e402..b2658a47 100644 --- a/ultraplot/tests/test_sharing.py +++ b/ultraplot/tests/test_sharing.py @@ -62,6 +62,7 @@ def test_sharing_levels_x(share_level): axs.format(title=f"sharex = {share_level}") fig.canvas.draw() # needed for checks + # Get the border axes if fig._sharex < 3: border_axes = set(axs) else: @@ -73,20 +74,24 @@ def test_sharing_levels_x(share_level): border_axes.update(axes) else: border_axes.add(axes) + + # Run tests for axi in axs: tick_params = axi.xaxis.get_tick_params() + # Get correct directions depending on mpl version from ultraplot.internals.versions import _version_mpl from packaging import version - directions = ( - ["top", "bottom"] - if version.parse(str(_version_mpl)) < version.parse("3.10") - else ["left", "right"] - ) + if version.parse(str(_version_mpl)) >= version.parse("3.10"): + direction_label_map = {"top": "labeltop", "bottom": "labelbottom"} + else: + direction_label_map = {"top": "labelright", "bottom": "labelleft"} + for direction in ["top", "bottom"]: - label_key = f"label{direction}" + label_key = direction_label_map[direction] visible = tick_params.get(label_key, False) is_border = axi in fig._get_border_axes().get(direction, []) + print(axi.number, is_border, share_level, visible, tick_params) if direction == "bottom" and (fig._sharex < 3 or is_border): assert visible else: From f962859c018c2434876b82437673cef0f90a0b19 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 23:04:49 +0200 Subject: [PATCH 17/75] minor formatting --- ultraplot/axes/geo.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 8c901d93..2a258e84 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -653,7 +653,6 @@ def _apply_axis_sharing(self): the leftmost and bottommost is the *figure* sharing level. """ # Handle X axis sharing - # if self._sharex: self._handle_axis_sharing( source_axis=self._sharex._lonaxis, From 3aea23600ab42c6bc2cf22365573a7faa71c4356 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 23:24:20 +0200 Subject: [PATCH 18/75] satisfy codecov --- ultraplot/tests/test_figure.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 0e92f8f2..2bde251f 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -58,6 +58,41 @@ def test_unsharing_different_rectilinear(): """ with pytest.warns(uplt.internals.warnings.UltraPlotWarning): fig, ax = uplt.subplots(ncols=2, proj=("cyl", "merc"), share="all") + + +def test_get_renderer_basic(): + """ + Test that _get_renderer returns a renderer object. + """ + fig, ax = uplt.subplots() + renderer = fig._get_renderer() + # Renderer should not be None and should have draw_path method + assert renderer is not None + assert hasattr(renderer, "draw_path") + + +def test_share_labels_with_others_no_sharing(): + """ + Test that _share_labels_with_others returns early when no sharing is set. + """ + fig, ax = uplt.subplots() + fig._sharex = 0 + fig._sharey = 0 + # Should simply return without error + result = fig._share_labels_with_others() + assert result is None + + +def test_share_labels_with_others_with_sharing(): + """ + Test that _share_labels_with_others runs when sharing is enabled. + """ + fig, ax = uplt.subplots(ncols=2, sharex=1, sharey=1) + fig._sharex = 1 + fig._sharey = 1 + # Should not return early + fig._share_labels_with_others() + # No assertion, just check for coverage and no error uplt.close(fig) From ddb1ef7ecddeceff1bb7b2779d4dd431dcaf8890 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Fri, 19 Sep 2025 13:00:13 +0200 Subject: [PATCH 19/75] Update ultraplot/tests/test_sharing.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ultraplot/tests/test_sharing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/tests/test_sharing.py b/ultraplot/tests/test_sharing.py index b2658a47..1c026d39 100644 --- a/ultraplot/tests/test_sharing.py +++ b/ultraplot/tests/test_sharing.py @@ -91,7 +91,6 @@ def test_sharing_levels_x(share_level): label_key = direction_label_map[direction] visible = tick_params.get(label_key, False) is_border = axi in fig._get_border_axes().get(direction, []) - print(axi.number, is_border, share_level, visible, tick_params) if direction == "bottom" and (fig._sharex < 3 or is_border): assert visible else: From 6341074da51178bc51fc5ec32b509234def0d4bf Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Fri, 19 Sep 2025 13:00:24 +0200 Subject: [PATCH 20/75] Update ultraplot/tests/test_sharing.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ultraplot/tests/test_sharing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_sharing.py b/ultraplot/tests/test_sharing.py index 1c026d39..199ec7f9 100644 --- a/ultraplot/tests/test_sharing.py +++ b/ultraplot/tests/test_sharing.py @@ -5,7 +5,7 @@ Axis labels are pushed to the border subplots when the sharing level is greater than 1. -Ticks are visible only on the border plots when the sharing levels is greater than 2. +Ticks are visible only on the border plots when the sharing level is greater than 2. Or more verbosely: sharey = 0: no sharing, all labels and ticks visible From 345009e8829bf8c4cfcfbb10fa887acb9086820d Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Fri, 19 Sep 2025 13:00:32 +0200 Subject: [PATCH 21/75] Update ultraplot/tests/test_sharing.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ultraplot/tests/test_sharing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_sharing.py b/ultraplot/tests/test_sharing.py index 199ec7f9..7f884eed 100644 --- a/ultraplot/tests/test_sharing.py +++ b/ultraplot/tests/test_sharing.py @@ -1,7 +1,7 @@ import pytest, ultraplot as uplt """ -Sharing levels for subplots determine the visbility of the axis labels and tick labels. +Sharing levels for subplots determine the visibility of the axis labels and tick labels. Axis labels are pushed to the border subplots when the sharing level is greater than 1. From d65c14132476ff1f8d15a043cf338eb85217997e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 19 Sep 2025 21:12:43 +0200 Subject: [PATCH 22/75] restore typo with sharing level --- ultraplot/figure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 04d7eb9b..2c48c921 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1965,7 +1965,7 @@ def format( # When we apply formatting to all axes, we need # to potentially adjust the labels. - if len(axs) == len(self.axes) and (self._sharex >= 3 and self._sharey >= 3): + if len(axs) == len(self.axes) and (self._sharex > 0 or self._sharey > 0): self._share_labels_with_others() # Warn unused keyword argument(s) From b1a8b06bbd9d0c393af9b41faff514682bd78690 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 19 Sep 2025 21:13:37 +0200 Subject: [PATCH 23/75] clarify comment --- ultraplot/tests/test_sharing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_sharing.py b/ultraplot/tests/test_sharing.py index 7f884eed..9d2ef59e 100644 --- a/ultraplot/tests/test_sharing.py +++ b/ultraplot/tests/test_sharing.py @@ -9,7 +9,7 @@ Or more verbosely: sharey = 0: no sharing, all labels and ticks visible - sharey = 1: share axis, all labels and ticks visible + sharey = 1: share axis labels, tick labels are still independent sharey = 2: share limits sharey = 3 or True, share both ticks and labels A similar story holds for sharex. From f720d09ab586981e4697383100c0e3725b6632f0 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 19 Sep 2025 21:14:27 +0200 Subject: [PATCH 24/75] specify sharing limit --- ultraplot/tests/test_sharing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_sharing.py b/ultraplot/tests/test_sharing.py index 9d2ef59e..620e879f 100644 --- a/ultraplot/tests/test_sharing.py +++ b/ultraplot/tests/test_sharing.py @@ -10,7 +10,7 @@ Or more verbosely: sharey = 0: no sharing, all labels and ticks visible sharey = 1: share axis labels, tick labels are still independent - sharey = 2: share limits + sharey = 2: share data limits sharey = 3 or True, share both ticks and labels A similar story holds for sharex. """ From 579cadde24158dfe75df7f249f803bb7f67262d2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 21 Sep 2025 15:01:04 +0200 Subject: [PATCH 25/75] update geosharing --- ultraplot/axes/geo.py | 10 +++--- ultraplot/figure.py | 82 ++++++++++++------------------------------- 2 files changed, 28 insertions(+), 64 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 2a258e84..bf9099fe 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -1437,16 +1437,18 @@ def _is_ticklabel_on(self, side: str) -> bool: """ # Deal with different cartopy versions left_labels, right_labels, bottom_labels, top_labels = self._get_side_labels() + if self.gridlines_major is None: return False + elif side == "labelleft": - return getattr(self.gridlines_major, left_labels) + return getattr(self.gridlines_major, left_labels) == "y" elif side == "labelright": - return getattr(self.gridlines_major, right_labels) + return getattr(self.gridlines_major, right_labels) == "y" elif side == "labelbottom": - return getattr(self.gridlines_major, bottom_labels) + return getattr(self.gridlines_major, bottom_labels) == "x" elif side == "labeltop": - return getattr(self.gridlines_major, top_labels) + return getattr(self.gridlines_major, top_labels) == "x" else: raise ValueError(f"Invalid side: {side}") diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 2c48c921..fc30cc06 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1281,26 +1281,36 @@ def _share_labels_with_others(self, *, which="both"): for axi in axes: recoded[axi] = recoded.get(axi, []) + [direction] - are_ticks_on = False default = dict( - labelleft=are_ticks_on, - labelright=are_ticks_on, - labeltop=are_ticks_on, - labelbottom=are_ticks_on, + labelleft=False, + labelright=False, + labeltop=False, + labelbottom=False, ) + sides = "top bottom left right".split() 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() - 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 + match side: + case "left" | "right": + if self._sharey < 3: + turn_on_or_off[sidelabel] = is_label_on + else: + # When we are a border an the labels are on + # we keep them on + if side in recoded.get(axi, []): + turn_on_or_off[sidelabel] = is_label_on + case "top" | "bottom": + if self._sharex < 3: + turn_on_or_off[sidelabel] = is_label_on + else: + # When we are a border an the labels are on + # we keep them on + if side in recoded.get(axi, []): + turn_on_or_off[sidelabel] = is_label_on if isinstance(axi, paxes.GeoAxes): axi._toggle_gridliner_labels(**turn_on_or_off) @@ -1816,7 +1826,6 @@ def _align_content(): # noqa: E306 # subsequent tight layout really weird. Have to resize twice. _draw_content() if not gs: - print("hello") return if aspect: gs._auto_layout_aspect() @@ -1979,53 +1988,6 @@ 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 > 0) - 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( From e18b02af69d32974ff43375c7692fb891b9f1415 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 21 Sep 2025 15:21:21 +0200 Subject: [PATCH 26/75] propagate shared axis for border without shared axis --- ultraplot/axes/cartesian.py | 26 +++++++++++++++++--------- ultraplot/tests/test_axes.py | 2 +- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 46685b5d..28e74d1e 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -425,13 +425,15 @@ def _apply_axis_sharing_for_axis( label_params = ["labelleft", "labelright"] border_sides = ["left", "right"] - if shared_axis is None or not axis.get_visible(): + if not axis.get_visible(): return level = 3 if panel_group else sharing_level # Handle axis label sharing (level > 0) - if level > 0: + # If we are a border axis, @shared_axis may be None + # We propagate this through the _determine_tick_label_visiblity() logic + if level > 0 and shared_axis: shared_axis_obj = getattr(shared_axis, f"{axis_name}axis") labels._transfer_label(axis.label, shared_axis_obj.label) axis.label.set_visible(False) @@ -483,8 +485,11 @@ def _determine_tick_label_visibility( 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() + + sharing_ticks = {} + if shared_axis: + shared_axis_obj = getattr(shared_axis, f"{axis_name}axis") + sharing_ticks = shared_axis_obj.get_tick_params() label_visibility = {} @@ -519,19 +524,22 @@ def _convert_label_param(label_param: str) -> str: 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] - ) + is_panel = False + if shared_axis: + 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 we are a panel we "push" the labels outwards label_param_trans = _convert_label_param(label_param) is_this_tick_on = ticks[label_param_trans] - is_parent_tick_on = sharing_ticks[label_param_trans] + is_parent_tick_on = sharing_ticks.get(label_param_trans, False) if is_panel: label_visibility[label_param] = is_parent_tick_on elif is_border: label_visibility[label_param] = is_this_tick_on + print(self.number, label_visibility) return label_visibility def _add_alt(self, sx, **kwargs): diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index a04c2233..f94dc23c 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -352,7 +352,7 @@ def test_sharing_labels_top_right(): [3, 4, 5], [3, 4, 0], ], - 3, # default sharing level + True, # default sharing level {"xticklabelloc": "t", "yticklabelloc": "r"}, [1, 3, 4], # y-axis labels visible indices [0, 1, 4], # x-axis labels visible indices From c52f0e657debd5943f9468cb8d306feebce738f2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 22 Sep 2025 16:37:27 +0200 Subject: [PATCH 27/75] move apply axis to each subtype --- ultraplot/axes/base.py | 6 +++++ ultraplot/axes/geo.py | 27 +++++++++++++++++++ ultraplot/axes/polar.py | 5 ++++ ultraplot/figure.py | 60 ----------------------------------------- 4 files changed, 38 insertions(+), 60 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 22e489d1..db5aab3c 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -1518,6 +1518,12 @@ def _apply_title_above(self): for name in names: labels._transfer_label(self._title_dict[name], pax._title_dict[name]) + def _apply_axis_sharing(self): + """ + Should be implemented by subclasses but silently pass if not, e.g. for polar axes + """ + raise ImplementationError("Axis sharing not implemented for this axes type.") + def _apply_auto_share(self): """ Automatically configure axis sharing based on the horizontal and diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index bf9099fe..957a59a1 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -722,6 +722,33 @@ def _handle_axis_sharing( source_axis: The source axis to share from target_axis: The target axis to apply sharing to """ + + # Turn the ticks on or off depending on the position + sides = "top bottom".split() if which == "x" else "left right".split() + border_to_ax = self.figure._get_border_axes() + turn_on_or_off = {} + for side in sides: + sidelabel = f"label{side}" + is_label_on = self._is_ticklabel_on(sidelabel) + turn_on_or_off[sidelabel] = False # default is False + match side: + case "left" | "right": + if self.figure._sharey < 3: + turn_on_or_off[sidelabel] = is_label_on + else: + # When we are a border an the labels are on + # we keep them on + if self in border_to_ax.get(side, False): + turn_on_or_off[sidelabel] = is_label_on + case "top" | "bottom": + if self.figure._sharex < 3: + turn_on_or_off[sidelabel] = is_label_on + else: + # When we are a border an the labels are on + # we keep them on + if self in border_to_ax.get(side, False): + turn_on_or_off[sidelabel] = is_label_on + # Copy view interval and minor locator from source to target if getattr(self.figure, f"_share{which}") >= 2: target_axis.set_view_interval(*source_axis.get_view_interval()) diff --git a/ultraplot/axes/polar.py b/ultraplot/axes/polar.py index d66e3e2e..ae8a78d6 100644 --- a/ultraplot/axes/polar.py +++ b/ultraplot/axes/polar.py @@ -138,6 +138,11 @@ def __init__(self, *args, **kwargs): for axis in (self.xaxis, self.yaxis): axis.set_tick_params(which="both", size=0) + @override + def _apply_axis_sharing(self): + # Not implemented. Silently pass + return + def _update_formatter(self, x, *, formatter=None, formatter_kw=None): """ Update the gridline label formatter. diff --git a/ultraplot/figure.py b/ultraplot/figure.py index fc30cc06..cb03af74 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1257,66 +1257,6 @@ def _unshare_axes(self): if isinstance(ax, paxes.GeoAxes) and hasattr(ax, "set_global"): ax.set_global() - def _share_labels_with_others(self, *, which="both"): - """ - Helpers function to ensure the labels - are shared for rectilinear GeoAxes. - """ - # Only apply sharing of labels when we are - # actually sharing labels. - if self._sharex == 0 and self._sharey == 0: - return - # 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. - # 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 = {} - for direction, axes in border_axes.items(): - for axi in axes: - recoded[axi] = recoded.get(axi, []) + [direction] - - default = dict( - labelleft=False, - labelright=False, - labeltop=False, - labelbottom=False, - ) - sides = "top bottom left right".split() - for axi in self._iter_axes(hidden=False, panels=False, children=False): - # Turn the ticks on or off depending on the position - turn_on_or_off = default.copy() - for side in sides: - sidelabel = f"label{side}" - is_label_on = axi._is_ticklabel_on(sidelabel) - match side: - case "left" | "right": - if self._sharey < 3: - turn_on_or_off[sidelabel] = is_label_on - else: - # When we are a border an the labels are on - # we keep them on - if side in recoded.get(axi, []): - turn_on_or_off[sidelabel] = is_label_on - case "top" | "bottom": - if self._sharex < 3: - turn_on_or_off[sidelabel] = is_label_on - else: - # When we are a border an the labels are on - # we keep them on - if side in recoded.get(axi, []): - turn_on_or_off[sidelabel] = is_label_on - - if isinstance(axi, paxes.GeoAxes): - axi._toggle_gridliner_labels(**turn_on_or_off) - else: - axi._apply_axis_sharing() - def _toggle_axis_sharing( self, *, From e31cf678b5a4a0a69adc085880e5014cc65bcc84 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 22 Sep 2025 16:37:43 +0200 Subject: [PATCH 28/75] add import on polar --- ultraplot/axes/polar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ultraplot/axes/polar.py b/ultraplot/axes/polar.py index ae8a78d6..74d08304 100644 --- a/ultraplot/axes/polar.py +++ b/ultraplot/axes/polar.py @@ -3,6 +3,7 @@ Polar axes using azimuth and radius instead of *x* and *y*. """ import inspect +from typing import override import matplotlib.projections.polar as mpolar import numpy as np From 1c62fefa7138aa163fef818cf1411ec715a1414a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 22 Sep 2025 16:37:58 +0200 Subject: [PATCH 29/75] remove redundant function --- ultraplot/figure.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index cb03af74..485afd05 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1914,9 +1914,6 @@ def format( # When we apply formatting to all axes, we need # to potentially adjust the labels. - if len(axs) == len(self.axes) and (self._sharex > 0 or self._sharey > 0): - self._share_labels_with_others() - # Warn unused keyword argument(s) kw = { key: value From f76e4af5fff9705eb201a6bdc6c118fb809d51c1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 22 Sep 2025 16:38:54 +0200 Subject: [PATCH 30/75] refactor cartesian sharing --- ultraplot/axes/cartesian.py | 69 ++++++++++--------------------------- 1 file changed, 18 insertions(+), 51 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 28e74d1e..202cc954 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -415,15 +415,11 @@ def _apply_axis_sharing_for_axis( 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 not axis.get_visible(): return @@ -440,55 +436,41 @@ def _apply_axis_sharing_for_axis( # 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, - ) + label_visibility = self._determine_tick_label_visibility(which=axis_name) axis.set_tick_params(which="both", **label_visibility) # Turn minor ticks off 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[str], - border_sides: list[str], - border_axes: dict[str, list[plot.PlotAxes]], + *, + which: str, ) -> dict[str, bool]: """ 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() + axis: str ('x' or 'y') Returns ------- dict Dictionary of label visibility parameters """ + axis = getattr(self, f"{which}axis") + shared_axis = getattr(self, f"_share{which}") + label_params = ( + ("labeltop", "labelbottom") if which == "x" else ("labelleft", "labelright") + ) + border_sides = ("top", "bottom") if which == "x" else ("left", "right") + border_axes = self.figure._get_border_axes() + ticks = axis.get_tick_params() sharing_ticks = {} if shared_axis: - shared_axis_obj = getattr(shared_axis, f"{axis_name}axis") + shared_axis_obj = getattr(shared_axis, f"{which}axis") sharing_ticks = shared_axis_obj.get_tick_params() label_visibility = {} @@ -497,32 +479,15 @@ 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": + if label_param == "labeltop" and which == "x": label_param = "labelright" - elif label_param == "labelbottom" and axis_name == "x": + elif label_param == "labelbottom" and which == "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 - has_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 # key is tuple (side, top | center | lower) - ] - if not panel in colorbars: - # Skip colorbar as their - # yaxis is not shared - has_panel = True - break - # When we have a panel, let the panel have - # the labels and turn-off for this axis + side. - if has_panel: - continue is_border = self in border_axes.get(border_side, []) is_panel = False if shared_axis: @@ -535,11 +500,13 @@ def _convert_label_param(label_param: str) -> str: label_param_trans = _convert_label_param(label_param) is_this_tick_on = ticks[label_param_trans] is_parent_tick_on = sharing_ticks.get(label_param_trans, False) + # print(self.number, is_panel, is_legend, is_colorbar, border_side) if is_panel: label_visibility[label_param] = is_parent_tick_on + elif self.number is None: # for legend, colorbars + label_visibility[label_param] = is_this_tick_on elif is_border: label_visibility[label_param] = is_this_tick_on - print(self.number, label_visibility) return label_visibility def _add_alt(self, sx, **kwargs): From c656a17e2e2ac2a480916ce89b7f3cf1e4396f3b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 23 Sep 2025 09:43:03 +0200 Subject: [PATCH 31/75] fixed sharing edge cases cartesian --- ultraplot/axes/cartesian.py | 44 +++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 202cc954..a85c368d 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -412,8 +412,8 @@ def _apply_axis_sharing_for_axis( """ if axis_name == "x": axis = self.xaxis - shared_axis = self._sharex - panel_group = self._panel_sharex_group + shared_axis = self._sharex # do we share the xaxis? + panel_group = self._panel_sharex_group # do we have a panel? sharing_level = self.figure._sharex else: # axis_name == 'y' axis = self.yaxis @@ -490,6 +490,7 @@ def _convert_label_param(label_param: str) -> str: label_visibility[label_param] = False is_border = self in border_axes.get(border_side, []) is_panel = False + is_colorbar = True if self._colorbar_fill else False if shared_axis: is_panel = ( self in shared_axis._panel_dict[border_side] @@ -500,12 +501,41 @@ def _convert_label_param(label_param: str) -> str: label_param_trans = _convert_label_param(label_param) is_this_tick_on = ticks[label_param_trans] is_parent_tick_on = sharing_ticks.get(label_param_trans, False) - # print(self.number, is_panel, is_legend, is_colorbar, border_side) - if is_panel: - label_visibility[label_param] = is_parent_tick_on - elif self.number is None: # for legend, colorbars + are_we_sharing_labels_on_ax = ( + self._panel_sharex_group if which == "x" else self._panel_sharey_group + ) + # To share all axes we need to consider a few cases. + + # Case 1 and 2: Sharing top and right labels only on the + # figure borders or when we are not an alternate axis + + if is_colorbar: label_visibility[label_param] = is_this_tick_on - elif is_border: + elif ( + not self._altx_parent + and border_side == "top" + and self.figure._sharex > 2 + ): + label_visibility[label_param] = is_border and is_this_tick_on + elif ( + not self._alty_parent + and border_side == "right" + and self.figure._sharey > 2 + ): + label_visibility[label_param] = is_border and is_this_tick_on + # Case 3: share axis labels when we are sharing axes set + elif (which == "x" and self._sharex) or (which == "y" and self._sharey): + # Shared subplot. Labels are off unless it's a border. + # On the border, respect the local tick setting. + label_visibility[label_param] = False + # or when we are sharing the labels + elif are_we_sharing_labels_on_ax: + # Panel sharing. Labels are off unless it's a border or the outermost panel. + label_visibility[label_param] = False + # Case 4: singular axes we check if the ticks are on + else: + # print("here", self.number, which, is_this_tick_on) + # Not sharing. label_visibility[label_param] = is_this_tick_on return label_visibility From e2c2989d09317a2f9c741b4e905f213b9fb18ff5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 23 Sep 2025 10:14:36 +0200 Subject: [PATCH 32/75] refactor sharing handler to be similar to cartesian --- ultraplot/axes/geo.py | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 957a59a1..bffc9064 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -652,19 +652,26 @@ def _apply_axis_sharing(self): or to the *right* of the leftmost panel. But the sharing level used for the leftmost and bottommost is the *figure* sharing level. """ + + # Share interval x + if self._sharex and self.figure._sharex >= 2: + self._lonaxis.set_view_interval(*self._sharex._lonaxis.get_view_interval()) + self._lonaxis.set_minor_locator(self._sharex._lonaxis.get_minor_locator()) + # Handle X axis sharing - if self._sharex: + if self.figure._sharex > 2: self._handle_axis_sharing( - source_axis=self._sharex._lonaxis, - target_axis=self._lonaxis, which="x", ) + # Share interval y + if self._sharey and self.figure._sharey >= 2: + self._lataxis.set_view_interval(*self._sharey._lataxis.get_view_interval()) + self._lataxis.set_minor_locator(self._sharey._lataxis.get_minor_locator()) + # Handle Y axis sharing - if self._sharey: + if self.figure._sharey > 2: self._handle_axis_sharing( - source_axis=self._sharey._lataxis, - target_axis=self._lataxis, which="y", ) @@ -710,8 +717,6 @@ def _toggle_gridliner_labels( def _handle_axis_sharing( self, - source_axis: "GeoAxes", - target_axis: "GeoAxes", *, which: str, ): @@ -722,15 +727,16 @@ def _handle_axis_sharing( source_axis: The source axis to share from target_axis: The target axis to apply sharing to """ - - # Turn the ticks on or off depending on the position - sides = "top bottom".split() if which == "x" else "left right".split() - border_to_ax = self.figure._get_border_axes() + # 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(same_type=False) turn_on_or_off = {} + sides = ("left", "right", "top", "bottom") for side in sides: sidelabel = f"label{side}" is_label_on = self._is_ticklabel_on(sidelabel) - turn_on_or_off[sidelabel] = False # default is False match side: case "left" | "right": if self.figure._sharey < 3: @@ -738,7 +744,7 @@ def _handle_axis_sharing( else: # When we are a border an the labels are on # we keep them on - if self in border_to_ax.get(side, False): + if self in border_axes.get(side, []): turn_on_or_off[sidelabel] = is_label_on case "top" | "bottom": if self.figure._sharex < 3: @@ -746,13 +752,9 @@ def _handle_axis_sharing( else: # When we are a border an the labels are on # we keep them on - if self in border_to_ax.get(side, False): + if self in border_axes.get(side, []): turn_on_or_off[sidelabel] = is_label_on - - # Copy view interval and minor locator from source to target - if getattr(self.figure, f"_share{which}") >= 2: - target_axis.set_view_interval(*source_axis.get_view_interval()) - target_axis.set_minor_locator(source_axis.get_minor_locator()) + self._toggle_gridliner_labels(**turn_on_or_off) @override def draw(self, renderer=None, *args, **kwargs): From 4ea7867b2d500b30568c6314fc21de6acb91e672 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 23 Sep 2025 12:38:55 +0200 Subject: [PATCH 33/75] further fixes for cartesian sharing --- ultraplot/axes/cartesian.py | 33 +++++++++++++----------- ultraplot/axes/geo.py | 51 ++++++++++++++++++++++--------------- 2 files changed, 48 insertions(+), 36 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index a85c368d..89003b93 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -487,15 +487,9 @@ def _convert_label_param(label_param: str) -> str: 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_border = self in border_axes.get(border_side, []) - is_panel = False + is_panel = True if self._panel_parent else False is_colorbar = True if self._colorbar_fill else False - if shared_axis: - 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 we are a panel we "push" the labels outwards label_param_trans = _convert_label_param(label_param) @@ -508,7 +502,6 @@ def _convert_label_param(label_param: str) -> str: # Case 1 and 2: Sharing top and right labels only on the # figure borders or when we are not an alternate axis - if is_colorbar: label_visibility[label_param] = is_this_tick_on elif ( @@ -516,25 +509,35 @@ def _convert_label_param(label_param: str) -> str: and border_side == "top" and self.figure._sharex > 2 ): - label_visibility[label_param] = is_border and is_this_tick_on + if is_panel: + if self._panel_sharex_group: + panels = self._panel_parent._panel_dict.get(border_side, []) + if panels and self == panels[-1]: + label_visibility[label_param] = is_parent_tick_on + else: + label_visibility[label_param] = is_border and is_this_tick_on elif ( not self._alty_parent and border_side == "right" and self.figure._sharey > 2 ): - label_visibility[label_param] = is_border and is_this_tick_on + if is_panel: + # check if we are sharing hte axis labels + if self._panel_sharey_group: + panels = self._panel_parent._panel_dict.get(border_side, []) + if panels and self == panels[-1]: + label_visibility[label_param] = is_parent_tick_on + else: + label_visibility[label_param] = is_this_tick_on + else: + label_visibility[label_param] = is_border and is_this_tick_on # Case 3: share axis labels when we are sharing axes set elif (which == "x" and self._sharex) or (which == "y" and self._sharey): # Shared subplot. Labels are off unless it's a border. # On the border, respect the local tick setting. label_visibility[label_param] = False - # or when we are sharing the labels - elif are_we_sharing_labels_on_ax: - # Panel sharing. Labels are off unless it's a border or the outermost panel. - label_visibility[label_param] = False # Case 4: singular axes we check if the ticks are on else: - # print("here", self.number, which, is_this_tick_on) # Not sharing. label_visibility[label_param] = is_this_tick_on return label_visibility diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index bffc9064..83837f1c 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -733,27 +733,36 @@ def _handle_axis_sharing( # it once as it is an expensive action. border_axes = self.figure._get_border_axes(same_type=False) turn_on_or_off = {} - sides = ("left", "right", "top", "bottom") - for side in sides: - sidelabel = f"label{side}" - is_label_on = self._is_ticklabel_on(sidelabel) - match side: - case "left" | "right": - if self.figure._sharey < 3: - turn_on_or_off[sidelabel] = is_label_on - else: - # When we are a border an the labels are on - # we keep them on - if self in border_axes.get(side, []): - turn_on_or_off[sidelabel] = is_label_on - case "top" | "bottom": - if self.figure._sharex < 3: - turn_on_or_off[sidelabel] = is_label_on - else: - # When we are a border an the labels are on - # we keep them on - if self in border_axes.get(side, []): - turn_on_or_off[sidelabel] = is_label_on + for label_param, border_side in zip(label_params, border_sides): + is_border = self in border_axes.get(border_side, []) + is_this_tick_on = self._is_ticklabel_on(label_param) + + # Case 1: Top-side of a shared X-axis (for primary axes). + if ( + which == "x" + and not getattr(self, "_altx_parent", None) + and border_side == "top" + and self.figure._sharex > 2 + ): + + # Case 2: Right-side of a shared Y-axis (for primary axes). + elif ( + which == "y" + and not getattr(self, "_alty_parent", None) + and border_side == "right" + and self.figure._sharey > 2 + ): + turn_on_or_off[label_param] = is_border and is_this_tick_on + + # Case 3: Standard bottom/left shared axes. + elif which == "x" and not self._sharex is None and self.figure._sharex > 2: + turn_on_or_off[label_param] = is_border and is_this_tick_on + elif which == "y" and not self._sharey is None and self.figure._sharey > 2: + turn_on_or_off[label_param] = is_border and is_this_tick_on + # Case 4: Standalone axes (no sharing). + else: + turn_on_or_off[label_param] = is_this_tick_on + self._toggle_gridliner_labels(**turn_on_or_off) @override From 7fa21201d49000a940ecfe3bc997b2c03f8f352e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 23 Sep 2025 12:39:20 +0200 Subject: [PATCH 34/75] update gridliner sharing --- ultraplot/axes/geo.py | 47 ++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 83837f1c..0e5c7378 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -659,10 +659,7 @@ def _apply_axis_sharing(self): self._lonaxis.set_minor_locator(self._sharex._lonaxis.get_minor_locator()) # Handle X axis sharing - if self.figure._sharex > 2: - self._handle_axis_sharing( - which="x", - ) + self._handle_axis_sharing(which="x") # Share interval y if self._sharey and self.figure._sharey >= 2: @@ -670,10 +667,7 @@ def _apply_axis_sharing(self): self._lataxis.set_minor_locator(self._sharey._lataxis.get_minor_locator()) # Handle Y axis sharing - if self.figure._sharey > 2: - self._handle_axis_sharing( - which="y", - ) + self._handle_axis_sharing(which="y") # This block is apart of the draw sequence as the # gridliner object is created late in the @@ -719,7 +713,7 @@ def _handle_axis_sharing( self, *, which: str, - ): + ) -> None: """ Helper method to handle axis sharing for both X and Y axes. @@ -727,11 +721,22 @@ def _handle_axis_sharing( source_axis: The source axis to share from target_axis: The target axis to apply sharing to """ - # 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(same_type=False) + if self.figure._sharex == 0 and which == "x": + return + if self.figure._sharey == 0 and which == "y": + return + # This logic is adapted from CartesianAxes._determine_tick_label_visibility + # to provide consistent tick label sharing behavior for GeoAxes. + # Per user guidance, it excludes panel and colorbar logic. + + axis = getattr(self, f"{which}axis") + ticks = axis.get_tick_params() + label_params = ( + ("labeltop", "labelbottom") if which == "x" else ("labelleft", "labelright") + ) + border_sides = ("top", "bottom") if which == "x" else ("left", "right") + border_axes = self.figure._get_border_axes() + turn_on_or_off = {} for label_param, border_side in zip(label_params, border_sides): is_border = self in border_axes.get(border_side, []) @@ -745,6 +750,8 @@ def _handle_axis_sharing( and self.figure._sharex > 2 ): + turn_on_or_off[label_param] = is_border and is_this_tick_on + # Case 2: Right-side of a shared Y-axis (for primary axes). elif ( which == "y" @@ -759,6 +766,7 @@ def _handle_axis_sharing( turn_on_or_off[label_param] = is_border and is_this_tick_on elif which == "y" and not self._sharey is None and self.figure._sharey > 2: turn_on_or_off[label_param] = is_border and is_this_tick_on + # Case 4: Standalone axes (no sharing). else: turn_on_or_off[label_param] = is_this_tick_on @@ -1480,13 +1488,13 @@ def _is_ticklabel_on(self, side: str) -> bool: return False elif side == "labelleft": - return getattr(self.gridlines_major, left_labels) == "y" + return getattr(self.gridlines_major, left_labels) elif side == "labelright": return getattr(self.gridlines_major, right_labels) == "y" elif side == "labelbottom": - return getattr(self.gridlines_major, bottom_labels) == "x" + return getattr(self.gridlines_major, bottom_labels) elif side == "labeltop": - return getattr(self.gridlines_major, top_labels) == "x" + return getattr(self.gridlines_major, top_labels) else: raise ValueError(f"Invalid side: {side}") @@ -1508,8 +1516,9 @@ def _toggle_gridliner_labels( togglers = (labelleft, labelright, labelbottom, labeltop) gl = self.gridlines_major for toggle, side in zip(togglers, side_labels): - if getattr(gl, side) != toggle: - setattr(gl, side, toggle) + if toggle is None: + continue + setattr(gl, side, toggle) if geo is not None: # only cartopy 0.20 supported but harmless setattr(gl, "geo_labels", geo) From 57a5f334f7bc31b5e3e09ee85d248c16ddd3bf30 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 23 Sep 2025 12:39:41 +0200 Subject: [PATCH 35/75] update tests --- ultraplot/tests/test_axes.py | 1 + ultraplot/tests/test_geographic.py | 27 +++++++++++++-------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index f94dc23c..75ccb3aa 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -405,6 +405,7 @@ def check_state(ax, numbers, state, which): # Format axes with the specified tick label locations ax.format(**tick_loc) + fig.canvas.draw() # needed for sharing labels # Calculate the indices where labels should be hidden all_indices = list(range(len(ax))) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 5c8cc159..e2564bd6 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -296,6 +296,7 @@ def are_labels_on(ax, which=["top", "bottom", "right", "left"]) -> tuple[bool]: settings = dict(land=True, ocean=True, labels="both") fig, ax = uplt.subplots(layout, share="all", proj="cyl") ax.format(**settings) + fig.canvas.draw() # needed for sharing labels for axi in ax: state = are_labels_on(axi) expectation = expectations[axi.number - 1] @@ -314,8 +315,8 @@ def test_toggle_gridliner_labels(): gl = ax[0].gridlines_major assert gl.left_labels == False - assert gl.right_labels == None # initially these are none - assert gl.top_labels == None + assert gl.right_labels == False + assert gl.top_labels == False assert gl.bottom_labels == False ax[0]._toggle_gridliner_labels(labeltop=True) assert gl.top_labels == True @@ -572,22 +573,18 @@ def assert_views_are_sharing(ax): fig.canvas.draw() # need this to update the labels # All the labels should be on for axi in ax: - side_labels = axi._get_gridliner_labels( - left=True, - right=True, - top=True, - bottom=True, + + s = sum( + [ + 1 if axi._is_ticklabel_on(side) else 0 + for side in "labeltop labelbottom labelleft labelright".split() + ] ) - s = 0 - for dir, labels in side_labels.items(): - s += any([label.get_visible() for label in labels]) assert_views_are_sharing(axi) # When we share the labels but not the limits, # we expect all ticks to be on - if level < 3: - assert s == 4 - else: + if level > 2: assert s == 2 uplt.close(fig) @@ -616,7 +613,9 @@ def test_cartesian_and_geo(rng): ax[0].pcolormesh(rng.random((10, 10))) ax[1].scatter(*rng.random((2, 100))) ax[0]._apply_axis_sharing() - assert mocked.call_count == 1 + assert ( + mocked.call_count == 2 + ) # needs to be called at least twice; one for each axis return fig From 7e3b5a1b3e9dc310163502dffff0162b3393d92b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 23 Sep 2025 12:39:49 +0200 Subject: [PATCH 36/75] update tests part 2 --- ultraplot/tests/test_geographic.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index e2564bd6..349a3b38 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -586,6 +586,8 @@ def assert_views_are_sharing(ax): # we expect all ticks to be on if level > 2: assert s == 2 + else: + assert s == 4 uplt.close(fig) @@ -805,6 +807,7 @@ def are_labels_on(ax, which=("top", "bottom", "right", "left")) -> tuple[bool]: h = ax.imshow(data)[0] ax.format(land=True, labels="both") # need this otherwise no labels are printed fig.colorbar(h, loc="r") + fig.canvas.draw() # needed to invoke axis sharing expectations = ( [True, False, False, True], From b4e8bcf1c0c828b95925af16b6124950994bd839 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 23 Sep 2025 12:40:04 +0200 Subject: [PATCH 37/75] remove redundant draw --- ultraplot/tests/test_geographic.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 349a3b38..1f243f13 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -570,7 +570,6 @@ def assert_views_are_sharing(ax): for idx, axi in enumerate(ax): axi.plot(x * (idx + 1), y * (idx + 1)) - fig.canvas.draw() # need this to update the labels # All the labels should be on for axi in ax: From 05515a38b827ab526c8024863157eeb89b0689d8 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 23 Sep 2025 12:40:11 +0200 Subject: [PATCH 38/75] remove redundant tests --- ultraplot/tests/test_figure.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/ultraplot/tests/test_figure.py b/ultraplot/tests/test_figure.py index 2bde251f..cffa3c7f 100644 --- a/ultraplot/tests/test_figure.py +++ b/ultraplot/tests/test_figure.py @@ -71,31 +71,6 @@ def test_get_renderer_basic(): assert hasattr(renderer, "draw_path") -def test_share_labels_with_others_no_sharing(): - """ - Test that _share_labels_with_others returns early when no sharing is set. - """ - fig, ax = uplt.subplots() - fig._sharex = 0 - fig._sharey = 0 - # Should simply return without error - result = fig._share_labels_with_others() - assert result is None - - -def test_share_labels_with_others_with_sharing(): - """ - Test that _share_labels_with_others runs when sharing is enabled. - """ - fig, ax = uplt.subplots(ncols=2, sharex=1, sharey=1) - fig._sharex = 1 - fig._sharey = 1 - # Should not return early - fig._share_labels_with_others() - # No assertion, just check for coverage and no error - uplt.close(fig) - - def test_figure_sharing_toggle(): """ Check if axis sharing and unsharing works From 20e292ce68d711966065a0e4f19c81e7f9677cda Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 23 Sep 2025 12:40:29 +0200 Subject: [PATCH 39/75] forgot this --- ultraplot/axes/geo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 0e5c7378..d69a9240 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -1490,7 +1490,7 @@ def _is_ticklabel_on(self, side: str) -> bool: elif side == "labelleft": return getattr(self.gridlines_major, left_labels) elif side == "labelright": - return getattr(self.gridlines_major, right_labels) == "y" + return getattr(self.gridlines_major, right_labels) elif side == "labelbottom": return getattr(self.gridlines_major, bottom_labels) elif side == "labeltop": From 211a100e77b9fcb931291df982f72703fca9b13a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 23 Sep 2025 12:45:14 +0200 Subject: [PATCH 40/75] add override import --- ultraplot/axes/polar.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/polar.py b/ultraplot/axes/polar.py index 74d08304..94950179 100644 --- a/ultraplot/axes/polar.py +++ b/ultraplot/axes/polar.py @@ -3,7 +3,11 @@ Polar axes using azimuth and radius instead of *x* and *y*. """ import inspect -from typing import override + +try: + from typing import override +except: + from typing_extensions import override import matplotlib.projections.polar as mpolar import numpy as np From 5eb11e275526fb2e9f1579be769531185e7bc963 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 25 Sep 2025 16:55:22 +0200 Subject: [PATCH 41/75] Move label sharing to axis object --- .gitignore | 1 + ultraplot/axes/cartesian.py | 105 -------------------- ultraplot/figure.py | 154 ++++++++++++++++++++++++++--- ultraplot/gridspec.py | 11 ++- ultraplot/tests/test_geographic.py | 2 +- ultraplot/utils.py | 49 +++++++-- 6 files changed, 189 insertions(+), 133 deletions(-) diff --git a/.gitignore b/.gitignore index bbd6bf10..0bf4ea4e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ sources # Python extras .ipynb_checkpoints *.log +*.ipnyb *.pyc .*.pyc __pycache__ diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 89003b93..692fbef2 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -434,114 +434,9 @@ def _apply_axis_sharing_for_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(which=axis_name) - axis.set_tick_params(which="both", **label_visibility) # Turn minor ticks off axis.set_minor_formatter(mticker.NullFormatter()) - def _determine_tick_label_visibility( - self, - *, - which: str, - ) -> dict[str, bool]: - """ - Determine which tick labels should be visible based on sharing rules and borders. - - Parameters - ---------- - axis: str ('x' or 'y') - - Returns - ------- - dict - Dictionary of label visibility parameters - """ - axis = getattr(self, f"{which}axis") - shared_axis = getattr(self, f"_share{which}") - label_params = ( - ("labeltop", "labelbottom") if which == "x" else ("labelleft", "labelright") - ) - border_sides = ("top", "bottom") if which == "x" else ("left", "right") - border_axes = self.figure._get_border_axes() - - ticks = axis.get_tick_params() - - sharing_ticks = {} - if shared_axis: - shared_axis_obj = getattr(shared_axis, f"{which}axis") - sharing_ticks = shared_axis_obj.get_tick_params() - - 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 which == "x": - label_param = "labelright" - elif label_param == "labelbottom" and which == "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() - is_border = self in border_axes.get(border_side, []) - is_panel = True if self._panel_parent else False - is_colorbar = True if self._colorbar_fill else False - # Use automatic border detection logic - # if we are a panel we "push" the labels outwards - label_param_trans = _convert_label_param(label_param) - is_this_tick_on = ticks[label_param_trans] - is_parent_tick_on = sharing_ticks.get(label_param_trans, False) - are_we_sharing_labels_on_ax = ( - self._panel_sharex_group if which == "x" else self._panel_sharey_group - ) - # To share all axes we need to consider a few cases. - - # Case 1 and 2: Sharing top and right labels only on the - # figure borders or when we are not an alternate axis - if is_colorbar: - label_visibility[label_param] = is_this_tick_on - elif ( - not self._altx_parent - and border_side == "top" - and self.figure._sharex > 2 - ): - if is_panel: - if self._panel_sharex_group: - panels = self._panel_parent._panel_dict.get(border_side, []) - if panels and self == panels[-1]: - label_visibility[label_param] = is_parent_tick_on - else: - label_visibility[label_param] = is_border and is_this_tick_on - elif ( - not self._alty_parent - and border_side == "right" - and self.figure._sharey > 2 - ): - if is_panel: - # check if we are sharing hte axis labels - if self._panel_sharey_group: - panels = self._panel_parent._panel_dict.get(border_side, []) - if panels and self == panels[-1]: - label_visibility[label_param] = is_parent_tick_on - else: - label_visibility[label_param] = is_this_tick_on - else: - label_visibility[label_param] = is_border and is_this_tick_on - # Case 3: share axis labels when we are sharing axes set - elif (which == "x" and self._sharex) or (which == "y" and self._sharey): - # Shared subplot. Labels are off unless it's a border. - # On the border, respect the local tick setting. - label_visibility[label_param] = False - # Case 4: singular axes we check if the ticks are on - else: - # Not sharing. - label_visibility[label_param] = is_this_tick_on - return label_visibility - def _add_alt(self, sx, **kwargs): """ Add an alternate axes. diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 485afd05..8aa3bce4 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -20,6 +20,11 @@ import matplotlib.transforms as mtransforms import numpy as np +try: + from typing import override +except: + from typing_extensions import override + from . import axes as paxes from . import constructor from . import gridspec as pgridspec @@ -477,6 +482,21 @@ def _canvas_preprocess(self, *args, **kwargs): return canvas +def _clear_border_cache(func): + """ + Decorator that clears the border cache after function execution. + """ + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + result = func(self, *args, **kwargs) + if hasattr(self, "_cache_border_axes"): + delattr(self, "_cache_border_axes") + return result + + return wrapper + + class Figure(mfigure.Figure): """ The `~matplotlib.figure.Figure` subclass used by ultraplot. @@ -801,6 +821,95 @@ def __init__( # NOTE: This ignores user-input rc_mode. self.format(rc_kw=rc_kw, rc_mode=1, skip_axes=True, **kw_format) + @override + def draw(self, renderer): + # implement the tick sharing here + # should be shareable --> either all cartesian or all geographic + # but no mixing (panels can be mixed) + # check which ticks are on for x or y and push the labels to the + # outer most on a given column or row. + # we can use get_border_axes for the outermost plots and then collect their outermost panels that are not colorbars + self._share_ticklabels(axis="x") + self._share_ticklabels(axis="y") + super().draw(renderer) + + def _share_ticklabels(self, *, axis: str) -> None: + """ + Tick label sharing is determined at the figure level. While + each subplot controls the limits, we are dealing with the ticklabels + here as the complexity is easiier to deal with. + axis: str 'x' or 'y', row or columns to update + """ + if not self.stale: + return + outer_axes = self._get_border_axes() + true_outer = {} + + sides = ("top", "bottom") if axis == "x" else ("left", "right") + # for panels + other_axis = "x" if axis == "y" else "y" + other_sides = ("left", "right") if axis == "x" else ("top", "bottom") + # Outer_axes contains the main grid but we need + # to add the panels that are on these axes potentially + + tick_params = ( + {"labeltop": False, "labelbottom": False} + if axis == "x" + else {"labelleft": False, "labelright": False} + ) + + # Check if any of the ticks are set to on for @axis + subplot_types = set() + for axi in self._iter_axes(panels=True, hidden=False): + if not type(axi) in (paxes.CartesianAxes, paxes.GeoAxes): + warnings._warn_ultraplot( + f"Tick label sharing not implemented for {type(axi)} subplots." + ) + return + subplot_types.add(type(axi)) + match axis: + # Handle x + case "x" if isinstance(axi, paxes.CartesianAxes): + tmp = axi.xaxis.get_tick_params() + if tmp.get("labeltop"): + tick_params["labeltop"] = tmp["labeltop"] + if tmp.get("labelbottom"): + tick_params["labelbottom"] = tmp["labelbottom"] + + # TODO: + case "x" if isinstance(axi, paxes.GeoAxes): + pass + + # Handle y + case "y" if isinstance(axi, paxes.CartesianAxes): + tmp = axi.yaxis.get_tick_params() + if tmp.get("labelleft"): + tick_params["labelleft"] = tmp["labelleft"] + if tmp.get("labelright"): + tick_params["labelright"] = tmp["labelright"] + + # TODO: + case "y" if isinstance(axi, paxes.GeoAxes): + pass + + # We cannot mix types (yet) + if len(subplot_types) > 1: + warnings._warn_ultraplot( + "Tick label sharing not implemented for mixed subplot types." + ) + return + for axi in self._iter_axes(panels=True, hidden=False): + tmp = tick_params.copy() + # For sharing limits and or axis labels we + # can leave the ticks as found + for side in sides: + label = f"label{side}" + if axi not in outer_axes[side]: + tmp[label] = False + + axi.tick_params(**tmp) + self.stale = True + def _context_adjusting(self, cache=True): """ Prevent re-running auto layout steps due to draws triggered by figure @@ -928,8 +1037,9 @@ def _get_border_axes( if gs is None: return border_axes - # Skip colorbars or panels etc - all_axes = [axi for axi in self.axes if axi.number is not None] + all_axes = [] + for axi in self._iter_axes(panels=True): + all_axes.append(axi) # Handle empty cases nrows, ncols = gs.nrows, gs.ncols @@ -941,26 +1051,45 @@ def _get_border_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, grid_axis_type, seen_axis_type = _get_subplot_layout( - gs, - all_axes, - same_type=same_type, - ) + + gs = self.axes[0].get_gridspec() + shape = (gs.nrows_total, gs.ncols_total) + grid = np.zeros(shape, dtype=object) + grid.fill(None) + grid_axis_type = np.zeros(shape, dtype=int) + seen_axis_type = dict() + for axi in self._iter_axes(panels=True): + gs = axi.get_subplotspec() + x, y = np.unravel_index(gs.num1, shape) + span = gs._get_rows_columns() + + xleft, xright, yleft, yright = span + xspan = xright - xleft + 1 + yspan = yright - yleft + 1 + number = axi.number + if type(axi) not in seen_axis_type: + seen_axis_type[type(axi)] = len(seen_axis_type) + type_number = seen_axis_type[type(axi)] + grid[x : x + xspan, y : y + yspan] = axi + grid_axis_type[x : x + xspan, y : y + yspan] = type_number # 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: axis_type = seen_axis_type.get(type(axi), 1) + number = axi.number + if axi.number is None: + number = -axi._panel_parent.number crawler = _Crawler( ax=axi, grid=grid, - target=axi.number, + target=number, axis_type=axis_type, grid_axis_type=grid_axis_type, ) for direction, is_border in crawler.find_edges(): - if is_border: + if is_border and axi not in border_axes[direction]: border_axes[direction].append(axi) self._cached_border_axes = border_axes return border_axes @@ -1054,6 +1183,7 @@ def _get_renderer(self): renderer = canvas.get_renderer() return renderer + @_clear_border_cache def _add_axes_panel(self, ax, side=None, **kwargs): """ Add an axes panel. @@ -1098,6 +1228,7 @@ def _add_axes_panel(self, ax, side=None, **kwargs): axis.set_label_position(side) # set label position return pax + @_clear_border_cache def _add_figure_panel( self, side=None, span=None, row=None, col=None, rows=None, cols=None, **kwargs ): @@ -1132,6 +1263,7 @@ def _add_figure_panel( pax._panel_parent = None return pax + @_clear_border_cache def _add_subplot(self, *args, **kwargs): """ The driver function for adding single subplots. @@ -1240,9 +1372,6 @@ def _add_subplot(self, *args, **kwargs): if ax.number: self._subplot_dict[ax.number] = ax - # Invalidate border axes cache - if hasattr(self, "_cached_border_axes"): - delattr(self, "_cached_border_axes") return ax def _unshare_axes(self): @@ -1672,6 +1801,7 @@ def _update_super_title(self, title, **kwargs): if title is not None: self._suptitle.set_text(title) + @_clear_border_cache @docstring._concatenate_inherited @docstring._snippet_manager def add_axes(self, rect, **kwargs): diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 0d642505..3183aa1d 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -195,7 +195,7 @@ 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): + def _get_grid_span(self, hidden=True) -> (int, int, int, int): """ Retrieve the location of the subplot within the gridspec. When hidden is False we only consider @@ -203,11 +203,12 @@ def _get_grid_span(self, hidden=False) -> (int, int, int, int): """ gs = self.get_gridspec() nrows, ncols = gs.nrows_total, gs.ncols_total - if not hidden: + if hidden: + x, y = np.unravel_index(self.num1, (nrows, ncols)) + else: nrows, ncols = gs.nrows, gs.ncols - # Use num1 or num2 - decoded = gs._decode_indices(self.num1) - x, y = np.unravel_index(decoded, (nrows, ncols)) + 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 diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 1f243f13..10510387 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -742,7 +742,7 @@ def test_geo_with_panels(rng): length=0.5, ), ) - ax.format(oceancolor="blue", coast=True) + ax.format(oceancolor="blue", coast=True, latticklabels="r") return fig diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 1b1b97a9..5f04be8f 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -918,7 +918,8 @@ def _get_subplot_layout( axis types. This function is used internally to determine the layout of axes in a GridSpec. """ - grid = np.zeros((gs.nrows, gs.ncols)) + grid = np.zeros((gs.nrows_total, gs.ncols_total), dtype=object) + grid.fill(None) 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 @@ -936,7 +937,7 @@ def _get_subplot_layout( grid[ slice(*rowspan), slice(*colspan), - ] = axi.number + ] = axi # Allow grouping of mixed types axis_type = 1 @@ -1004,13 +1005,19 @@ def find_edge_for( # Retrieve where the axis is in the grid spec = self.ax.get_subplotspec() - spans = spec._get_grid_span() + shape = (spec.get_gridspec().nrows_total, spec.get_gridspec().ncols_total) + x, y = np.unravel_index(spec.num1, shape) + spans = spec._get_rows_columns() rowspan = spans[:2] colspan = spans[-2:] - xs = range(*rowspan) - ys = range(*colspan) + + a = rowspan[1] - rowspan[0] + b = colspan[1] - colspan[0] + xs = range(x, x + a + 1) + ys = range(y, y + b + 1) + is_border = False - for x, y in product(xs, ys): + for xl, yl in product(xs, ys): pos = (x, y) if self.is_border(pos, d): is_border = True @@ -1037,11 +1044,11 @@ 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: + if self.grid[x, y] is None 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: + if self.grid[x, y] != self.ax: return self._check_ranges(direction, other=self.grid[x, y]) dx, dy = direction @@ -1065,14 +1072,15 @@ def _check_ranges( can share x. """ this_spec = self.ax.get_subplotspec() - other_spec = self.ax.figure._subplot_dict[other].get_subplotspec() + other_spec = other.get_subplotspec() # Get the row and column spans of both axes - this_span = this_spec._get_grid_span() + this_span = this_spec._get_rows_columns() this_rowspan = this_span[:2] this_colspan = this_span[-2:] other_span = other_spec._get_grid_span() + other_span = other_spec._get_rows_columns() other_rowspan = other_span[:2] other_colspan = other_span[-2:] @@ -1089,6 +1097,27 @@ def _check_ranges( other_start, other_stop = other_rowspan if this_start == other_start and this_stop == other_stop: + # Check if it is a panel + mapper = { + (0, -1): "left", + (0, 1): "right", + (1, 0): "top", + (-1, 0): "bottom", + } + d = mapper[direction] + if self.ax.number is None: + parent = self.ax._panel_parent + if panels := parent._panel_dict.get(d, []): + if self.ax == panels[-1]: + if d in ("left", "right") and parent._sharey: + return True + elif d in ("top", "bottom") and parent._sharex: + return True + elif self.ax.number is not None: + if d in ("left", "right") and not self.ax._sharey: + return True + elif d in ("top", "bottom") and not self.ax._sharex: + return True return False # not a border return True From cf5ba69ffb57daab55221e7f5086a74be6eaaee1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 29 Sep 2025 14:19:50 +0200 Subject: [PATCH 42/75] Add geo back in --- ultraplot/axes/geo.py | 76 ------------------------------------------- ultraplot/figure.py | 22 +++++++++---- ultraplot/utils.py | 13 +++++--- 3 files changed, 25 insertions(+), 86 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index d69a9240..f46ad544 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -658,25 +658,11 @@ def _apply_axis_sharing(self): self._lonaxis.set_view_interval(*self._sharex._lonaxis.get_view_interval()) self._lonaxis.set_minor_locator(self._sharex._lonaxis.get_minor_locator()) - # Handle X axis sharing - self._handle_axis_sharing(which="x") - # Share interval y if self._sharey and self.figure._sharey >= 2: self._lataxis.set_view_interval(*self._sharey._lataxis.get_view_interval()) self._lataxis.set_minor_locator(self._sharey._lataxis.get_minor_locator()) - # Handle Y axis sharing - self._handle_axis_sharing(which="y") - - # This block is apart of the draw sequence as the - # gridliner object is created late in the - # build chain. - if not self.stale: - return - if self.figure._sharex == 0 and self.figure._sharey == 0: - return - def _get_gridliner_labels( self, bottom=None, @@ -709,68 +695,6 @@ def _toggle_gridliner_labels( for label in gridlabels.get(direction, []): label.set_visible(toggle) - def _handle_axis_sharing( - self, - *, - which: str, - ) -> None: - """ - Helper method to handle axis sharing for both X and Y axes. - - Args: - source_axis: The source axis to share from - target_axis: The target axis to apply sharing to - """ - if self.figure._sharex == 0 and which == "x": - return - if self.figure._sharey == 0 and which == "y": - return - # This logic is adapted from CartesianAxes._determine_tick_label_visibility - # to provide consistent tick label sharing behavior for GeoAxes. - # Per user guidance, it excludes panel and colorbar logic. - - axis = getattr(self, f"{which}axis") - ticks = axis.get_tick_params() - label_params = ( - ("labeltop", "labelbottom") if which == "x" else ("labelleft", "labelright") - ) - border_sides = ("top", "bottom") if which == "x" else ("left", "right") - border_axes = self.figure._get_border_axes() - - turn_on_or_off = {} - for label_param, border_side in zip(label_params, border_sides): - is_border = self in border_axes.get(border_side, []) - is_this_tick_on = self._is_ticklabel_on(label_param) - - # Case 1: Top-side of a shared X-axis (for primary axes). - if ( - which == "x" - and not getattr(self, "_altx_parent", None) - and border_side == "top" - and self.figure._sharex > 2 - ): - - turn_on_or_off[label_param] = is_border and is_this_tick_on - - # Case 2: Right-side of a shared Y-axis (for primary axes). - elif ( - which == "y" - and not getattr(self, "_alty_parent", None) - and border_side == "right" - and self.figure._sharey > 2 - ): - turn_on_or_off[label_param] = is_border and is_this_tick_on - - # Case 3: Standard bottom/left shared axes. - elif which == "x" and not self._sharex is None and self.figure._sharex > 2: - turn_on_or_off[label_param] = is_border and is_this_tick_on - elif which == "y" and not self._sharey is None and self.figure._sharey > 2: - turn_on_or_off[label_param] = is_border and is_this_tick_on - - # Case 4: Standalone axes (no sharing). - else: - turn_on_or_off[label_param] = is_this_tick_on - self._toggle_gridliner_labels(**turn_on_or_off) @override diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 8aa3bce4..62e15854 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -861,7 +861,11 @@ def _share_ticklabels(self, *, axis: str) -> None: # Check if any of the ticks are set to on for @axis subplot_types = set() for axi in self._iter_axes(panels=True, hidden=False): - if not type(axi) in (paxes.CartesianAxes, paxes.GeoAxes): + if not type(axi) in ( + paxes.CartesianAxes, + paxes._CartopyAxes, + paxes._BasemapAxes, + ): warnings._warn_ultraplot( f"Tick label sharing not implemented for {type(axi)} subplots." ) @@ -876,9 +880,9 @@ def _share_ticklabels(self, *, axis: str) -> None: if tmp.get("labelbottom"): tick_params["labelbottom"] = tmp["labelbottom"] - # TODO: case "x" if isinstance(axi, paxes.GeoAxes): - pass + tick_params["labeltop"] = axi._is_ticklabel_on("labeltop") + tick_params["labelbottom"] = axi._is_ticklabel_on("labelbottom") # Handle y case "y" if isinstance(axi, paxes.CartesianAxes): @@ -888,9 +892,9 @@ def _share_ticklabels(self, *, axis: str) -> None: if tmp.get("labelright"): tick_params["labelright"] = tmp["labelright"] - # TODO: case "y" if isinstance(axi, paxes.GeoAxes): - pass + tick_params["labelleft"] = axi._is_ticklabel_on("labelleft") + tick_params["labelright"] = axi._is_ticklabel_on("labelright") # We cannot mix types (yet) if len(subplot_types) > 1: @@ -907,7 +911,13 @@ def _share_ticklabels(self, *, axis: str) -> None: if axi not in outer_axes[side]: tmp[label] = False - axi.tick_params(**tmp) + if isinstance(axi, paxes.GeoAxes): + # TODO: move this to tick_params? + # Deal with backends as tick_params is still a + # function + axi._toggle_gridliner_labels(**tmp) + else: + axi.tick_params(**tmp) self.stale = True def _context_adjusting(self, cache=True): diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 5f04be8f..06da8414 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1114,10 +1114,15 @@ def _check_ranges( elif d in ("top", "bottom") and parent._sharex: return True elif self.ax.number is not None: - if d in ("left", "right") and not self.ax._sharey: - return True - elif d in ("top", "bottom") and not self.ax._sharex: - return True + # Defer import to prevent circular import + from . import axes as paxes + + if isinstance(self.ax, paxes.CartesianAxes): + if d in ("left", "right") and not self.ax._sharey: + return True + elif d in ("top", "bottom") and not self.ax._sharex: + return True + # GeoAxes or Polar return False # not a border return True From b2c9fe1a2b05487feb7594f041eb3985cea7cd6e Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Mon, 29 Sep 2025 23:00:30 +0200 Subject: [PATCH 43/75] Fix order of label transfer (#353) * prefer dest over src * add preference only on first pass * add fontproperties --- ultraplot/internals/labels.py | 34 +++++++++++++++++++++++++++++++--- ultraplot/tests/test_format.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/ultraplot/internals/labels.py b/ultraplot/internals/labels.py index c12c528e..8b7cb851 100644 --- a/ultraplot/internals/labels.py +++ b/ultraplot/internals/labels.py @@ -4,19 +4,47 @@ """ import matplotlib.patheffects as mpatheffects import matplotlib.text as mtext +from matplotlib.font_manager import FontProperties + from . import ic # noqa: F401 -def _transfer_label(src, dest): +def merge_font_properties( + dest_fp: FontProperties, src_fp: FontProperties +) -> FontProperties: + # Prefer dest_fp's values if set, otherwise use src_fp's + return FontProperties( + family=dest_fp.get_family() or src_fp.get_family(), + style=dest_fp.get_style() or src_fp.get_style(), + variant=dest_fp.get_variant() or src_fp.get_variant(), + weight=dest_fp.get_weight() or src_fp.get_weight(), + stretch=dest_fp.get_stretch() or src_fp.get_stretch(), + size=dest_fp.get_size() or src_fp.get_size(), + ) + + +def _transfer_label(src: mtext.Text, dest: mtext.Text) -> None: """ Transfer the input text object properties and content to the destination text object. Then clear the input object text. """ text = src.get_text() dest.set_color(src.get_color()) # not a font property - dest.set_fontproperties(src.get_fontproperties()) # size, weight, etc. - if not text.strip(): # WARNING: must test strip() (see _align_axis_labels()) + src_fp = src.get_font_properties() + dest_fp = dest.get_font_properties() + + # Track if we've already transferred to this dest + if not hasattr(dest, "_label_transferred"): + # First transfer: copy all from src + dest.set_fontproperties(src_fp) + dest._label_transferred = True + else: + # Subsequent transfers: preserve dest's manual changes + merged_fp = merge_font_properties(dest_fp, src_fp) # dest takes precedence + dest.set_fontproperties(merged_fp) + + if not text.strip(): return dest.set_text(text) src.set_text("") diff --git a/ultraplot/tests/test_format.py b/ultraplot/tests/test_format.py index c2248d7a..3a45fd66 100644 --- a/ultraplot/tests/test_format.py +++ b/ultraplot/tests/test_format.py @@ -140,6 +140,34 @@ def test_inner_title_zorder(): return fig +def test_transfer_label_preserves_dest_font_properties(): + """ + Test that repeated _transfer_label calls do not overwrite dest's updated font properties. + """ + import matplotlib.pyplot as plt + from ultraplot.internals.labels import _transfer_label + + fig, ax = plt.subplots() + src = ax.text(0.1, 0.5, "Source", fontsize=10, fontweight="bold", color="red") + dest = ax.text(0.9, 0.5, "Dest", fontsize=12, fontweight="normal", color="blue") + + # First transfer: dest gets src's font properties + _transfer_label(src, dest) + assert dest.get_fontsize() == 10 + assert dest.get_fontweight() == "bold" + assert dest.get_text() == "Source" + + # Change dest's font size + dest.set_fontsize(20) + + # Second transfer: dest's font size should be preserved + src.set_text("New Source") + _transfer_label(src, dest) + assert dest.get_fontsize() == 20 # Should not be overwritten by src + assert dest.get_fontweight() == "bold" # Still from src originally + assert dest.get_text() == "New Source" + + @pytest.mark.mpl_image_compare def test_font_adjustments(): """ From 876d1eca145bdf0cf642b7e501d063f43b937444 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 08:58:11 +0200 Subject: [PATCH 44/75] Bump the github-actions group with 2 updates (#354) Bumps the github-actions group with 2 updates: [mamba-org/setup-micromamba](https://github.com/mamba-org/setup-micromamba) and [actions/setup-python](https://github.com/actions/setup-python). Updates `mamba-org/setup-micromamba` from 2.0.5 to 2.0.7 - [Release notes](https://github.com/mamba-org/setup-micromamba/releases) - [Commits](https://github.com/mamba-org/setup-micromamba/compare/v2.0.5...v2.0.7) Updates `actions/setup-python` from 5 to 6 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: mamba-org/setup-micromamba dependency-version: 2.0.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-ultraplot.yml | 4 ++-- .github/workflows/main.yml | 2 +- .github/workflows/publish-pypi.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 79a5f2db..8185af58 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -26,7 +26,7 @@ jobs: with: fetch-depth: 0 - - uses: mamba-org/setup-micromamba@v2.0.5 + - uses: mamba-org/setup-micromamba@v2.0.7 with: environment-file: ./environment.yml init-shell: bash @@ -59,7 +59,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: mamba-org/setup-micromamba@v2.0.5 + - uses: mamba-org/setup-micromamba@v2.0.7 with: environment-file: ./environment.yml init-shell: bash diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7f1660c4..9e9a3519 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,7 +32,7 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.11" diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 724f3fe7..1eda57cc 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -23,7 +23,7 @@ jobs: run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* shell: bash - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" From 39be7e8b6fc97d569cdf15017ebef0c326a386cb Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 16 Sep 2025 17:41:33 +0200 Subject: [PATCH 45/75] revert check --- ultraplot/axes/cartesian.py | 3 --- ultraplot/tests/conftest.py | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 692fbef2..86a726e9 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -386,9 +386,6 @@ def _apply_axis_sharing(self): # bottommost or to the *right* of the leftmost panel. But the sharing level # used for the leftmost and bottommost is the *figure* sharing level. - # 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) diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index e6848aba..8296fa82 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -4,6 +4,10 @@ logging.getLogger("matplotlib").setLevel(logging.ERROR) +<<<<<<< HEAD +======= + +>>>>>>> 7394633ef (revert check) SEED = 51423 From 4610d8414d66cb1bc087832fe1fb724263d458e5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 29 Sep 2025 15:23:01 +0200 Subject: [PATCH 46/75] stash --- ultraplot/figure.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 62e15854..d0325b88 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1068,7 +1068,7 @@ def _get_border_axes( grid.fill(None) grid_axis_type = np.zeros(shape, dtype=int) seen_axis_type = dict() - for axi in self._iter_axes(panels=True): + for axi in self._iter_axes(panels=True, hidden=True): gs = axi.get_subplotspec() x, y = np.unravel_index(gs.num1, shape) span = gs._get_rows_columns() @@ -1080,12 +1080,14 @@ def _get_border_axes( if type(axi) not in seen_axis_type: seen_axis_type[type(axi)] = len(seen_axis_type) type_number = seen_axis_type[type(axi)] - grid[x : x + xspan, y : y + yspan] = axi + if axi.get_visible(): + grid[x : x + xspan, y : y + yspan] = axi grid_axis_type[x : x + xspan, y : y + yspan] = type_number # 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 + print(grid, grid.shape) for axi in all_axes: axis_type = seen_axis_type.get(type(axi), 1) number = axi.number @@ -1099,6 +1101,7 @@ def _get_border_axes( grid_axis_type=grid_axis_type, ) for direction, is_border in crawler.find_edges(): + # print(">>", axi.number, direction, is_border) if is_border and axi not in border_axes[direction]: border_axes[direction].append(axi) self._cached_border_axes = border_axes From 685c2f7ee13d446a0730fb8b240adcef8ae4b993 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 1 Oct 2025 23:49:42 +0200 Subject: [PATCH 47/75] refactor geo toggling --- ultraplot/axes/geo.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index f46ad544..2b75f3fe 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -680,22 +680,36 @@ def _toggle_gridliner_labels( 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 + """ + Toggle visibility of gridliner labels for each direction. + + Parameters + ---------- + labeltop, labelbottom, labelleft, labelright : bool or None + Whether to show labels on each side. If None, do not change. + geo : optional + Not used in this method. + """ + # Ensure gridlines_major is fully initialized if any(i is None for i in self.gridlines_major): return + gridlabels = self._get_gridliner_labels( bottom=labelbottom, top=labeltop, left=labelleft, right=labelright ) - bools = [labelbottom, labeltop, labelleft, labelright] - directions = "bottom top left right".split() - for direction, toggle in zip(directions, bools): + + toggles = { + "bottom": labelbottom, + "top": labeltop, + "left": labelleft, + "right": labelright, + } + + for direction, toggle in toggles.items(): if toggle is None: continue - for label in gridlabels.get(direction, []): - label.set_visible(toggle) - - self._toggle_gridliner_labels(**turn_on_or_off) + if label := gridlabels.get(direction, None): + label.set_visible(bool(toggle) or toggle in ("x", "y")) @override def draw(self, renderer=None, *args, **kwargs): From 7ea819c93d306416bdb1a67c17eb87eff46c17e4 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 2 Oct 2025 17:41:40 +0200 Subject: [PATCH 48/75] more refactoring --- ultraplot/axes/geo.py | 3 +- ultraplot/figure.py | 50 +++++++++++++++++++++--------- ultraplot/tests/conftest.py | 5 --- ultraplot/tests/test_geographic.py | 4 +-- ultraplot/utils.py | 8 ++++- 5 files changed, 45 insertions(+), 25 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 2b75f3fe..558292c2 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -708,7 +708,7 @@ def _toggle_gridliner_labels( for direction, toggle in toggles.items(): if toggle is None: continue - if label := gridlabels.get(direction, None): + for label in gridlabels.get(direction, []): label.set_visible(bool(toggle) or toggle in ("x", "y")) @override @@ -1424,7 +1424,6 @@ def _is_ticklabel_on(self, side: str) -> bool: if self.gridlines_major is None: return False - elif side == "labelleft": return getattr(self.gridlines_major, left_labels) elif side == "labelright": diff --git a/ultraplot/figure.py b/ultraplot/figure.py index d0325b88..d9c5f526 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -842,6 +842,7 @@ def _share_ticklabels(self, *, axis: str) -> None: """ if not self.stale: return + outer_axes = self._get_border_axes() true_outer = {} @@ -851,12 +852,7 @@ def _share_ticklabels(self, *, axis: str) -> None: other_sides = ("left", "right") if axis == "x" else ("top", "bottom") # Outer_axes contains the main grid but we need # to add the panels that are on these axes potentially - - tick_params = ( - {"labeltop": False, "labelbottom": False} - if axis == "x" - else {"labelleft": False, "labelright": False} - ) + tick_params = {} # Check if any of the ticks are set to on for @axis subplot_types = set() @@ -881,8 +877,10 @@ def _share_ticklabels(self, *, axis: str) -> None: tick_params["labelbottom"] = tmp["labelbottom"] case "x" if isinstance(axi, paxes.GeoAxes): - tick_params["labeltop"] = axi._is_ticklabel_on("labeltop") - tick_params["labelbottom"] = axi._is_ticklabel_on("labelbottom") + if axi._is_ticklabel_on("labeltop"): + tick_params["labeltop"] = axi._is_ticklabel_on("labeltop") + if axi._is_ticklabel_on("labelbottom"): + tick_params["labelbottom"] = axi._is_ticklabel_on("labelbottom") # Handle y case "y" if isinstance(axi, paxes.CartesianAxes): @@ -893,8 +891,10 @@ def _share_ticklabels(self, *, axis: str) -> None: tick_params["labelright"] = tmp["labelright"] case "y" if isinstance(axi, paxes.GeoAxes): - tick_params["labelleft"] = axi._is_ticklabel_on("labelleft") - tick_params["labelright"] = axi._is_ticklabel_on("labelright") + if axi._is_ticklabel_on("labelleft"): + tick_params["labelleft"] = axi._is_ticklabel_on("labelleft") + if axi._is_ticklabel_on("labelright"): + tick_params["labelright"] = axi._is_ticklabel_on("labelright") # We cannot mix types (yet) if len(subplot_types) > 1: @@ -911,6 +911,20 @@ def _share_ticklabels(self, *, axis: str) -> None: if axi not in outer_axes[side]: tmp[label] = False + # Determine sharing level + level = getattr(self, f"_share{axis}") + if axis == "y": + # For panels + if hasattr(axi, "_panel_sharey_group") and axi._panel_sharey_group: + level = 3 + else: # x-axis + # For panels + if hasattr(axi, "_panel_sharex_group") and axi._panel_sharex_group: + level = 3 + + # Don't update when we are not sharing axis ticks + if level <= 2: + continue if isinstance(axi, paxes.GeoAxes): # TODO: move this to tick_params? # Deal with backends as tick_params is still a @@ -1068,6 +1082,7 @@ def _get_border_axes( grid.fill(None) grid_axis_type = np.zeros(shape, dtype=int) seen_axis_type = dict() + ax_type_mapping = dict() for axi in self._iter_axes(panels=True, hidden=True): gs = axi.get_subplotspec() x, y = np.unravel_index(gs.num1, shape) @@ -1077,9 +1092,13 @@ def _get_border_axes( xspan = xright - xleft + 1 yspan = yright - yleft + 1 number = axi.number - if type(axi) not in seen_axis_type: - seen_axis_type[type(axi)] = len(seen_axis_type) - type_number = seen_axis_type[type(axi)] + axis_type = type(axi) + if isinstance(axi, (paxes.GeoAxes)): + axis_type = axi.projection + if axis_type not in seen_axis_type: + seen_axis_type[axis_type] = len(seen_axis_type) + type_number = seen_axis_type[axis_type] + ax_type_mapping[axi] = type_number if axi.get_visible(): grid[x : x + xspan, y : y + yspan] = axi grid_axis_type[x : x + xspan, y : y + yspan] = type_number @@ -1087,9 +1106,10 @@ def _get_border_axes( # 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 - print(grid, grid.shape) + # print(grid, grid.shape) + # print(grid_axis_type) for axi in all_axes: - axis_type = seen_axis_type.get(type(axi), 1) + axis_type = ax_type_mapping[axi] number = axi.number if axi.number is None: number = -axi._panel_parent.number diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index 8296fa82..db2482d9 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -3,11 +3,6 @@ import warnings, logging logging.getLogger("matplotlib").setLevel(logging.ERROR) - -<<<<<<< HEAD -======= - ->>>>>>> 7394633ef (revert check) SEED = 51423 diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 10510387..2d436fd1 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -613,7 +613,7 @@ def test_cartesian_and_geo(rng): ax.format(land=True, lonlim=(-10, 10), latlim=(-10, 10)) ax[0].pcolormesh(rng.random((10, 10))) ax[1].scatter(*rng.random((2, 100))) - ax[0]._apply_axis_sharing() + fig.canvas.draw() assert ( mocked.call_count == 2 ) # needs to be called at least twice; one for each axis @@ -742,7 +742,7 @@ def test_geo_with_panels(rng): length=0.5, ), ) - ax.format(oceancolor="blue", coast=True, latticklabels="r") + ax.format(oceancolor="blue", coast=True) return fig diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 06da8414..c6864f66 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1044,8 +1044,14 @@ def is_border( elif y > self.grid.shape[1] - 1: return True - if self.grid[x, y] is None or self.grid_axis_type[x, y] != self.axis_type: + if self.grid[x, y] is None: return True + if self.grid_axis_type[x, y] != self.axis_type: + if ( + hasattr(self.grid[x, y], "_panel_side") + and self.grid[x, y]._panel_side is None + ): + return True # Check if we reached a plot or an internal edge if self.grid[x, y] != self.ax: From 267bcdcfd698c81e7d66a2a7d3afba9174c6c0f7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 10:47:57 +0200 Subject: [PATCH 49/75] update test to reflect new sharing changes --- ultraplot/tests/test_subplots.py | 46 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index dead27f3..f698ee42 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -290,27 +290,27 @@ def test_panel_sharing_top_right(layout): for dir in "left right top bottom".split(): 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 + border_axes = fig._get_border_axes() + + for axi in fig._iter_axes(panels=True): + assert ( + axi._is_ticklabel_on("labelleft") + if axi in border_axes["left"] + else not axi._is_ticklabel_on("labelleft") + ) + assert ( + axi._is_ticklabel_on("labeltop") + if axi in border_axes["top"] + else not axi._is_ticklabel_on("labeltop") + ) + assert ( + axi._is_ticklabel_on("labelright") + if axi in border_axes["right"] + else not axi._is_ticklabel_on("labelright") + ) + assert ( + axi._is_ticklabel_on("labelbottom") + if axi in border_axes["bottom"] + else not axi._is_ticklabel_on("labelbottom") + ) return fig From ab231827d6d3b09db829d87ac832208181ae70ac Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 10:49:02 +0200 Subject: [PATCH 50/75] simplify internal border detection --- ultraplot/utils.py | 57 ++++++++++++---------------------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index c6864f66..5dfe1dd9 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1003,6 +1003,8 @@ def find_edge_for( Setup search for a specific direction. """ + from itertools import product + # Retrieve where the axis is in the grid spec = self.ax.get_subplotspec() shape = (spec.get_gridspec().nrows_total, spec.get_gridspec().ncols_total) @@ -1033,19 +1035,15 @@ def is_border( 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: + # Edge of grid (out-of-bounds) + if not (0 <= x < self.grid.shape[0] and 0 <= y < self.grid.shape[1]): return True - if y < 0: - return True - elif y > self.grid.shape[1] - 1: - return True + cell = self.grid[x, y] + dx, dy = direction + if cell is None: + return self.is_border((x + dx, y + dy), direction) - if self.grid[x, y] is None: - return True if self.grid_axis_type[x, y] != self.axis_type: if ( hasattr(self.grid[x, y], "_panel_side") @@ -1053,13 +1051,12 @@ def is_border( ): return True - # Check if we reached a plot or an internal edge - if self.grid[x, y] != self.ax: - return self._check_ranges(direction, other=self.grid[x, y]) + # Internal edge or plot reached + if cell != self.ax: + print(x, y, direction, self.ax, cell) + return self._check_ranges(direction, other=cell) - dx, dy = direction - pos = (x + dx, y + dy) - return self.is_border(pos, direction) + return self.is_border((x + dx, y + dy), direction) def _check_ranges( self, @@ -1103,33 +1100,7 @@ def _check_ranges( other_start, other_stop = other_rowspan if this_start == other_start and this_stop == other_stop: - # Check if it is a panel - mapper = { - (0, -1): "left", - (0, 1): "right", - (1, 0): "top", - (-1, 0): "bottom", - } - d = mapper[direction] - if self.ax.number is None: - parent = self.ax._panel_parent - if panels := parent._panel_dict.get(d, []): - if self.ax == panels[-1]: - if d in ("left", "right") and parent._sharey: - return True - elif d in ("top", "bottom") and parent._sharex: - return True - elif self.ax.number is not None: - # Defer import to prevent circular import - from . import axes as paxes - - if isinstance(self.ax, paxes.CartesianAxes): - if d in ("left", "right") and not self.ax._sharey: - return True - elif d in ("top", "bottom") and not self.ax._sharex: - return True - # GeoAxes or Polar - return False # not a border + return False # internal border return True From 21f124dcaf186dbea34ea2df57549f3bd8e8a8f8 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 10:49:14 +0200 Subject: [PATCH 51/75] minor refactor --- ultraplot/utils.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 5dfe1dd9..92fc5783 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -997,8 +997,6 @@ def find_edge_for( direction: str, d: tuple[int, int], ) -> tuple[str, bool]: - from itertools import product - """ Setup search for a specific direction. """ @@ -1045,10 +1043,7 @@ def is_border( return self.is_border((x + dx, y + dy), direction) if self.grid_axis_type[x, y] != self.axis_type: - if ( - hasattr(self.grid[x, y], "_panel_side") - and self.grid[x, y]._panel_side is None - ): + if getattr(cell, "_panel_side", None) is None: return True # Internal edge or plot reached From 53c3b805f4ced6c14893113f8ab469d38f38b8bc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 10:51:35 +0200 Subject: [PATCH 52/75] more refactoring --- ultraplot/figure.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index d9c5f526..53dec12f 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1106,8 +1106,6 @@ def _get_border_axes( # 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 - # print(grid, grid.shape) - # print(grid_axis_type) for axi in all_axes: axis_type = ax_type_mapping[axi] number = axi.number @@ -1121,7 +1119,6 @@ def _get_border_axes( grid_axis_type=grid_axis_type, ) for direction, is_border in crawler.find_edges(): - # print(">>", axi.number, direction, is_border) if is_border and axi not in border_axes[direction]: border_axes[direction].append(axi) self._cached_border_axes = border_axes From 56dfb9509c83ab8bc64c19f3a8770935cb610479 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 10:53:53 +0200 Subject: [PATCH 53/75] add get for border back --- ultraplot/axes/cartesian.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py index 86a726e9..0678a2a8 100644 --- a/ultraplot/axes/cartesian.py +++ b/ultraplot/axes/cartesian.py @@ -386,6 +386,7 @@ def _apply_axis_sharing(self): # bottommost or to the *right* of the leftmost panel. But the sharing level # used for the leftmost and bottommost is the *figure* sharing level. + border_axes = self.figure._get_border_axes() # Apply X axis sharing self._apply_axis_sharing_for_axis("x", border_axes) From 582c52f127f84a582ac1b6d75558c5eabc0879fc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 11:05:20 +0200 Subject: [PATCH 54/75] remote debug --- ultraplot/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 92fc5783..9e413d28 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1048,7 +1048,6 @@ def is_border( # Internal edge or plot reached if cell != self.ax: - print(x, y, direction, self.ax, cell) return self._check_ranges(direction, other=cell) return self.is_border((x + dx, y + dy), direction) From a29061f2c9fa7fa9ec5ef7bc6a647b72bfba1b47 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 11:49:06 +0200 Subject: [PATCH 55/75] make mpl 3.9 compat --- ultraplot/figure.py | 43 ++++++++++++++++++++++++-------- ultraplot/tests/test_subplots.py | 1 + 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 53dec12f..07ec70c1 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -6,6 +6,7 @@ import inspect import os from numbers import Integral +from packaging import version try: from typing import List @@ -856,6 +857,25 @@ def _share_ticklabels(self, *, axis: str) -> None: # Check if any of the ticks are set to on for @axis subplot_types = set() + + from packaging import version + from .internals import _version_mpl + + mpl_version = version.parse(str(_version_mpl)) + use_new_labels = mpl_version >= version.parse("3.10") + + label_map = { + "labeltop": "labeltop" if use_new_labels else "labelright", + "labelbottom": "labelbottom" if use_new_labels else "labelleft", + "labelleft": "labelleft", + "labelright": "labelright", + } + + labelleft = label_map["labelleft"] + labelright = label_map["labelright"] + labeltop = label_map["labeltop"] + labelbottom = label_map["labelbottom"] + for axi in self._iter_axes(panels=True, hidden=False): if not type(axi) in ( paxes.CartesianAxes, @@ -871,10 +891,10 @@ def _share_ticklabels(self, *, axis: str) -> None: # Handle x case "x" if isinstance(axi, paxes.CartesianAxes): tmp = axi.xaxis.get_tick_params() - if tmp.get("labeltop"): - tick_params["labeltop"] = tmp["labeltop"] - if tmp.get("labelbottom"): - tick_params["labelbottom"] = tmp["labelbottom"] + if tmp.get(labeltop): + tick_params[labeltop] = tmp[labeltop] + if tmp.get(labelbottom): + tick_params[labelbottom] = tmp[labelbottom] case "x" if isinstance(axi, paxes.GeoAxes): if axi._is_ticklabel_on("labeltop"): @@ -885,10 +905,10 @@ def _share_ticklabels(self, *, axis: str) -> None: # Handle y case "y" if isinstance(axi, paxes.CartesianAxes): tmp = axi.yaxis.get_tick_params() - if tmp.get("labelleft"): - tick_params["labelleft"] = tmp["labelleft"] - if tmp.get("labelright"): - tick_params["labelright"] = tmp["labelright"] + if tmp.get(labelleft): + tick_params[labelleft] = tmp[labelleft] + if tmp.get(labelright): + tick_params[labelright] = tmp[labelright] case "y" if isinstance(axi, paxes.GeoAxes): if axi._is_ticklabel_on("labelleft"): @@ -908,6 +928,9 @@ def _share_ticklabels(self, *, axis: str) -> None: # can leave the ticks as found for side in sides: label = f"label{side}" + if isinstance(axi, paxes.CartesianAxes): + # Ignore for geo as it internally converts + label = label_map[label] if axi not in outer_axes[side]: tmp[label] = False @@ -930,8 +953,8 @@ def _share_ticklabels(self, *, axis: str) -> None: # Deal with backends as tick_params is still a # function axi._toggle_gridliner_labels(**tmp) - else: - axi.tick_params(**tmp) + elif tmp: + getattr(axi, f"{axis}axis").set_tick_params(**tmp) self.stale = True def _context_adjusting(self, cache=True): diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index f698ee42..e84b8acb 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -291,6 +291,7 @@ def test_panel_sharing_top_right(layout): pax = ax[0].panel(dir) fig.canvas.draw() # force redraw tick labels border_axes = fig._get_border_axes() + uplt.show(block=1) for axi in fig._iter_axes(panels=True): assert ( From c520e0f0dd5a78e628cedde083197e0a411e4fe3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 11:49:32 +0200 Subject: [PATCH 56/75] 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 e84b8acb..f698ee42 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -291,7 +291,6 @@ def test_panel_sharing_top_right(layout): pax = ax[0].panel(dir) fig.canvas.draw() # force redraw tick labels border_axes = fig._get_border_axes() - uplt.show(block=1) for axi in fig._iter_axes(panels=True): assert ( From 2ba95c3abf25742d95d946d091d3e5046f7cbde0 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 17:58:28 +0200 Subject: [PATCH 57/75] don't use hidden axes --- ultraplot/figure.py | 2 +- ultraplot/utils.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 07ec70c1..0edd7362 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1106,7 +1106,7 @@ def _get_border_axes( grid_axis_type = np.zeros(shape, dtype=int) seen_axis_type = dict() ax_type_mapping = dict() - for axi in self._iter_axes(panels=True, hidden=True): + for axi in self._iter_axes(panels=True): gs = axi.get_subplotspec() x, y = np.unravel_index(gs.num1, shape) span = gs._get_rows_columns() diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 9e413d28..773998fd 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1042,6 +1042,10 @@ def is_border( if cell is None: return self.is_border((x + dx, y + dy), direction) + # If legend or colorbar we should ignore + # if cell._is_legend() or cell._is_colorbar(): + # return self.is_border((x + dx, y + dy), direction) + if self.grid_axis_type[x, y] != self.axis_type: if getattr(cell, "_panel_side", None) is None: return True From fdce0fde9a681a4179c54ccb6d9538e224c251e7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 18:10:10 +0200 Subject: [PATCH 58/75] don't use hidden axes --- ultraplot/figure.py | 2 +- ultraplot/utils.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 0edd7362..07ec70c1 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1106,7 +1106,7 @@ def _get_border_axes( grid_axis_type = np.zeros(shape, dtype=int) seen_axis_type = dict() ax_type_mapping = dict() - for axi in self._iter_axes(panels=True): + for axi in self._iter_axes(panels=True, hidden=True): gs = axi.get_subplotspec() x, y = np.unravel_index(gs.num1, shape) span = gs._get_rows_columns() diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 773998fd..a9831d18 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1042,9 +1042,8 @@ def is_border( if cell is None: return self.is_border((x + dx, y + dy), direction) - # If legend or colorbar we should ignore - # if cell._is_legend() or cell._is_colorbar(): - # return self.is_border((x + dx, y + dy), direction) + if hasattr(cell, "_panel_hidden"): + return self.is_border((x + dx, y + dy), direction) if self.grid_axis_type[x, y] != self.axis_type: if getattr(cell, "_panel_side", None) is None: From e82d6759f38c3f3fff7a570fa33f873d4808e9f7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 18:16:56 +0200 Subject: [PATCH 59/75] don't use hidden axes --- ultraplot/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index a9831d18..e31c8d2d 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1042,7 +1042,7 @@ def is_border( if cell is None: return self.is_border((x + dx, y + dy), direction) - if hasattr(cell, "_panel_hidden"): + if hasattr(cell, "_panel_hidden") and cell._panel_hidden: return self.is_border((x + dx, y + dy), direction) if self.grid_axis_type[x, y] != self.axis_type: From 5f145c07072486ccf1538f48fc84b5b3d716b414 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 18:43:28 +0200 Subject: [PATCH 60/75] rm dead code --- ultraplot/tests/test_2dplots.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/tests/test_2dplots.py b/ultraplot/tests/test_2dplots.py index 13f084c6..8a4282e6 100644 --- a/ultraplot/tests/test_2dplots.py +++ b/ultraplot/tests/test_2dplots.py @@ -30,7 +30,6 @@ def test_auto_diverging1(rng): """ # Test with basic data fig = uplt.figure() - # fig.format(collabels=('Auto sequential', 'Auto diverging'), suptitle='Default') ax = fig.subplot(121) ax.pcolor(rng.random((10, 10)) * 5, colorbar="b") ax = fig.subplot(122) From 92af811e1d11bc798de926fc29c85db5ad249900 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 3 Oct 2025 19:15:00 +0200 Subject: [PATCH 61/75] debug autodiverging -- locally passing --- ultraplot/tests/test_2dplots.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ultraplot/tests/test_2dplots.py b/ultraplot/tests/test_2dplots.py index 8a4282e6..a2b75319 100644 --- a/ultraplot/tests/test_2dplots.py +++ b/ultraplot/tests/test_2dplots.py @@ -35,6 +35,7 @@ def test_auto_diverging1(rng): ax = fig.subplot(122) ax.pcolor(rng.random((10, 10)) * 5 - 3.5, colorbar="b") fig.format(toplabels=("Sequential", "Diverging")) + fig.canvas.draw() return fig From 35f5bc1cbbadd5c0c166cbd34d12b0e614a2fe26 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 08:29:11 +0200 Subject: [PATCH 62/75] fix grid indexing --- ultraplot/axes/geo.py | 30 ++++++++++++++-------- ultraplot/figure.py | 3 --- ultraplot/gridspec.py | 58 ++++++++++++++++++------------------------- ultraplot/utils.py | 2 +- 4 files changed, 44 insertions(+), 49 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 558292c2..15c5f9a4 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -792,15 +792,18 @@ def _to_label_array(arg, lon=True): array[4] = True # possibly toggle geo spine labels elif not any(isinstance(_, str) for _ in array): if len(array) == 1: - array.append(False) # default is to label bottom or left + array.append(None) if len(array) == 2: - array = [False, False, *array] if lon else [*array, False, False] + array = [None, None, *array] if lon else [*array, None, None] if len(array) == 4: - b = any(array) if rc["grid.geolabels"] else False - array.append(b) # possibly toggle geo spine labels + b = ( + any(a for a in array if a is not None) + if rc["grid.geolabels"] + else None + ) + array.append(b) if len(array) != 5: raise ValueError(f"Invald boolean label array length {len(array)}.") - array = list(map(bool, array)) else: raise ValueError(f"Invalid {which}label spec: {arg}.") return array @@ -921,9 +924,13 @@ def format( # NOTE: Cartopy 0.18 and 0.19 inline labels require any of # top, bottom, left, or right to be toggled then ignores them. # Later versions of cartopy permit both or neither labels. - labels = _not_none(labels, rc.find("grid.labels", context=True)) - lonlabels = _not_none(lonlabels, labels) - latlabels = _not_none(latlabels, labels) + if lonlabels is None and latlabels is None: + labels = _not_none(labels, rc.find("grid.labels", context=True)) + lonlabels = labels + latlabels = labels + else: + lonlabels = _not_none(lonlabels, labels) + latlabels = _not_none(latlabels, labels) # Set the ticks self._toggle_ticks(lonlabels, "x") self._toggle_ticks(latlabels, "y") @@ -1452,10 +1459,10 @@ def _toggle_gridliner_labels( side_labels = _CartopyAxes._get_side_labels() togglers = (labelleft, labelright, labelbottom, labeltop) gl = self.gridlines_major + for toggle, side in zip(togglers, side_labels): - if toggle is None: - continue - setattr(gl, side, toggle) + if toggle is not None: + setattr(gl, side, toggle) if geo is not None: # only cartopy 0.20 supported but harmless setattr(gl, "geo_labels", geo) @@ -1749,6 +1756,7 @@ def _update_major_gridlines( for side, lon, lat in zip( "labelleft labelright labelbottom labeltop geo".split(), lonarray, latarray ): + sides[side] = None if lon and lat: sides[side] = True elif lon: diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 07ec70c1..4cec362c 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -2094,9 +2094,6 @@ def format( } ax.format(rc_kw=rc_kw, rc_mode=rc_mode, skip_figure=True, **kw, **kwargs) ax.number = store_old_number - # When we apply formatting to all axes, we need - # to potentially adjust the labels. - # Warn unused keyword argument(s) kw = { key: value diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 3183aa1d..faf29bcd 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1537,42 +1537,32 @@ def __getitem__(self, key): >>> axs[1, 2] # the subplot in the second row, third column >>> axs[:, 0] # a SubplotGrid containing the subplots in the first column """ - if isinstance(key, tuple) and len(key) == 1: - key = key[0] - # List-style indexing - if isinstance(key, (Integral, slice)): - slices = isinstance(key, slice) - objs = list.__getitem__(self, key) - # Gridspec-style indexing - elif ( - isinstance(key, tuple) - and len(key) == 2 - and all(isinstance(ikey, (Integral, slice)) for ikey in key) - ): - # WARNING: Permit no-op slicing of empty grids here - slices = any(isinstance(ikey, slice) for ikey in key) - objs = [] - if self: - gs = self.gridspec - ss_key = gs._make_subplot_spec(key) # obfuscates panels - row1_key, col1_key = divmod(ss_key.num1, gs.ncols) - row2_key, col2_key = divmod(ss_key.num2, gs.ncols) - for ax in self: - ss = ax._get_topmost_axes().get_subplotspec().get_topmost_subplotspec() - row1, col1 = divmod(ss.num1, gs.ncols) - row2, col2 = divmod(ss.num2, gs.ncols) - inrow = row1_key <= row1 <= row2_key or row1_key <= row2 <= row2_key - incol = col1_key <= col1 <= col2_key or col1_key <= col2 <= col2_key - if inrow and incol: - objs.append(ax) - if not slices and len(objs) == 1: # accounts for overlapping subplots - objs = objs[0] + # Allow 1D list-like indexing + if isinstance(key, int): + return list.__getitem__(self, key) + elif isinstance(key, slice): + return SubplotGrid(list.__getitem__(self, key)) + + # Allow 2D array-like indexing + # NOTE: We assume this is a 2D array of subplots, because this is + # how it is generated in the first place by ultraplot.figure(). + # But it is possible to append subplots manually. + gs = self.gridspec + if gs is None: + raise IndexError( + f"{self.__class__.__name__} has no gridspec, cannot index with {key!r}." + ) + nrows, ncols = gs.get_geometry() + axs = np.array(self, dtype=object).reshape(nrows, ncols) + objs = axs[key] + if hasattr(objs, "flat"): + objs = list(objs.flat) + elif not isinstance(objs, list): + objs = [objs] + if len(objs) == 1: + return objs[0] else: - raise IndexError(f"Invalid index {key!r}.") - if isinstance(objs, list): return SubplotGrid(objs) - else: - return objs def __setitem__(self, key, value): """ diff --git a/ultraplot/utils.py b/ultraplot/utils.py index e31c8d2d..d5cc5db7 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1018,7 +1018,7 @@ def find_edge_for( is_border = False for xl, yl in product(xs, ys): - pos = (x, y) + pos = (xl, yl) if self.is_border(pos, d): is_border = True break From 4deb49c414eccf73d5a06c0c9edb08a3f045e4c3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 08:29:11 +0200 Subject: [PATCH 63/75] fix grid indexing --- ultraplot/axes/geo.py | 27 +++++++++++++------- ultraplot/gridspec.py | 58 ++++++++++++++++++------------------------- 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index ab95a661..896bc0a6 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -805,15 +805,18 @@ def _to_label_array(arg, lon=True): array[4] = True # possibly toggle geo spine labels elif not any(isinstance(_, str) for _ in array): if len(array) == 1: - array.append(False) # default is to label bottom or left + array.append(None) if len(array) == 2: - array = [False, False, *array] if lon else [*array, False, False] + array = [None, None, *array] if lon else [*array, None, None] if len(array) == 4: - b = any(array) if rc["grid.geolabels"] else False - array.append(b) # possibly toggle geo spine labels + b = ( + any(a for a in array if a is not None) + if rc["grid.geolabels"] + else None + ) + array.append(b) if len(array) != 5: raise ValueError(f"Invald boolean label array length {len(array)}.") - array = list(map(bool, array)) else: raise ValueError(f"Invalid {which}label spec: {arg}.") return array @@ -934,9 +937,13 @@ def format( # NOTE: Cartopy 0.18 and 0.19 inline labels require any of # top, bottom, left, or right to be toggled then ignores them. # Later versions of cartopy permit both or neither labels. - labels = _not_none(labels, rc.find("grid.labels", context=True)) - lonlabels = _not_none(lonlabels, labels) - latlabels = _not_none(latlabels, labels) + if lonlabels is None and latlabels is None: + labels = _not_none(labels, rc.find("grid.labels", context=True)) + lonlabels = labels + latlabels = labels + else: + lonlabels = _not_none(lonlabels, labels) + latlabels = _not_none(latlabels, labels) # Set the ticks self._toggle_ticks(lonlabels, "x") self._toggle_ticks(latlabels, "y") @@ -1464,8 +1471,9 @@ def _toggle_gridliner_labels( side_labels = _CartopyAxes._get_side_labels() togglers = (labelleft, labelright, labelbottom, labeltop) gl = self.gridlines_major + for toggle, side in zip(togglers, side_labels): - if getattr(gl, side) != toggle: + if toggle is not None: setattr(gl, side, toggle) if geo is not None: # only cartopy 0.20 supported but harmless setattr(gl, "geo_labels", geo) @@ -1760,6 +1768,7 @@ def _update_major_gridlines( for side, lon, lat in zip( "labelleft labelright labelbottom labeltop geo".split(), lonarray, latarray ): + sides[side] = None if lon and lat: sides[side] = True elif lon: diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 0d642505..9f023ec3 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1536,42 +1536,32 @@ def __getitem__(self, key): >>> axs[1, 2] # the subplot in the second row, third column >>> axs[:, 0] # a SubplotGrid containing the subplots in the first column """ - if isinstance(key, tuple) and len(key) == 1: - key = key[0] - # List-style indexing - if isinstance(key, (Integral, slice)): - slices = isinstance(key, slice) - objs = list.__getitem__(self, key) - # Gridspec-style indexing - elif ( - isinstance(key, tuple) - and len(key) == 2 - and all(isinstance(ikey, (Integral, slice)) for ikey in key) - ): - # WARNING: Permit no-op slicing of empty grids here - slices = any(isinstance(ikey, slice) for ikey in key) - objs = [] - if self: - gs = self.gridspec - ss_key = gs._make_subplot_spec(key) # obfuscates panels - row1_key, col1_key = divmod(ss_key.num1, gs.ncols) - row2_key, col2_key = divmod(ss_key.num2, gs.ncols) - for ax in self: - ss = ax._get_topmost_axes().get_subplotspec().get_topmost_subplotspec() - row1, col1 = divmod(ss.num1, gs.ncols) - row2, col2 = divmod(ss.num2, gs.ncols) - inrow = row1_key <= row1 <= row2_key or row1_key <= row2 <= row2_key - incol = col1_key <= col1 <= col2_key or col1_key <= col2 <= col2_key - if inrow and incol: - objs.append(ax) - if not slices and len(objs) == 1: # accounts for overlapping subplots - objs = objs[0] + # Allow 1D list-like indexing + if isinstance(key, int): + return list.__getitem__(self, key) + elif isinstance(key, slice): + return SubplotGrid(list.__getitem__(self, key)) + + # Allow 2D array-like indexing + # NOTE: We assume this is a 2D array of subplots, because this is + # how it is generated in the first place by ultraplot.figure(). + # But it is possible to append subplots manually. + gs = self.gridspec + if gs is None: + raise IndexError( + f"{self.__class__.__name__} has no gridspec, cannot index with {key!r}." + ) + nrows, ncols = gs.get_geometry() + axs = np.array(self, dtype=object).reshape(nrows, ncols) + objs = axs[key] + if hasattr(objs, "flat"): + objs = list(objs.flat) + elif not isinstance(objs, list): + objs = [objs] + if len(objs) == 1: + return objs[0] else: - raise IndexError(f"Invalid index {key!r}.") - if isinstance(objs, list): return SubplotGrid(objs) - else: - return objs def __setitem__(self, key, value): """ From cd55ccc488a611bd8a51603c5e759c444275be9f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 08:34:40 +0200 Subject: [PATCH 64/75] add unittest --- ultraplot/tests/test_geographic.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 4b95a938..72efaf3d 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -895,3 +895,26 @@ def test_imshow_with_and_without_transform(rng): ax[2].imshow(data, transform=uplt.axes.geo.ccrs.PlateCarree()) ax.format(title=["LCC", "No transform", "PlateCarree"]) return fig + + +@pytest.mark.mpl_image_compare +def test_grid_indexing_formatting(rng): + """ + Check if subplotgrid is correctly selecting + the subplots based on non-shared axis formatting + """ + # See https://github.com/Ultraplot/UltraPlot/issues/356 + lon = np.arange(0, 360, 10) + lat = np.arange(-60, 60 + 1, 10) + data = rng.rnadom((len(lat), len(lon))) + + fig, axs = uplt.subplots(nrows=3, ncols=2, proj="cyl", share=0) + axs.format(coast=True) + + for ax in axs: + m = ax.pcolor(lon, lat, data) + ax.colorbar(m) + + axs[-1, :].format(lonlabels=True) + axs[:, 0].format(latlabels=True) + return fig From 7555bb38eda8020db3566743bad4bfadf00ee03a Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Sat, 4 Oct 2025 08:40:07 +0200 Subject: [PATCH 65/75] Update ultraplot/tests/test_geographic.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- 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 72efaf3d..dd6156e9 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -906,7 +906,7 @@ def test_grid_indexing_formatting(rng): # See https://github.com/Ultraplot/UltraPlot/issues/356 lon = np.arange(0, 360, 10) lat = np.arange(-60, 60 + 1, 10) - data = rng.rnadom((len(lat), len(lon))) + data = rng.random((len(lat), len(lon))) fig, axs = uplt.subplots(nrows=3, ncols=2, proj="cyl", share=0) axs.format(coast=True) From 6dda98053e9a13e1add5b8869f8ae93097bbcc7d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 08:54:52 +0200 Subject: [PATCH 66/75] update tests to reflect changes --- ultraplot/tests/test_geographic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index dd6156e9..35789a54 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -314,8 +314,8 @@ def test_toggle_gridliner_labels(): gl = ax[0].gridlines_major assert gl.left_labels == False - assert gl.right_labels == None # initially these are none - assert gl.top_labels == None + assert gl.right_labels == False + assert gl.top_labels == False assert gl.bottom_labels == False ax[0]._toggle_gridliner_labels(labeltop=True) assert gl.top_labels == True @@ -617,7 +617,7 @@ def test_cartesian_and_geo(rng): ax[0].pcolormesh(rng.random((10, 10))) ax[1].scatter(*rng.random((2, 100))) ax[0]._apply_axis_sharing() - assert mocked.call_count == 1 + assert mocked.call_count == 2 return fig From 1a72ba1d44b5fa04179d503e7f7daecee352c744 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 12:55:45 +0200 Subject: [PATCH 67/75] fix indexing --- ultraplot/gridspec.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 9f023ec3..5f97781c 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1552,7 +1552,20 @@ def __getitem__(self, key): f"{self.__class__.__name__} has no gridspec, cannot index with {key!r}." ) nrows, ncols = gs.get_geometry() - axs = np.array(self, dtype=object).reshape(nrows, ncols) + + # Build grid with None for empty slots + grid = np.full((nrows, ncols), None, dtype=object) + for ax in self: + spec = ax.get_subplotspec() + spans = spec._get_grid_span() + rowspan = spans[:2] + colspan = spans[-2:] + + grid[ + slice(*rowspan), + slice(*colspan), + ] = ax + axs = np.array(grid, dtype=object) objs = axs[key] if hasattr(objs, "flat"): objs = list(objs.flat) From c94a3c5ac0a38c0baf102f03d8351dd6664aa369 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 12:59:46 +0200 Subject: [PATCH 68/75] add unittest that made docs fail --- ultraplot/tests/test_subplots.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index dead27f3..4e7a5d2e 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -314,3 +314,16 @@ def test_panel_sharing_top_right(layout): # The sharing axis is not showing any ticks assert ax[0]._is_ticklabel_on(dir) == False return fig + + +@pytest.mark.mpl_image_compare +def test_uneven_span_subplots(rng): + fig = uplt.figure(refwidth=1, refnum=5, span=False) + axs = fig.subplots([[1, 1, 2], [3, 4, 2], [3, 4, 5]], hratios=[2.2, 1, 1]) + axs.format(xlabel="xlabel", ylabel="ylabel", suptitle="Complex SubplotGrid") + axs[0].format(ec="black", fc="gray1", lw=1.4) + axs[1, 1:].format(fc="blush") + axs[1, :1].format(fc="sky blue") + axs[-1, -1].format(fc="gray4", grid=False) + axs[0].plot((rng.random(50, 10) - 0.5).cumsum(axis=0), cycle="Grays_r", lw=2) + return fig From 5fb9cd9bbfc95026e291f652530be7004d89eeff Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 13:39:48 +0200 Subject: [PATCH 69/75] restore indexing --- ultraplot/gridspec.py | 72 +++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 5f97781c..23341310 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1536,45 +1536,43 @@ def __getitem__(self, key): >>> axs[1, 2] # the subplot in the second row, third column >>> axs[:, 0] # a SubplotGrid containing the subplots in the first column """ - # Allow 1D list-like indexing - if isinstance(key, int): - return list.__getitem__(self, key) - elif isinstance(key, slice): - return SubplotGrid(list.__getitem__(self, key)) - - # Allow 2D array-like indexing - # NOTE: We assume this is a 2D array of subplots, because this is - # how it is generated in the first place by ultraplot.figure(). - # But it is possible to append subplots manually. - gs = self.gridspec - if gs is None: - raise IndexError( - f"{self.__class__.__name__} has no gridspec, cannot index with {key!r}." - ) - nrows, ncols = gs.get_geometry() - - # Build grid with None for empty slots - grid = np.full((nrows, ncols), None, dtype=object) - for ax in self: - spec = ax.get_subplotspec() - spans = spec._get_grid_span() - rowspan = spans[:2] - colspan = spans[-2:] - - grid[ - slice(*rowspan), - slice(*colspan), - ] = ax - axs = np.array(grid, dtype=object) - objs = axs[key] - if hasattr(objs, "flat"): - objs = list(objs.flat) - elif not isinstance(objs, list): - objs = [objs] - if len(objs) == 1: - return objs[0] + if isinstance(key, tuple) and len(key) == 1: + key = key[0] + # List-style indexing + if isinstance(key, (Integral, slice)): + slices = isinstance(key, slice) + objs = list.__getitem__(self, key) + # Gridspec-style indexing + elif ( + isinstance(key, tuple) + and len(key) == 2 + and all(isinstance(ikey, (Integral, slice)) for ikey in key) + ): + # WARNING: Permit no-op slicing of empty grids here + slices = any(isinstance(ikey, slice) for ikey in key) + objs = [] + if self: + gs = self.gridspec + ss_key = gs._make_subplot_spec(key) # obfuscates panels + row1_key, col1_key = divmod(ss_key.num1, gs.ncols) + row2_key, col2_key = divmod(ss_key.num2, gs.ncols) + for ax in self: + ss = ax._get_topmost_axes().get_subplotspec().get_topmost_subplotspec() + row1, col1 = divmod(ss.num1, gs.ncols) + row2, col2 = divmod(ss.num2, gs.ncols) + inrow = row1_key <= row1 <= row2_key or row1_key <= row2 <= row2_key + incol = col1_key <= col1 <= col2_key or col1_key <= col2 <= col2_key + if inrow and incol: + objs.append(ax) + + if not slices and len(objs) == 1: # accounts for overlapping subplots + objs = objs[0] else: + raise IndexError(f"Invalid index {key!r}.") + if isinstance(objs, list): return SubplotGrid(objs) + else: + return objs def __setitem__(self, key, value): """ From 98555a886fc4746b0f7d9b6f1e1d06eeddf23b94 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 15:06:08 +0200 Subject: [PATCH 70/75] fix indexing --- ultraplot/gridspec.py | 66 ++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 23341310..9757179b 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1536,43 +1536,39 @@ def __getitem__(self, key): >>> axs[1, 2] # the subplot in the second row, third column >>> axs[:, 0] # a SubplotGrid containing the subplots in the first column """ - if isinstance(key, tuple) and len(key) == 1: - key = key[0] - # List-style indexing - if isinstance(key, (Integral, slice)): - slices = isinstance(key, slice) - objs = list.__getitem__(self, key) - # Gridspec-style indexing - elif ( - isinstance(key, tuple) - and len(key) == 2 - and all(isinstance(ikey, (Integral, slice)) for ikey in key) - ): - # WARNING: Permit no-op slicing of empty grids here - slices = any(isinstance(ikey, slice) for ikey in key) - objs = [] - if self: - gs = self.gridspec - ss_key = gs._make_subplot_spec(key) # obfuscates panels - row1_key, col1_key = divmod(ss_key.num1, gs.ncols) - row2_key, col2_key = divmod(ss_key.num2, gs.ncols) - for ax in self: - ss = ax._get_topmost_axes().get_subplotspec().get_topmost_subplotspec() - row1, col1 = divmod(ss.num1, gs.ncols) - row2, col2 = divmod(ss.num2, gs.ncols) - inrow = row1_key <= row1 <= row2_key or row1_key <= row2 <= row2_key - incol = col1_key <= col1 <= col2_key or col1_key <= col2 <= col2_key - if inrow and incol: - objs.append(ax) - - if not slices and len(objs) == 1: # accounts for overlapping subplots - objs = objs[0] + # Allow 1D list-like indexing + if isinstance(key, int): + return list.__getitem__(self, key) + elif isinstance(key, slice): + return SubplotGrid(list.__getitem__(self, key)) + + # Allow 2D array-like indexing + # NOTE: We assume this is a 2D array of subplots, because this is + # how it is generated in the first place by ultraplot.figure(). + # But it is possible to append subplots manually. + gs = self.gridspec + if gs is None: + raise IndexError( + f"{self.__class__.__name__} has no gridspec, cannot index with {key!r}." + ) + nrows, ncols = gs.get_geometry() + + # Build grid with None for empty slots + grid = np.full((gs.nrows_total, gs.ncols_total), None, dtype=object) + for ax in self: + spec = ax.get_subplotspec() + x1, x2, y1, y2 = spec._get_rows_columns(ncols=gs.ncols_total) + grid[x1 : x2 + 1, y1 : y2 + 1] = ax + objs = grid[key] + if hasattr(objs, "flat"): + objs = list(objs.flat) + elif not isinstance(objs, list): + objs = [objs] + if len(objs) == 1: + return objs[0] else: - raise IndexError(f"Invalid index {key!r}.") - if isinstance(objs, list): + objs = [obj for obj in objs if obj is not None] return SubplotGrid(objs) - else: - return objs def __setitem__(self, key, value): """ From 711ca150d10a505aeb4e24930ec5b63e074c0246 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 15:06:28 +0200 Subject: [PATCH 71/75] rm dead code --- ultraplot/gridspec.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 9757179b..ff446952 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1551,8 +1551,6 @@ def __getitem__(self, key): raise IndexError( f"{self.__class__.__name__} has no gridspec, cannot index with {key!r}." ) - nrows, ncols = gs.get_geometry() - # Build grid with None for empty slots grid = np.full((gs.nrows_total, gs.ncols_total), None, dtype=object) for ax in self: From dd6ff2aa86de00cdd158b5262758d266a05bfea8 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 16:07:16 +0200 Subject: [PATCH 72/75] handle index error --- ultraplot/gridspec.py | 21 ++++++++++++++++----- ultraplot/tests/test_subplots.py | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index ff446952..159cac2c 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1557,16 +1557,27 @@ def __getitem__(self, key): spec = ax.get_subplotspec() x1, x2, y1, y2 = spec._get_rows_columns(ncols=gs.ncols_total) grid[x1 : x2 + 1, y1 : y2 + 1] = ax - objs = grid[key] + + new_key = [] + for which, keyi in zip("hw", key): + try: + encoded_keyi = gs._encode_indices(keyi, which=which) + except: + raise IndexError( + f"Attempted to access {key=} for gridspec {grid.shape=}" + ) + new_key.append(encoded_keyi) + xs, ys = new_key + objs = grid[xs, ys] if hasattr(objs, "flat"): - objs = list(objs.flat) + objs = [obj for obj in objs.flat if obj is not None] elif not isinstance(objs, list): objs = [objs] + if len(objs) == 1: return objs[0] - else: - objs = [obj for obj in objs if obj is not None] - return SubplotGrid(objs) + objs = [obj for obj in objs if obj is not None] + return SubplotGrid(objs) def __setitem__(self, key, value): """ diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index 4e7a5d2e..e215a90e 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -325,5 +325,5 @@ def test_uneven_span_subplots(rng): axs[1, 1:].format(fc="blush") axs[1, :1].format(fc="sky blue") axs[-1, -1].format(fc="gray4", grid=False) - axs[0].plot((rng.random(50, 10) - 0.5).cumsum(axis=0), cycle="Grays_r", lw=2) + axs[0].plot((rng.random((50, 10)) - 0.5).cumsum(axis=0), cycle="Grays_r", lw=2) return fig From a138bbafe0d2bc45def8045b7e2066871775919d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 4 Oct 2025 18:23:50 +0200 Subject: [PATCH 73/75] adjust default panel ticks --- ultraplot/axes/base.py | 6 ------ ultraplot/figure.py | 18 ++++++++++++++++++ ultraplot/tests/test_geographic.py | 5 ----- ultraplot/utils.py | 5 +++++ 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index db5aab3c..22e489d1 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -1518,12 +1518,6 @@ def _apply_title_above(self): for name in names: labels._transfer_label(self._title_dict[name], pax._title_dict[name]) - def _apply_axis_sharing(self): - """ - Should be implemented by subclasses but silently pass if not, e.g. for polar axes - """ - raise ImplementationError("Axis sharing not implemented for this axes type.") - def _apply_auto_share(self): """ Automatically configure axis sharing based on the horizontal and diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 4cec362c..d3e093b1 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1142,6 +1142,7 @@ def _get_border_axes( grid_axis_type=grid_axis_type, ) for direction, is_border in crawler.find_edges(): + # print(">>", is_border, direction, axi.number) if is_border and axi not in border_axes[direction]: border_axes[direction].append(axi) self._cached_border_axes = border_axes @@ -1273,6 +1274,23 @@ def _add_axes_panel(self, ax, side=None, **kwargs): pax = self.add_subplot(ss, **kwargs) pax._panel_side = side pax._panel_share = share + if share: + # When we are sharing we remove the ticks by default + # as we "push" the labels out. See Figure._share_ticklabels. + # If we add the labels here it is more difficult to control + # for some ticks being on. + from packaging import version + from .internals import _version_mpl + + params = {} + if version.parse(str(_version_mpl)) < version.parse("3.10"): + params = dict(labelleft=False, labelright=False) + pax.xaxis.set_tick_params(**params) + pax.yaxis.set_tick_params(**params) + else: + pax.xaxis.set_tick_params(labelbottom=False, labeltop=False) + pax.yaxis.set_tick_params(labelleft=False, labelright=False) + pax._panel_parent = ax ax._panel_dict[side].append(pax) ax._apply_auto_share() diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index c69fb102..9d4101d4 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -613,15 +613,10 @@ def test_cartesian_and_geo(rng): ax.format(land=True, lonlim=(-10, 10), latlim=(-10, 10)) ax[0].pcolormesh(rng.random((10, 10))) ax[1].scatter(*rng.random((2, 100))) -<<<<<<< HEAD fig.canvas.draw() assert ( mocked.call_count == 2 ) # needs to be called at least twice; one for each axis -======= - ax[0]._apply_axis_sharing() - assert mocked.call_count == 2 ->>>>>>> hotfix-grid-index return fig diff --git a/ultraplot/utils.py b/ultraplot/utils.py index d5cc5db7..2e74e725 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1097,6 +1097,11 @@ def _check_ranges( other_start, other_stop = other_rowspan if this_start == other_start and this_stop == other_stop: + if other._panel_parent is not None: + if dx == 0 and not other._panel_sharex_group: + return True + elif dy == 0 and not other._panel_sharey_group: + return True return False # internal border return True From be2f46b7f4495fcbf3098ce7154724ec8d0cc9ec Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 9 Oct 2025 09:25:14 +0200 Subject: [PATCH 74/75] bump --- ultraplot/figure.py | 14 +++++++++----- ultraplot/utils.py | 43 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index d3e093b1..27062ddd 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -886,7 +886,8 @@ def _share_ticklabels(self, *, axis: str) -> None: f"Tick label sharing not implemented for {type(axi)} subplots." ) return - subplot_types.add(type(axi)) + if not axi._panel_side: + subplot_types.add(type(axi)) match axis: # Handle x case "x" if isinstance(axi, paxes.CartesianAxes): @@ -940,18 +941,21 @@ def _share_ticklabels(self, *, axis: str) -> None: # For panels if hasattr(axi, "_panel_sharey_group") and axi._panel_sharey_group: level = 3 + elif axi._panel_side and axi._sharey: + level = 3 else: # x-axis # For panels if hasattr(axi, "_panel_sharex_group") and axi._panel_sharex_group: level = 3 + elif axi._panel_side and axi._sharex: + level = 3 - # Don't update when we are not sharing axis ticks - if level <= 2: + if level < 3: continue if isinstance(axi, paxes.GeoAxes): # TODO: move this to tick_params? - # Deal with backends as tick_params is still a - # function + # Tick_params is independent of gridliner objects + # Depending on the backend tick params is useful or not axi._toggle_gridliner_labels(**tmp) elif tmp: getattr(axi, f"{axis}axis").set_tick_params(**tmp) diff --git a/ultraplot/utils.py b/ultraplot/utils.py index 2e74e725..3ecd9597 100644 --- a/ultraplot/utils.py +++ b/ultraplot/utils.py @@ -1046,8 +1046,8 @@ def is_border( return self.is_border((x + dx, y + dy), direction) if self.grid_axis_type[x, y] != self.axis_type: - if getattr(cell, "_panel_side", None) is None: - return True + if cell in self.ax._panel_dict.get(cell._panel_side, []): + return self.is_border((x + dx, y + dy), direction) # Internal edge or plot reached if cell != self.ax: @@ -1097,12 +1097,43 @@ def _check_ranges( other_start, other_stop = other_rowspan if this_start == other_start and this_stop == other_stop: - if other._panel_parent is not None: - if dx == 0 and not other._panel_sharex_group: + # We may hit an internal border if we are at + # the interface with a panel that is not sharing + dmap = { + (-1, 0): "bottom", + (1, 0): "top", + (0, -1): "left", + (0, 1): "right", + } + side = dmap[direction] + if self.ax.number is None: # panel + parent = self.ax._panel_parent + + panels = parent._panel_dict.get(side, []) + # If we are a panel at the end we are a border + # only if we are not sharing axes + if side in ("left", "right"): + if self.ax._sharey is None: + return True + elif not self.ax._panel_sharey_group: + return True + elif side in ("top", "bottom"): + if self.ax._sharex is None: + return True + elif not self.ax._panel_sharex_group: + return True + + # Only consider when we are interfacing with a panel + # axes on the outside will also not share when they are in top + # or left + elif side in ("left", "right") and self.ax._sharey is None: + if other.number is None: return True - elif dy == 0 and not other._panel_sharey_group: + elif side in ("bottom", "top") and self.ax._sharex is None: + if other.number is None: return True - return False # internal border + + return False return True From a5a4145d203704a43b565ba1af636d1db7cdaace Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 9 Oct 2025 09:34:13 +0200 Subject: [PATCH 75/75] bump test --- ultraplot/tests/test_geographic.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 9d4101d4..6eef28fd 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -615,7 +615,7 @@ def test_cartesian_and_geo(rng): ax[1].scatter(*rng.random((2, 100))) fig.canvas.draw() assert ( - mocked.call_count == 2 + mocked.call_count > 2 ) # needs to be called at least twice; one for each axis return fig @@ -677,19 +677,9 @@ def test_panels_geo(): ax.format(labels=True) for dir in "top bottom right left".split(): pax = ax.panel_axes(dir) - match dir: - case "top": - assert len(pax.get_xticklabels()) > 0 - assert len(pax.get_yticklabels()) > 0 - case "bottom": - assert len(pax.get_xticklabels()) > 0 - assert len(pax.get_yticklabels()) > 0 - case "left": - assert len(pax.get_xticklabels()) > 0 - assert len(pax.get_yticklabels()) > 0 - case "right": - assert len(pax.get_xticklabels()) > 0 - assert len(pax.get_yticklabels()) > 0 + fig.canvas.draw() # need this to update the ticks + assert len(pax.get_xticklabels()) > 0 + assert len(pax.get_yticklabels()) > 0 @pytest.mark.mpl_image_compare