From 89ae723441623bf0bd3dbbae39a772a7e2075fc5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 15 Jul 2026 18:10:09 +1000 Subject: [PATCH] Gives more power to geo users to place mixed-subplot abc anchors and set aspect on geoplots --- docs/projections.py | 54 +++++++++++++++++++++++ ultraplot/axes/base.py | 1 + ultraplot/axes/geo.py | 70 ++++++++++++++++++++++++++++++ ultraplot/tests/test_geographic.py | 54 +++++++++++++++++++++++ 4 files changed, 179 insertions(+) diff --git a/docs/projections.py b/docs/projections.py index 14d5c4c00..6310fd584 100644 --- a/docs/projections.py +++ b/docs/projections.py @@ -516,6 +516,60 @@ ) +# %% [raw] raw_mimetype="text/restructuredtext" +# .. _ug_geo_layout: +# +# Geographic axes in mixed subplot layouts +# ----------------------------------------- +# +# Geographic axes preserve the aspect ratio required by their projection. This is +# important for interpreting distances and shapes correctly, but it creates a layout +# choice when a map shares a GridSpec with Cartesian axes or maps that have a different +# natural aspect ratio. A fixed-aspect map cannot both fill a differently shaped subplot +# slot *and* preserve its projection geometry. +# +# By default, UltraPlot preserves the map aspect. If the slot is a different shape, the +# visible map is centered inside it. This keeps the map accurate, but decorations attached +# to the visible map boundary can stop lining up with decorations on neighboring subplots. +# For example, the following uses ``abcanchor='slot'`` to attach the map's a-b-c label to +# its unadjusted GridSpec slot instead. The map itself remains aspect-faithful. + +# %% +import ultraplot as uplt + +layout = [[1, 1, 1, 2, 2, 2], [3, 3, 4, 4, 5, 5]] +fig, axs = uplt.subplots(layout, refwidth=2.4, proj={4: "cyl"}, share=False) +axs[3].format( + lonlim=(0, 1), + latlim=(0, 1), + abcanchor="slot", # align its a-b-c label with the Cartesian subplot slots +) +fig.format(abc="A.", abcloc="left") + + +# %% [raw] raw_mimetype="text/restructuredtext" +# ``abcanchor`` has two modes: +# +# * ``'axes'`` (the default) attaches the label to the visible map boundary. This is +# usually best when the map is the visual focus. +# * ``'slot'`` attaches it to the original GridSpec slot. This is useful when labels +# should form a regular grid across mixed subplot types. +# +# There are two other deliberate trade-offs, depending on what the figure needs: +# +# * Use ``aspect='auto'`` with :meth:`~ultraplot.axes.GeoAxes.format` to stretch a map +# to fill its slot. This preserves the GridSpec geometry but distorts the projection, +# so it is generally unsuitable when geographic shape or scale matters. +# * Make the map the layout reference with ``ref=4`` in the above call to +# :func:`~ultraplot.ui.subplots`. UltraPlot then resizes the overall figure so that +# the map fills its slot while retaining its aspect. This is useful when the map, +# rather than the Cartesian axes, should determine the figure geometry. +# +# If none of these trade-offs is appropriate, change the map extent so that its natural +# aspect better matches the intended GridSpec slot. That preserves projection geometry, +# but necessarily changes the plotted geographic domain. + + # %% [raw] raw_mimetype="text/restructuredtext" # .. _ug_zoom: # diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index d71c9b7a9..6d4178b95 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -962,6 +962,7 @@ def __init__(self, *args, **kwargs): self._active_cycle = rc["axes.prop_cycle"] self._auto_format = None # manipulated by wrapper functions self._abc_border_kwargs = {} + self._abc_anchor = "axes" self._abc_loc = None self._abc_pad = 0 # User's horizontal offset for abc label (in points) self._abc_title_pad = rc[ diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index d364e1591..4df8ff08f 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -75,6 +75,15 @@ # Format docstring _format_docstring = """ +aspect : {'auto', 'equal'} or float, optional + The map aspect ratio. ``'auto'`` makes the map fill its subplot slot, which + can be useful for aligning it with neighboring Cartesian axes but distorts + the projection. See :func:`~matplotlib.axes.Axes.set_aspect` for details. +abcanchor : {'axes', 'slot'}, default: 'axes' + The coordinate box used for the a-b-c label. ``'axes'`` attaches it to the + visible map boundary. ``'slot'`` attaches it to the unadjusted GridSpec + slot, keeping labels aligned with neighboring subplots when fixed map + aspect leaves empty space inside a slot. round : bool, default: :rc:`geo.round` *For polar cartopy axes only*. Whether to bound polar projections with circles rather than squares. Note that outer @@ -1553,6 +1562,57 @@ def _get_gridliner_labels( right=right, ) + def _update_title_position(self, renderer: Any) -> None: + """Optionally anchor the a-b-c label to the unadjusted subplot slot.""" + super()._update_title_position(renderer) + if self._abc_anchor != "slot": + return + + obj = self._title_dict["abc"] + if not obj.get_text(): + return + ss = self.get_subplotspec() + if ss is None: + return + fig = self.figure + active = self.get_position() + slot = ss.get_position(fig) + if np.allclose(active.bounds, slot.bounds): + return + + # Preserve the physical text offset from its active-axes edge, while + # replacing that edge with the corresponding GridSpec-slot edge. + active = active.transformed(fig.transFigure) + slot = slot.transformed(fig.transFigure) + x, y = obj.get_transform().transform(obj.get_position()) + loc = self._abc_loc + if loc in ("left", "upper left", "lower left", "outer left"): + x = slot.x0 + (x - active.x0) + elif loc in ("right", "upper right", "lower right", "outer right"): + x = slot.x1 - (active.x1 - x) + else: + x = 0.5 * (slot.x0 + slot.x1) + (x - 0.5 * (active.x0 + active.x1)) + # Matplotlib's above-axes titles use ``va='baseline'`` despite being + # positioned at the top edge, so use the requested location rather + # than the text vertical alignment to select the reference edge. + if loc in ( + "left", + "center", + "right", + "upper left", + "upper center", + "upper right", + "outer left", + "outer right", + ): + y = slot.y1 - (active.y1 - y) + elif loc in ("lower left", "lower center", "lower right"): + y = slot.y0 + (y - active.y0) + else: + y = 0.5 * (slot.y0 + slot.y1) + (y - 0.5 * (active.y0 + active.y1)) + obj.set_transform(fig.transFigure) + obj.set_position(fig.transFigure.inverted().transform((x, y))) + def _toggle_gridliner_labels( self, labeltop: bool | str | None = None, @@ -2138,6 +2198,8 @@ def _format_apply_ticklen( def format( self, *, + aspect: str | float | None = None, + abcanchor: str | None = None, extent: str | None = None, round: bool | None = None, lonlim: tuple[float | None, float | None] | None = None, @@ -2319,6 +2381,14 @@ def format( lonticklen=lonticklen, latticklen=latticklen, ) + if aspect is not None: + self.set_aspect(aspect) + if abcanchor is not None: + if abcanchor not in ("axes", "slot"): + raise ValueError( + f"Invalid abcanchor={abcanchor!r}. Options are 'axes' and 'slot'." + ) + self._abc_anchor = abcanchor # Parent format method super().format(rc_kw=rc_kw, rc_mode=rc_mode, **kwargs) diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 59651d868..c91a9c4f2 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -7,6 +7,60 @@ import ultraplot as uplt +def test_geo_format_aspect(): + pytest.importorskip("cartopy") + fig, ax = uplt.subplots(proj="cyl") + + ax.format(aspect="auto") + + assert ax[0].get_aspect() == "auto" + uplt.close(fig) + + +def test_geo_abcanchor_slot(): + pytest.importorskip("cartopy") + fig, ax = uplt.subplots(ncols=2, proj="cyl", figsize=(8, 3), share=False) + ax.format( + abc=True, + abcloc="upper left", + abcanchor="slot", + lonlim=(0, 1), + latlim=(0, 1), + ) + fig.canvas.draw() + + axes = ax[0] + label = axes._title_dict["abc"] + slot = axes.get_subplotspec().get_position(fig).transformed(fig.transFigure) + active = axes.get_position().transformed(fig.transFigure) + bbox = label.get_window_extent(fig.canvas.get_renderer()) + assert bbox.x0 < active.x0 + assert abs(bbox.x0 - slot.x0) < abs(bbox.x0 - active.x0) + uplt.close(fig) + + +def test_geo_abcanchor_slot_above_axes(): + pytest.importorskip("cartopy") + fig, ax = uplt.subplots(nrows=2, proj="cyl", figsize=(3, 8), share=False) + ax.format( + abc=True, + abcloc="left", + abcanchor="slot", + lonlim=(0, 1), + latlim=(0, 1), + ) + fig.canvas.draw() + + axes = ax[0] + label = axes._title_dict["abc"] + slot = axes.get_subplotspec().get_position(fig).transformed(fig.transFigure) + active = axes.get_position().transformed(fig.transFigure) + bbox = label.get_window_extent(fig.canvas.get_renderer()) + assert bbox.y0 > active.y1 + assert abs(bbox.y0 - slot.y1) < abs(bbox.y0 - active.y1) + uplt.close(fig) + + @pytest.mark.mpl_image_compare def test_geographic_single_projection(): fig = uplt.figure(refwidth=3)