Skip to content
Merged
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
73 changes: 72 additions & 1 deletion docs/insets_panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@
)

# %%
import ultraplot as uplt
import numpy as np
import ultraplot as uplt

state = np.random.RandomState(51423)
data = (state.rand(20, 20) - 0.48).cumsum(axis=1).cumsum(axis=0)
Expand Down Expand Up @@ -192,3 +192,74 @@
)
ix.format(xlim=(2, 4), ylim=(2, 4), color="red8", linewidth=1.5, ticklabelweight="bold")
ix.pcolormesh(data, cmap="Grays", levels=N, inbounds=False)

# %% [raw] raw_mimetype="text/restructuredtext"
# Hawkeye map insets
# ~~~~~~~~~~~~~~~~~~
#
# :class:`~ultraplot.axes.GeoAxes` adds :meth:`~ultraplot.axes.GeoAxes.hawkeye`
# for geographic callout maps. The inset is anchored at ``xy`` in a parent coordinate
# system, and its ``size`` is a fraction of the parent axes. Supply ``extent`` to set
# the inset map scope and draw a matching indicator and optional connectors on the
# parent map. Hawkeyes are excluded from automatic layout, so they can extend beyond
# the parent axes without reserving subplot space.
#
# Rectangular hawkeyes preserve both the requested extent and projection scale. A
# circular frame cannot simultaneously preserve projection scale and display an exact
# non-square extent. Circular hawkeyes therefore expand the shorter projected dimension
# to retain the projection; use a square extent when the exact circular map scope is
# important, or pass ``aspect='auto'`` to fit the exact extent with distortion.
#
# The following dependency-free example shows a Singapore city locator on a world map.
# The returned inset is an ordinary geographic axes, so optional geospatial libraries
# can draw external data inside it. For example, `OSMnx <https://osmnx.readthedocs.io/>`__
# can provide street geometry when it is installed separately:
#
# .. code-block:: python
#
# import osmnx as ox
# from cartopy import crs as ccrs
#
# network = ox.graph_from_point((1.3521, 103.8198), dist=8_000, network_type="drive")
# streets = ox.graph_to_gdfs(network, nodes=False)
# streets.plot(ax=inax, color="0.3", linewidth=0.4, transform=ccrs.PlateCarree())
#
# The data download and OpenStreetMap attribution are the responsibility of the
# application using that optional integration.

# %%
singapore = (103.8198, 1.3521)
fig, ax = uplt.subplots(proj="robin", refwidth=4)
ax.format(land=True, landcolor="gray8", oceancolor="blue9")
ax.plot(
*singapore,
marker="o",
color="red",
markeredgecolor="white",
markeredgewidth=0.8,
ms=5,
transform="cyl",
)
ax.text(106, 4, "Singapore", color="red", size=7, transform="map")
inax = ax.hawkeye(
(0.97, 0.97),
size=0.23,
anchor="ur",
proj="merc",
extent=(103.76, 103.90, 1.27, 1.41),
shape="circle",
target="circle",
connector="line",
color="red",
indicator_kw={"linewidth": 1.5},
)
inax.format(land=True, landcolor="gray9", oceancolor="blue9")
inax.plot(
*singapore,
marker="o",
color="red",
markeredgecolor="white",
markeredgewidth=0.8,
ms=5,
transform="cyl",
)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ exclude = ["**/*.ipynb"]

[tool.pytest.ini_options]
filterwarnings = [
"ignore:'parseString' deprecated - use 'parse_string':DeprecationWarning:matplotlib._fontconfig_pattern",
"ignore:'resetCache' deprecated - use 'reset_cache':DeprecationWarning:matplotlib._fontconfig_pattern",
]
mpl-default-style = { axes.prop_cycle = "cycler('color', ['#4c72b0ff', '#55a868ff', '#c44e52ff', '#8172b2ff', '#ccb974ff', '#64b5cdff'])" }
Expand Down
44 changes: 39 additions & 5 deletions ultraplot/axes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,9 @@

Parameters
-----------
bounds : 4-tuple of float
The (left, bottom, width, height) coordinates for the axes.
bounds : 4-tuple of float or (4-tuple, transform)
The (left, bottom, width, height) coordinates for the axes. To specify the
coordinate system alongside the coordinates, pass ``(bounds, transform)``.
%(axes.transform)s
Default is to use the same projection as the current axes.
%(axes.proj)s
Expand Down Expand Up @@ -750,6 +751,20 @@ def __call__(self, ax, renderer): # noqa: U100
return bbox


class _AspectAwareTransformedBoundsLocator(_TransformedBoundsLocator):
"""Preserve an inset's lower-left anchor after box-aspect adjustment."""

def __call__(self, ax, renderer):
bbox = super().__call__(ax, renderer)
aspect = ax.get_aspect()
if aspect == "auto" or ax.get_adjustable() != "box":
return bbox
box_aspect = float(aspect) * ax.get_data_ratio()
fig_bbox = ax.figure.bbox
fig_aspect = fig_bbox.height / fig_bbox.width
return bbox.shrunk_to_aspect(box_aspect, bbox, fig_aspect)


class _SideColorbarLocator:
"""Position a side colorbar beyond its parent axes decorations."""

Expand Down Expand Up @@ -1057,9 +1072,10 @@ def _add_inset_axes(
"""
Add an inset axes using arbitrary projection.
"""
# Converting transform to figure-relative coordinates
transform = self._get_transform(transform, "axes")
locator = self._make_inset_locator(bounds, transform)
# Convert coordinates to figure-relative coordinates while retaining the
# original bounds for aspect-aware geographic inset locators.
bounds_input, transform = self._parse_anchor(bounds, transform, default="axes")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't understand the logic here. Can you explain it? Or simple example?

locator = self._make_inset_locator(bounds_input, transform)
bounds = locator(self, None).bounds
label = kwargs.pop("label", "inset_axes")
zorder = _not_none(zorder, 4)
Expand All @@ -1077,6 +1093,8 @@ def _add_inset_axes(
# automatically if we used data coords. Called by ax.apply_aspect()
cls = mproj.get_projection_class(kwargs.pop("projection"))
ax = cls(self.figure, bounds, zorder=zorder, label=label, **kwargs)
if ax._name in ("cartopy", "basemap"):
locator = _AspectAwareTransformedBoundsLocator(bounds_input, transform)
ax.set_axes_locator(locator)
ax._inset_parent = self
ax._inset_bounds = bounds
Expand Down Expand Up @@ -1748,6 +1766,22 @@ def _get_transform(self, transform, default="data"):
else:
raise ValueError(f"Unknown transform {transform!r}.")

def _parse_anchor(self, coordinates, transform=None, default="data"):
"""
Parse coordinates and their transform.

Coordinates can be passed with the transform separately or packaged as
a ``(coordinates, transform)`` tuple. The latter is useful for APIs
that accept a single anchor argument, such as ``inset_axes``.
"""
if (
transform is None
and isinstance(coordinates, tuple)
and len(coordinates) == 2
):
coordinates, transform = coordinates
return coordinates, self._get_transform(transform, default)

def _register_guide(self, guide, obj, key, **kwargs):
"""
Queue up or replace objects for legends and list-of-artist style colorbars.
Expand Down
Loading
Loading