Repository: Ultraplot/UltraPlot

Issue:
Inconistent formatting when setting labelloc for shared axes
> Ah I see... that is a different bug. 
> 
> To explain what is happening. The axes are shared which makes the left most and bottom most subplot the references. If you set the ticks to be top and right, it updates them for the references. What you want, I think, is set the top labels on for the top and right labels for the right most axes. 
> 
>  

 _Originally posted by @cvanelteren in [#155](https://github.com/Ultraplot/ultraplot/issues/155#issuecomment-2829791466)_

```python
import ultraplot as pplt
fig, axs = pplt.subplots(ncols=2, nrows=2,share='all',refwidth='2cm')
axs.format(xlim=(0,60),ylim=(-20,20), xticklabelloc='t', yticklabelloc='r')
pplt.show()
```

Discussion:

Solution patch:
diff --git a/ultraplot/axes/cartesian.py b/ultraplot/axes/cartesian.py
index 2aa5a8be..923ba227 100644
--- a/ultraplot/axes/cartesian.py
+++ b/ultraplot/axes/cartesian.py
@@ -901,6 +901,55 @@ class CartesianAxes(shared._SharedAxes, plot.PlotAxes):
             kw.update({"label" + side: False for side in sides if side not in tickloc})
         self.tick_params(axis=s, which="both", **kw)
 
+        # When axes are shared, the reference
+        # is the top left and bottom left plot
+        # for labels that are top or right
+        # this will plot them on those first two plots
+        # we can fool mpl to share them by turning
+        # sharex/y  off for those plots. We still
+        # update the ticks with the prior step, and
+        # keep the references in _shared_axes for all plots
+        labellright = kw.pop("labelright", None)
+        labeltop = kw.pop("labeltop", None)
+
+        nrows, ncols = self.figure.gridspec.nrows, self.figure.gridspec.ncols
+        border_axes = {}
+        if labellright and self.figure._sharey or labeltop and self.figure._sharex:
+            border_axes = self.figure._get_border_axes()
+        # Only update if above is conditions above are true
+        for side, axs in border_axes.items():
+            for axi in axs:
+                if axi in self._twinned_axes:
+                    continue
+                # Unset sharex/y otherwise ticks
+                # won't appear
+                if labellright and side == "right":
+                    axi._sharey = None
+                    siblings = list(axi._shared_axes["y"].get_siblings(axi))
+                    for sibling in siblings:
+                        if sibling is axi:
+                            continue
+                        if sibling._sharey is not None:
+                            continue
+                        sibling._sharey = axi
+                    axi.tick_params(labelright=labellright)
+                elif labellright and side == "left":
+                    if axi not in border_axes["right"]:
+                        axi.tick_params(labelright=not labellright)
+                elif labeltop and side == "top":
+                    axi._sharex = None
+                    siblings = list(axi._shared_axes["x"].get_siblings(axi))
+                    for sibling in siblings:
+                        if sibling is axi:
+                            continue
+                        if sibling._sharex is not None:
+                            continue
+                        sibling._sharex = axi
+                    axi.tick_params(labeltop=labeltop)
+                elif labeltop and side == "bottom":
+                    if axi not in border_axes["top"]:
+                        axi.tick_params(labeltop=not labeltop)
+
         # Apply the axis label and offset label locations
         # Uses ugly mpl 3.3+ tick_top() tick_bottom() kludge for offset location
         # See: https://matplotlib.org/3.3.1/users/whats_new.html
diff --git a/ultraplot/figure.py b/ultraplot/figure.py
index cb52c5a9..3e9eb892 100644
--- a/ultraplot/figure.py
+++ b/ultraplot/figure.py
@@ -7,6 +7,11 @@ import inspect
 import os
 from numbers import Integral
 
+try:
+    from typing import List
+except:
+    from typing_extensions import List
+
 import matplotlib.axes as maxes
 import matplotlib.figure as mfigure
 import matplotlib.gridspec as mgridspec
@@ -901,6 +906,76 @@ class Figure(mfigure.Figure):
         axs = [ax for ax in axs if ax.get_visible()]
         return axs
 
+    def _get_border_axes(self) -> dict[str, list[paxes.Axes]]:
+        """
+        Identifies axes located on the outer boundaries of the GridSpec layout.
+
+        Returns a dictionary with keys 'top', 'bottom', 'left', 'right', each
+        containing a list of axes on that border.
+        """
+
+        gs = self.gridspec
+        all_axes = self.axes
+
+        # Handle empty cases
+        nrows, ncols = gs.nrows, gs.ncols
+        if nrows == 0 or ncols == 0 or not all_axes:
+            return dict(top=[], bottom=[], left=[], right=[])
+
+        # Find occupied grid cells and valid axes
+        occupied_cells = set()
+        axes_with_spec = []
+
+        for axi in all_axes:
+            spec = axi.get_subplotspec()
+            if spec is not None:
+                axes_with_spec.append((axi, spec))
+                r0, r1 = spec.rowspan.start, spec.rowspan.stop
+                c0, c1 = spec.colspan.start, spec.colspan.stop
+                for r in range(r0, r1):
+                    for c in range(c0, c1):
+                        occupied_cells.add((r, c))
+
+        if not axes_with_spec:
+            return dict(top=[], bottom=[], left=[], right=[])
+
+        # Initialize border axes sets
+        border_axes_sets = dict(top=set(), bottom=set(), left=set(), right=set())
+
+        # Check each axis against border criteria
+        for axi, spec in axes_with_spec:
+            r0, r1 = spec.rowspan.start, spec.rowspan.stop
+            c0, c1 = spec.colspan.start, spec.colspan.stop
+
+            # Check top border
+            if r0 == 0 or (
+                r0 == 1 and any((0, c) not in occupied_cells for c in range(c0, c1))
+            ):
+                border_axes_sets["top"].add(axi)
+
+            # Check bottom border
+            if r1 == nrows or (
+                r1 == nrows - 1
+                and any((nrows - 1, c) not in occupied_cells for c in range(c0, c1))
+            ):
+                border_axes_sets["bottom"].add(axi)
+
+            # Check left border
+            if c0 == 0 or (
+                c0 == 1 and any((r, 0) not in occupied_cells for r in range(r0, r1))
+            ):
+                border_axes_sets["left"].add(axi)
+
+            # Check right border
+            if c1 == ncols or (
+                c1 == ncols - 1
+                and any((r, ncols - 1) not in occupied_cells for r in range(r0, r1))
+            ):
+                border_axes_sets["right"].add(axi)
+
+        # Convert sets to lists
+        return {key: list(val) for key, val in border_axes_sets.items()}
+
     def _get_align_coord(self, side, axs, includepanels=False):
         """
         Return the figure coordinate for centering spanning axis labels or super titles.


Test patch:
diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py
index 668a16fc..d0b1f83b 100644
--- a/ultraplot/tests/test_axes.py
+++ b/ultraplot/tests/test_axes.py
@@ -187,3 +187,71 @@ def test_subset_format():
     # Shorter than number of axs
     with pytest.raises(ValueError):
         axs.format(title=["a"])
+
+
+def test_sharing_labels_top_right():
+    fig, ax = uplt.subplots(ncols=3, nrows=3, share="all")
+    # On the first format sharexy is modified
+    ax.format(
+        xticklabelloc="t",
+        yticklabelloc="r",
+    )
+    # If we format again, we expect all the limits to be changed
+    # Plot on one shared axis a non-trivial point
+    # and check whether the limits are correctly adjusted
+    # for all other plots
+    ax[0].scatter([30, 40], [30, 40])
+    xlim = ax[0].get_xlim()
+    ylim = ax[0].get_ylim()
+
+    for axi in ax:
+        for i, j in zip(axi.get_xlim(), xlim):
+            assert i == j
+        for i, j in zip(axi.get_ylim(), ylim):
+            assert i == j
+
+
+def test_sharing_labels_top_right_odd_layout():
+    layout = [
+        [1, 2, 0],
+        [1, 2, 5],
+        [3, 4, 5],
+        [3, 4, 0],
+    ]
+    fig, ax = uplt.subplots(layout)
+    ax.format(
+        xticklabelloc="t",
+        yticklabelloc="r",
+    )
+
+    def check_state(numbers: list, state: bool, which: str):
+        for number in numbers:
+            for label in getattr(ax[number], f"get_{which}ticklabels")():
+                assert label.get_visible() == state
+
+    # these correspond to the indices of the axis
+    # in the axes array (so the grid number minus 1)
+    check_state([0, 2], False, which="y")
+    check_state([1, 3, 4], True, which="y")
+    check_state([2, 3], False, which="x")
+    check_state([0, 1, 4], True, which="x")
+    uplt.close(fig)
+
+    layout = [
+        [1, 0, 2],
+        [0, 3, 0],
+        [4, 0, 5],
+    ]
+
+    fig, ax = uplt.subplots(layout)
+    ax.format(
+        xticklabelloc="t",
+        yticklabelloc="r",
+    )
+    # these correspond to the indices of the axis
+    # in the axes array (so the grid number minus 1)
+    check_state([0, 3], False, which="y")
+    check_state([1, 2, 4], True, which="y")
+    check_state([0, 1, 2], True, which="x")
+    check_state([3, 4], False, which="x")
+    uplt.close(fig)
diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py
index 5b2d7572..a95a3273 100644
--- a/ultraplot/tests/test_geographic.py
+++ b/ultraplot/tests/test_geographic.py
@@ -1,13 +1,11 @@
-import ultraplot as plt, numpy as np, warnings
+import ultraplot as uplt, numpy as np, warnings
 import pytest
 
 
 @pytest.mark.mpl_image_compare
 def test_geographic_single_projection():
-    fig = plt.figure(refwidth=3)
+    fig = uplt.figure(refwidth=3)
     axs = fig.subplots(nrows=2, proj="robin", proj_kw={"lon_0": 180})
-    # proj = uplt.Proj('robin', lon_0=180)
-    # axs = uplt.subplots(nrows=2, proj=proj)  # equivalent to above
     axs.format(
         suptitle="Figure with single projection",
         land=True,
@@ -19,9 +17,9 @@ def test_geographic_single_projection():
 
 @pytest.mark.mpl_image_compare
 def test_geographic_multiple_projections():
-    fig = plt.figure()
+    fig = uplt.figure()
     # Add projections
-    gs = plt.GridSpec(ncols=2, nrows=3, hratios=(1, 1, 1.4))
+    gs = uplt.GridSpec(ncols=2, nrows=3, hratios=(1, 1, 1.4))
     for i, proj in enumerate(("cyl", "hammer", "npstere")):
         ax1 = fig.subplot(gs[i, 0], proj=proj, basemap=True)  # basemap
         ax2 = fig.subplot(gs[i, 1], proj=proj)  # cartopy
@@ -40,7 +38,7 @@ def test_geographic_multiple_projections():
     fig.subplotgrid[-2:].format(
         latlines=20, lonlines=30
     )  # dense gridlines for polar plots
-    plt.rc.reset()
+    uplt.rc.reset()
     return fig
 
 
@@ -48,14 +46,14 @@ def test_geographic_multiple_projections():
 def test_drawing_in_projection_without_globe():
     # Fake data with unusual longitude seam location and without coverage over poles
     offset = -40
-    lon = plt.arange(offset, 360 + offset - 1, 60)
-    lat = plt.arange(-60, 60 + 1, 30)
+    lon = uplt.arange(offset, 360 + offset - 1, 60)
+    lat = uplt.arange(-60, 60 + 1, 30)
     data = np.random.rand(len(lat), len(lon))
 
     globe = False
     string = "with" if globe else "without"
-    gs = plt.GridSpec(nrows=2, ncols=2)
-    fig = plt.figure(refwidth=2.5)
+    gs = uplt.GridSpec(nrows=2, ncols=2)
+    fig = uplt.figure(refwidth=2.5)
     for i, ss in enumerate(gs):
         ax = fig.subplot(ss, proj="kav7", basemap=(i % 2))
         cmap = ("sunset", "sunrise")[i % 2]
@@ -83,14 +81,14 @@ def test_drawing_in_projection_without_globe():
 def test_drawing_in_projection_with_globe():
     # Fake data with unusual longitude seam location and without coverage over poles
     offset = -40
-    lon = plt.arange(offset, 360 + offset - 1, 60)
-    lat = plt.arange(-60, 60 + 1, 30)
+    lon = uplt.arange(offset, 360 + offset - 1, 60)
+    lat = uplt.arange(-60, 60 + 1, 30)
     data = np.random.rand(len(lat), len(lon))
 
     globe = True
     string = "with" if globe else "without"
-    gs = plt.GridSpec(nrows=2, ncols=2)
-    fig = plt.figure(refwidth=2.5)
+    gs = uplt.GridSpec(nrows=2, ncols=2)
+    fig = uplt.figure(refwidth=2.5)
     for i, ss in enumerate(gs):
         ax = fig.subplot(ss, proj="kav7", basemap=(i % 2))
         cmap = ("sunset", "sunrise")[i % 2]
@@ -119,13 +117,13 @@ def test_geoticks():
 
     lonlim = (-140, 60)
     latlim = (-10, 50)
-    basemap_projection = plt.Proj(
+    basemap_projection = uplt.Proj(
         "cyl",
         lonlim=lonlim,
         latlim=latlim,
         backend="basemap",
     )
-    fig, ax = plt.subplots(
+    fig, ax = uplt.subplots(
         ncols=3,
         proj=(
             "cyl",  # cartopy
@@ -162,9 +160,9 @@ def test_geoticks():
 
 
 def test_geoticks_input_handling(recwarn):
-    fig, ax = plt.subplots(proj="aeqd")
+    fig, ax = uplt.subplots(proj="aeqd")
     # Should warn that about non-rectilinear projection.
-    with pytest.warns(plt.warnings.UltraplotWarning):
+    with pytest.warns(uplt.warnings.UltraplotWarning):
         ax.format(lonticklen=True)
     # When set to None the latticks are not added.
     # No warnings should be raised.