Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/projections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
#
Expand Down
1 change: 1 addition & 0 deletions ultraplot/axes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down
70 changes: 70 additions & 0 deletions ultraplot/axes/geo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions ultraplot/tests/test_geographic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down