diff --git a/docs/insets_panels.py b/docs/insets_panels.py index 03f9d6cbd..e56010049 100644 --- a/docs/insets_panels.py +++ b/docs/insets_panels.py @@ -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) @@ -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 `__ +# 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", +) diff --git a/pyproject.toml b/pyproject.toml index cfb72d87e..d03a0c617 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'])" } diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 340a2960d..735e99bee 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -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 @@ -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.""" @@ -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") + locator = self._make_inset_locator(bounds_input, transform) bounds = locator(self, None).bounds label = kwargs.pop("label", "inset_axes") zorder = _not_none(zorder, 4) @@ -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 @@ -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. diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 15f74d8ce..657136f0a 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -7,7 +7,10 @@ import copy import inspect +from dataclasses import dataclass from functools import partial +from numbers import Real +from types import SimpleNamespace try: # From python 3.12 @@ -19,6 +22,7 @@ from typing import Any, Optional, Protocol import matplotlib.axis as maxis +import matplotlib.axes as maxes import matplotlib.collections as mcollections import matplotlib.patches as mpatches import matplotlib.path as mpath @@ -71,6 +75,432 @@ _BASEMAP_LABEL_X_SCALE = 0.25 # empirical spacing to mimic cartopy _CARTOPY_LABEL_SIDES = ("labelleft", "labelright", "labelbottom", "labeltop", "geo") _BASEMAP_LABEL_SIDES = ("labelleft", "labelright", "labelbottom", "labeltop", "geo") +_HAWKEYE_ANCHORS = { + "ul": (0, 1), + "upper left": (0, 1), + "ur": (1, 1), + "upper right": (1, 1), + "ll": (0, 0), + "lower left": (0, 0), + "lr": (1, 0), + "lower right": (1, 0), + "c": (0.5, 0.5), + "center": (0.5, 0.5), + "uc": (0.5, 1), + "upper center": (0.5, 1), + "lc": (0.5, 0), + "lower center": (0.5, 0), + "cl": (0, 0.5), + "center left": (0, 0.5), + "cr": (1, 0.5), + "center right": (1, 0.5), +} + + +class _AnchoredInsetLocator: + """Locate an inset by anchoring one of its points to a parent coordinate.""" + + def __init__(self, parent, xy, size, transform, anchor, square=False): + self._parent = parent + self._xy = tuple(xy) + self._size = tuple(size) + self._transform = transform + self._anchor = anchor + self._square = square + + def __call__(self, ax, renderer): # noqa: U100 + parent = self._parent + transform = self._transform + if ccrs is not None and isinstance(transform, ccrs.CRS): + transform = transform._as_mpl_transform(parent) + xy = transform.transform(self._xy) + transfig = getattr(parent.figure, "transSubfigure", parent.figure.transFigure) + x, y = transfig.inverted().transform(xy) + parent_bbox = parent.get_position() + width = self._size[0] * parent_bbox.width + height = self._size[1] * parent_bbox.height + if self._square: + figure_bbox = parent.figure.bbox + side = min(width * figure_bbox.width, height * figure_bbox.height) + width = side / figure_bbox.width + height = side / figure_bbox.height + return mtransforms.Bbox.from_bounds( + x - self._anchor[0] * width, + y - self._anchor[1] * height, + width, + height, + ) + + +# Transform names that address a matplotlib coordinate system rather than a +# geographic projection. Any other string passed as a hawkeye ``transform`` is +# resolved as a projection name through ``constructor.Proj``. +_HAWKEYE_TRANSFORM_NAMES = frozenset({"axes", "data", "figure", "subfigure", "map"}) + + +def _parse_hawkeye_anchor(anchor, axes_relative=True): + """Translate a named hawkeye anchor to normalized axes coordinates. + + String anchors are always axes-relative. When ``axes_relative`` is False the + caller supplied a non-default ``anchor_transform``, so a coordinate 2-tuple + is required and string aliases are rejected. + """ + if isinstance(anchor, str): + if not axes_relative: + raise ValueError( + "String anchors are axes-relative and cannot be combined with a " + "non-default anchor_transform; pass a coordinate 2-tuple instead." + ) + try: + return _HAWKEYE_ANCHORS[anchor] + except KeyError as exc: + options = ", ".join(map(repr, _HAWKEYE_ANCHORS)) + raise ValueError( + f"Invalid anchor {anchor!r}. Must be one of {options}." + ) from exc + if len(anchor) != 2: + raise ValueError(f"anchor must have length 2, got {anchor!r}.") + return tuple(anchor) + + +def _parse_hawkeye_size(size): + """Normalize scalar hawkeye sizes to square inset dimensions.""" + if isinstance(size, Real): + size = (size, size) + if len(size) != 2 or any(value <= 0 for value in size): + raise ValueError(f"size must contain two positive values, got {size!r}.") + return tuple(size) + + +def _hawkeye_crs_from_name(name): + """Resolve a projection name to a cartopy CRS for hawkeye coordinates.""" + crs = constructor.Proj(name) + if ccrs is None or not isinstance(crs, ccrs.CRS): + raise ValueError( + f"Projection {name!r} did not resolve to a cartopy CRS; hawkeye " + "projection coordinates require the cartopy backend." + ) + return crs + + +def _hawkeye_crs(transform, param): + """Resolve a hawkeye geographic transform to a cartopy CRS. + + Accepts ``'map'`` (an alias for `~cartopy.crs.PlateCarree`), a cartopy CRS + instance, or a registered projection name (e.g. ``'cyl'``, ``'moll'``). + """ + if transform == "map": + return ccrs.PlateCarree() + if ccrs is not None and isinstance(transform, ccrs.CRS): + return transform + if isinstance(transform, str): + return _hawkeye_crs_from_name(transform) + raise ValueError(f"{param} must be 'map', a projection name, or a cartopy CRS.") + + +def _parse_hawkeye_extent_transform(transform): + """Translate hawkeye extent transforms to cartopy coordinate systems.""" + return _hawkeye_crs(transform, "extent_transform") + + +def _parse_hawkeye_anchor_transform(transform): + """Resolve the coordinate system for a hawkeye anchor point. + + ``'axes'`` (the default) leaves the anchor as an inset axes fraction and is + signalled by returning ``None``. Any other value is resolved to a cartopy CRS + so the anchor can be interpreted as a geographic or projected point. + """ + if transform in ("axes", None): + return None + return _hawkeye_crs(transform, "anchor_transform") + + +def _hawkeye_anchor_fraction(inset, anchor, anchor_transform): + """Convert a hawkeye anchor point to an inset axes fraction. + + With ``anchor_transform`` None the anchor is already an axes fraction and is + returned unchanged. Otherwise the anchor is a point in ``anchor_transform`` + coordinates; it is projected into the inset projection and normalized against + the inset view limits, which are fixed by the time this runs. + """ + if anchor_transform is None: + return anchor + x, y = inset.projection.transform_point(anchor[0], anchor[1], anchor_transform) + if not (np.isfinite(x) and np.isfinite(y)): + raise ValueError( + f"anchor {anchor!r} does not project into the inset projection." + ) + x0, x1 = inset.get_xlim() + y0, y1 = inset.get_ylim() + fx = (x - x0) / (x1 - x0) + fy = (y - y0) / (y1 - y0) + tol = 1e-9 + if not (-tol <= fx <= 1 + tol and -tol <= fy <= 1 + tol): + raise ValueError( + f"anchor {anchor!r} lies outside the inset extent; the anchor point " + "must fall within the displayed geographic area." + ) + return (fx, fy) + + +def _parse_hawkeye_connector(connector): + """Normalize connector shorthand to a named presentation mode.""" + if connector is False: + return None + if connector is True: + return "corners" + if connector in ("corners", "line"): + return connector + raise ValueError("connector must be False, True, 'corners', or 'line'.") + + +def _parse_hawkeye_shape(value, name): + """Validate a hawkeye inset or target shape.""" + if value not in ("box", "circle"): + raise ValueError(f"{name} must be 'box' or 'circle'.") + return value + + +def _square_hawkeye_view(inset): + """Expand the shorter projected dimension to make a square map viewport.""" + x0, x1 = inset.get_xlim() + y0, y1 = inset.get_ylim() + width, height = x1 - x0, y1 - y0 + if width > height: + center = (y0 + y1) / 2 + inset.set_ylim(center - width / 2, center + width / 2) + elif height > width: + center = (x0 + x1) / 2 + inset.set_xlim(center - height / 2, center + height / 2) + + +def _infer_hawkeye_relation(parent_extent, inset_extent): + """Infer whether an inset is a geographic detail or overview.""" + parent_west, parent_east, parent_south, parent_north = parent_extent + inset_west, inset_east, inset_south, inset_north = inset_extent + parent_area = abs((parent_east - parent_west) * (parent_north - parent_south)) + inset_area = abs((inset_east - inset_west) * (inset_north - inset_south)) + return "overview" if inset_area > parent_area else "detail" + + +def _segments_intersect(start1, end1, start2, end2): + """Return whether two display-coordinate line segments intersect.""" + + def _cross(origin, point1, point2): + return np.cross(point1 - origin, point2 - origin) + + cross1 = _cross(start1, end1, start2) + cross2 = _cross(start1, end1, end2) + cross3 = _cross(start2, end2, start1) + cross4 = _cross(start2, end2, end1) + return cross1 * cross2 < 0 and cross3 * cross4 < 0 + + +def _select_hawkeye_connector_pairs(extent_display, frame_display): + """Select the shortest pair of non-crossing overview connectors.""" + candidates = [ + (extent_index, frame_index) + for extent_index in range(len(extent_display)) + for frame_index in range(len(frame_display)) + ] + best = None + for first_index, first in enumerate(candidates): + for second in candidates[first_index + 1 :]: + if first[0] == second[0] or first[1] == second[1]: + continue + first_start, first_end = extent_display[first[0]], frame_display[first[1]] + second_start, second_end = ( + extent_display[second[0]], + frame_display[second[1]], + ) + if _segments_intersect(first_start, first_end, second_start, second_end): + continue + distance = np.linalg.norm(first_end - first_start) + np.linalg.norm( + second_end - second_start + ) + if best is None or distance < best[0]: + best = (distance, (first, second)) + return best[1] if best is not None else () + + +def _add_hawkeye_overview_connectors(parent, inset, extent, transform, **kwargs): + """Connect the parent frame to its geographic extent on an overview inset.""" + west, east, south, north = extent + extent_corners = np.array( + ((west, south), (west, north), (east, south), (east, north)) + ) + frame_corners = np.array(((0, 0), (0, 1), (1, 0), (1, 1))) + extent_transform = transform._as_mpl_transform(inset) + pairs = _select_hawkeye_connector_pairs( + extent_transform.transform(extent_corners), + parent.transAxes.transform(frame_corners), + ) + + kwargs = dict(kwargs) + kwargs.pop("facecolor", None) + kwargs["color"] = kwargs.pop("edgecolor", kwargs.get("color", None)) + kwargs.setdefault("clip_on", False) + connectors = [] + for extent_index, frame_index in pairs: + connector = mpatches.ConnectionPatch( + extent_corners[extent_index], + frame_corners[frame_index], + coordsA=extent_transform, + coordsB=parent.transAxes, + axesA=inset, + axesB=parent, + **kwargs, + ) + parent.figure.add_artist(connector) + connectors.append(connector) + return tuple(connectors) + + +def _add_hawkeye_leader( + inset, target_axes, target_xy, transform, target_patch=None, **kwargs +): + """Draw a leader from an inset edge to a geographic target point.""" + kwargs = dict(kwargs) + kwargs.pop("facecolor", None) + kwargs["color"] = kwargs.pop("edgecolor", kwargs.get("color", None)) + kwargs.setdefault("clip_on", False) + connector = mpatches.ConnectionPatch( + (0.5, 0.5), + target_xy, + coordsA=inset.transAxes, + coordsB=transform._as_mpl_transform(target_axes), + axesA=inset, + axesB=target_axes, + patchA=inset.patch, + patchB=target_patch, + **kwargs, + ) + # The parent draws after its map artists but before the inset axes. Clipping the + # source point to ``inset.patch`` stops the leader at any custom inset boundary. + target_axes.add_artist(connector) + return connector + + +def _add_hawkeye_zoom_indicator(parent: "GeoAxes", inset: "GeoAxes", **kwargs: Any): + """Draw an ``indicate_inset_zoom`` marker with a version-stable return. + + matplotlib >= 3.10 returns an ``InsetIndicator`` artist exposing + ``.rectangle`` and ``.connectors``. Earlier versions return a plain + ``(rectangle, connectors)`` tuple, so wrap it to expose the same accessors. + """ + indicator = maxes.Axes.indicate_inset_zoom(parent, inset, **kwargs) + if isinstance(indicator, tuple): + rectangle, connectors = indicator + indicator = SimpleNamespace(rectangle=rectangle, connectors=connectors) + return indicator + + +def _select_enveloping_connectors(indicator, inset: "GeoAxes", renderer=None) -> None: + """Show the two zoom connectors that wrap around the inset (outer tangents). + + The visible pair is chosen from the sign of the inset-to-indicator centre + offset: a diagonally opposite pair envelops the inset, whereas a same-side + pair would run parallel and cross the frustum. + """ + connectors = indicator.connectors + if not connectors: + return + rect_bbox = indicator.rectangle.get_window_extent(renderer) + inset_bbox = inset.get_window_extent(renderer) + dx = (inset_bbox.x0 + inset_bbox.x1) - (rect_bbox.x0 + rect_bbox.x1) + dy = (inset_bbox.y0 + inset_bbox.y1) - (rect_bbox.y0 + rect_bbox.y1) + # Connector order is (lower-left, upper-left, lower-right, upper-right). + visible = (1, 2) if dx * dy >= 0 else (0, 3) + for index, connector in enumerate(connectors): + connector.set_visible(index in visible) + + +def _envelop_hawkeye_zoom_connectors(indicator, inset: "GeoAxes") -> None: + """Reassert the enveloping connector pair on every draw. + + matplotlib fixes connector visibility once, from a bounding-box rule that can + pick a parallel pair for a diagonally placed inset. The inset position is only + final at draw time, so recompute the enveloping pair from a draw hook: on + matplotlib >= 3.10 the ``InsetIndicator`` resolves its connectors in its own + ``draw``, while the legacy wrapper draws its rectangle before the connectors. + """ + draw_host = indicator if hasattr(indicator, "draw") else indicator.rectangle + original_draw = draw_host.draw + + def draw(renderer, *args, **kwargs): + _select_enveloping_connectors(indicator, inset, renderer) + return original_draw(renderer, *args, **kwargs) + + draw_host.draw = draw + + +@dataclass +class _HawkeyeSpec: + """ + Validated inputs for :meth:`GeoAxes.hawkeye`. + + ``extent_transform`` and ``relation`` are only fully resolved when ``extent`` + is not ``None`` (they require a geographic extent to normalize and infer); + otherwise they retain their raw defaults and are never consumed. ``aspect`` is + intentionally not stored here because ``'projection'`` can only be resolved + from the live inset axes (see :meth:`GeoAxes._build_hawkeye_inset`). When + ``anchor_transform`` is not ``None`` the ``anchor`` is a geographic/projected + point rather than an axes fraction; it is converted to a fraction against the + live inset view limits in :meth:`GeoAxes._build_hawkeye_inset`. + """ + + xy: tuple[float, float] + size: tuple[float, float] + anchor: tuple[float, float] + anchor_transform: Any + transform: Any + extent: Optional[tuple[float, float, float, float]] + extent_transform: Any + relation: str + connector: Optional[str] + shape: str + target: str + + +def _apply_hawkeye_circle_boundary(inset: "GeoAxes", aspect: str | float) -> None: + """Clip a hawkeye inset to a circular map boundary matching its view.""" + if aspect != "auto": + _square_hawkeye_view(inset) + x0, x1 = inset.get_xlim() + y0, y1 = inset.get_ylim() + radius_x = (x1 - x0) / 2 + radius_y = (y1 - y0) / 2 + theta = np.linspace(0, 2 * np.pi, 256) + circle_path = mpath.Path( + np.column_stack( + ( + (x0 + x1) / 2 + radius_x * np.cos(theta), + (y0 + y1) / 2 + radius_y * np.sin(theta), + ) + ) + ) + inset.set_boundary(circle_path, transform=inset.transData) + + +def _make_hawkeye_indicator_patch( + extent: Sequence[float], transform: Any, target: str, **kwargs: Any +) -> mpatches.Patch: + """Build the outline patch (box or circle) marking a hawkeye extent.""" + west, east, south, north = extent + if target == "circle": + return mpatches.Circle( + ((west + east) / 2, (south + north) / 2), + min(east - west, north - south) / 2, + transform=transform, + **kwargs, + ) + return mpatches.Rectangle( + (west, south), + east - west, + north - south, + transform=transform, + **kwargs, + ) # Format docstring @@ -235,6 +665,82 @@ """ docstring._snippet_manager["geo.format"] = _format_docstring +_hawkeye_docstring = """ +Add a transform-anchored geographic callout inset. + +Parameters +---------- +xy : 2-tuple of float + The parent-axes coordinate at which to anchor the inset. +size : float or 2-tuple of float + The requested inset width and height as fractions of the parent axes box. A scalar + requests equal width and height before geographic aspect adjustment. +transform : coordinate system, default: 'axes' + Coordinate system for *xy*. One of: + + * ``'axes'`` -- parent axes fractions (`~matplotlib.axes.Axes.transAxes`). + * ``'data'`` -- parent *projected* coordinates + (`~matplotlib.axes.Axes.transData`), i.e. the parent projection's native + units (metres for most projections). This coincides with longitude-latitude + only for a default `~cartopy.crs.PlateCarree` parent, so it is rarely what + you want on a map; use ``'map'`` for longitude-latitude. + * ``'figure'`` / ``'subfigure'`` -- figure or subfigure fractions. + * ``'map'`` -- longitude-latitude degrees (`~cartopy.crs.PlateCarree`). + * a projection name (e.g. ``'cyl'``, ``'moll'``), a `~cartopy.crs.Projection`, + or a `~matplotlib.transforms.Transform` -- *xy* in arbitrary projected + coordinates. +anchor : str or 2-tuple of float, default: 'upper right' + The inset point placed at *xy*. String aliases include ``'ul'``, ``'ur'``, + ``'ll'``, ``'lr'``, and ``'c'``. A float 2-tuple is interpreted in + `anchor_transform` coordinates. String aliases are always axes-relative. +anchor_transform : coordinate system, default: 'axes' + Coordinate system for a float-tuple `anchor`. ``'axes'`` (the default) reads + the anchor as inset axes fractions, matching string aliases. ``'map'``, a + projection name, or a `~cartopy.crs.Projection` reads the anchor as a + geographic/projected point, so a specific location on the inset map (e.g. + ``anchor=(103.8, 1.3), anchor_transform='map'``) is placed at *xy*. The point + must fall within the inset extent. Requires the cartopy backend. +aspect : {'auto', 'projection'} or float, default: 'projection' + The inset aspect. ``'projection'`` preserves the geographic projection aspect + inside the requested box, while ``'auto'`` stretches the map to fill that box. + Circular insets expand the shorter projected dimension to avoid distorting the + projection. +grid : bool, default: False + Whether to draw gridlines in the inset. +extent : 4-tuple of float, optional + The geographic scope ``(west, east, south, north)`` displayed by the inset. +extent_transform : coordinate system, default: 'map' + Coordinate system for *extent*. ``'map'`` uses `~cartopy.crs.PlateCarree` + (longitude-latitude). A projection name (e.g. ``'cyl'``) or a + `~cartopy.crs.Projection` is also accepted. +relation : {'auto', 'detail', 'overview'}, default: 'auto' + Whether the inset is a zoomed detail of the parent or an overview containing the + parent extent. ``'auto'`` compares the rectangular extent sizes. This determines + where the extent outline and connectors are drawn. +indicator : bool, default: True + Whether to outline *extent* on the parent map when an extent is supplied. +connector : {False, True, 'corners', 'line'}, default: False + The connector presentation. ``True`` and ``'corners'`` draw corner links between + the extent outline and inset. ``'line'`` draws one leader from the inset boundary + to the target centre. Requires *extent*. +shape, target : {'box', 'circle'}, default: 'box' + The inset clipping shape and target marker shape. Circular targets require + ``connector='line'`` or no connector. +indicator_kw : dict-like, optional + Patch properties for the extent outline and connector lines. + +Other parameters +---------------- +**kwargs + Passed to `~Axes.inset_axes`. + +Returns +------- +GeoAxes + The geographic inset axes. +""" +docstring._snippet_manager["geo.hawkeye"] = _hawkeye_docstring + _choropleth_docstring = """ Draw polygon geometries colored by numeric values. @@ -1085,6 +1591,212 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._edge_lat_labels: list[mtext.Text] = [] super().__init__(*args, **kwargs) + @docstring._snippet_manager + def hawkeye( + self, + xy: Sequence[float], + size: float | Sequence[float], + *, + transform: Any = "axes", + anchor: str | Sequence[float] = "upper right", + anchor_transform: Any = "axes", + aspect: str | float = "projection", + extent: Optional[Sequence[float]] = None, + extent_transform: Any = "map", + relation: str = "auto", + indicator: bool = True, + connector: bool | str = False, + shape: str = "box", + target: str = "box", + indicator_kw: Optional[Mapping[str, Any]] = None, + **kwargs: Any, + ) -> "GeoAxes": + """ + %(geo.hawkeye)s + """ + spec = self._resolve_hawkeye_spec( + xy, + size, + transform, + anchor, + anchor_transform, + extent, + extent_transform, + relation, + connector, + shape, + target, + ) + kwargs.setdefault("grid", False) + inset = self._build_hawkeye_inset(spec, aspect, **kwargs) + inset._hawkeye_relation = spec.relation + if indicator and spec.extent is not None: + color = kwargs.get("color", rc["axes.edgecolor"]) + self._add_hawkeye_indicator(inset, spec, indicator_kw, color) + return inset + + def _resolve_hawkeye_spec( + self, + xy: Sequence[float], + size: float | Sequence[float], + transform: Any, + anchor: str | Sequence[float], + anchor_transform: Any, + extent: Optional[Sequence[float]], + extent_transform: Any, + relation: str, + connector: bool | str, + shape: str, + target: str, + ) -> "_HawkeyeSpec": + """Validate and normalize raw hawkeye arguments into a :class:`_HawkeyeSpec`.""" + if len(xy) != 2: + raise ValueError(f"xy must have length 2, got {xy!r}.") + size = _parse_hawkeye_size(size) + anchor_transform = _parse_hawkeye_anchor_transform(anchor_transform) + if anchor_transform is not None and self._name != "cartopy": + raise NotImplementedError( + "hawkeye(anchor_transform=...) currently requires the cartopy backend." + ) + anchor = _parse_hawkeye_anchor(anchor, axes_relative=anchor_transform is None) + transform = self._resolve_hawkeye_xy_transform(transform) + connector = _parse_hawkeye_connector(connector) + shape = _parse_hawkeye_shape(shape, "shape") + target = _parse_hawkeye_shape(target, "target") + if connector and extent is None: + raise ValueError("connector requires extent.") + if connector == "corners" and target == "circle": + raise ValueError("target='circle' requires connector='line' or False.") + if relation not in ("auto", "detail", "overview"): + raise ValueError("relation must be 'auto', 'detail', or 'overview'.") + if extent is not None: + if self._name != "cartopy": + raise NotImplementedError( + "hawkeye(extent=...) currently requires the cartopy backend." + ) + if len(extent) != 4: + raise ValueError(f"extent must have length 4, got {extent!r}.") + west, east, south, north = extent + if west >= east or south >= north: + raise ValueError( + "extent must be ordered as (west, east, south, north) with " + "west < east and south < north." + ) + extent = tuple(extent) + extent_transform = _parse_hawkeye_extent_transform(extent_transform) + if relation == "auto": + relation = _infer_hawkeye_relation( + self.get_extent(crs=extent_transform), extent + ) + return _HawkeyeSpec( + xy=tuple(xy), + size=size, + anchor=anchor, + anchor_transform=anchor_transform, + transform=transform, + extent=extent, + extent_transform=extent_transform, + relation=relation, + connector=connector, + shape=shape, + target=target, + ) + + def _resolve_hawkeye_xy_transform(self, transform: Any) -> Any: + """Resolve the *xy* transform, accepting projection names. + + Reserved names (``'axes'``, ``'data'``, ``'figure'``, ``'subfigure'``, + ``'map'``), matplotlib transforms, and cartopy CRS instances are handled + by :meth:`_get_transform`. Any other string is treated as a projection + name and resolved to a cartopy CRS so *xy* can be given in arbitrary + projected coordinates. + """ + if isinstance(transform, str) and transform not in _HAWKEYE_TRANSFORM_NAMES: + transform = _hawkeye_crs_from_name(transform) + return self._get_transform(transform, default="axes") + + def _build_hawkeye_inset( + self, spec: "_HawkeyeSpec", aspect: str | float, **kwargs: Any + ) -> "GeoAxes": + """Create the inset axes and configure its extent, aspect, and boundary.""" + inset = self._add_inset_axes((0, 0, *spec.size), transform="axes", **kwargs) + if spec.extent is not None: + if isinstance(spec.extent_transform, ccrs.PlateCarree) and np.allclose( + spec.extent, (-180, 180, -90, 90) + ): + inset.set_global() + else: + inset.set_extent(spec.extent, crs=spec.extent_transform) + if aspect == "projection": + aspect = inset.get_aspect() + inset.set_aspect(aspect) + # Resolve the anchor to a fraction against the final view limits, which a + # circular boundary may adjust, before pinning it with set_anchor/locator. + if spec.shape == "circle": + _apply_hawkeye_circle_boundary(inset, aspect) + anchor = _hawkeye_anchor_fraction(inset, spec.anchor, spec.anchor_transform) + inset.set_anchor(anchor) + inset.set_axes_locator( + _AnchoredInsetLocator( + self, + spec.xy, + spec.size, + spec.transform, + anchor, + square=spec.shape == "circle", + ) + ) + return inset + + def _add_hawkeye_indicator( + self, + inset: "GeoAxes", + spec: "_HawkeyeSpec", + indicator_kw: Optional[Mapping[str, Any]], + color: Any, + ) -> None: + """Draw the extent indicator patch and any connectors onto the hawkeye.""" + indicator_kw = dict(indicator_kw or {}) + indicator_kw.setdefault("edgecolor", color) + indicator_kw.setdefault("facecolor", "none") + indicator_kw.setdefault("linewidth", rc["axes.linewidth"]) + indicator_kw.setdefault("zorder", 3.5) + if spec.connector == "corners" and spec.relation == "detail": + # matplotlib's indicate_inset_zoom always draws a box outline, so + # ``spec.target`` cannot be honored here; the box/circle guard in + # _resolve_hawkeye_spec rejects target='circle' with corner connectors. + indicator = _add_hawkeye_zoom_indicator(self, inset, **indicator_kw) + _envelop_hawkeye_zoom_connectors(indicator, inset) + inset._hawkeye_indicator = indicator + return + outline_axes = self if spec.relation == "detail" else inset + outline_extent = ( + spec.extent + if spec.relation == "detail" + else self.get_extent(crs=spec.extent_transform) + ) + patch = _make_hawkeye_indicator_patch( + outline_extent, spec.extent_transform, spec.target, **indicator_kw + ) + outline_axes.add_patch(patch) + inset._hawkeye_indicator = patch + if spec.connector == "corners": + inset._hawkeye_connectors = _add_hawkeye_overview_connectors( + self, inset, outline_extent, spec.extent_transform, **indicator_kw + ) + elif spec.connector == "line": + west, east, south, north = outline_extent + inset._hawkeye_connectors = ( + _add_hawkeye_leader( + inset, + outline_axes, + ((west + east) / 2, (south + north) / 2), + spec.extent_transform, + target_patch=patch, + **indicator_kw, + ), + ) + @override def _sharey_limits(self, sharey: "GeoAxes") -> None: return self._share_limits_with(sharey, which="y") diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index 7d5931f13..590ae9386 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -7,6 +7,7 @@ import pytest import matplotlib.patheffects as mpatheffects import matplotlib.text as mtext +import matplotlib.transforms as mtransforms import ultraplot as uplt from ultraplot.internals.warnings import UltraPlotWarning @@ -501,6 +502,37 @@ def test_inset_zoom_update(): return fig +@pytest.mark.parametrize("transform", ["figure", "axes"]) +def test_inset_axes_anchor_coordinates_transform(transform): + """Inset anchors may package their coordinates with a named transform.""" + fig, ax = uplt.subplots() + bounds = (0.1, 0.2, 0.3, 0.4) + ix = ax.inset_axes((bounds, transform), zoom=False) + fig.canvas.draw() + + expected = bounds + if transform == "axes": + expected = ( + ax.transAxes.transform_bbox(mtransforms.Bbox.from_bounds(*bounds)) + .transformed(fig.transFigure.inverted()) + .bounds + ) + assert np.allclose(ix.get_position().bounds, expected) + + +def test_parse_anchor_accepts_coordinates_and_transform(): + """Separate and packaged anchor inputs resolve to the same transform.""" + fig, ax = uplt.subplots() + ax = ax[0] + bounds = (0.1, 0.2, 0.3, 0.4) + coords, transform = ax._parse_anchor(bounds, "figure") + assert coords == bounds + assert transform is fig.transFigure + coords, transform = ax._parse_anchor((bounds, fig.transFigure)) + assert coords == bounds + assert transform is fig.transFigure + + @pytest.mark.mpl_image_compare def test_panels_with_sharing(): """ diff --git a/ultraplot/tests/test_inset.py b/ultraplot/tests/test_inset.py index 9a1dfc611..a0b8a52a9 100644 --- a/ultraplot/tests/test_inset.py +++ b/ultraplot/tests/test_inset.py @@ -1,4 +1,8 @@ -import ultraplot as uplt, numpy as np, pytest +import numpy as np +import pytest +from matplotlib import patches as mpatches + +import ultraplot as uplt @pytest.mark.mpl_image_compare @@ -27,3 +31,342 @@ def test_inset_basic(): titleabove=False, ) return fig + + +def test_geo_inset_preserves_bounds_anchor(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + parent = ax[0] + inset = parent.inset((0.1, 0.2, 0.4, 0.2), transform="axes") + fig.canvas.draw() + + expected = fig.transFigure.inverted().transform( + parent.transAxes.transform((0.1, 0.2)) + ) + actual = inset.get_position() + np.testing.assert_allclose((actual.x0, actual.y0), expected) + assert actual.width < 0.4 * parent.get_position().width + uplt.close(fig) + + +def test_hawkeye_projection_and_auto_aspects(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + parent = ax[0] + projected = parent.hawkeye((0.9, 0.9), size=(0.2, 0.2)) + stretched = parent.hawkeye((0.9, 0.6), size=0.2, aspect="auto") + fig.canvas.draw() + + projected_bbox = projected.get_position() + stretched_bbox = stretched.get_position() + np.testing.assert_allclose( + (stretched_bbox.width, stretched_bbox.height), + (0.2 * parent.get_position().width, 0.2 * parent.get_position().height), + ) + assert projected_bbox.width != pytest.approx(projected_bbox.height) + uplt.close(fig) + + +def test_hawkeye_extent_and_connectors(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + inset = ax[0].hawkeye( + (0.9, 0.9), + size=0.2, + extent=(120, 180, 10, 30), + connector=True, + ) + fig.canvas.draw() + + assert inset._hawkeye_relation == "detail" + assert len(inset._hawkeye_indicator.connectors) == 4 + uplt.close(fig) + + +def test_hawkeye_corner_connectors_envelop_inset() -> None: + pytest.importorskip("cartopy.crs") + + # Inset lower-left, indicator extent upper-right: the enveloping pair is the + # upper-left and lower-right connectors (indices 1 and 2), not a parallel pair. + fig, ax = uplt.subplots(proj="cyl") + inset = ax[0].hawkeye( + (0.03, 0.03), size=0.2, anchor="ll", extent=(110, 160, -45, 0), connector=True + ) + fig.canvas.draw() + visible = [bool(c.get_visible()) for c in inset._hawkeye_indicator.connectors] + assert visible == [False, True, True, False] + uplt.close(fig) + + # Opposite diagonal (inset upper-left, extent lower-right): the enveloping pair + # is the lower-left and upper-right connectors (indices 0 and 3). + fig, ax = uplt.subplots(proj="cyl") + inset = ax[0].hawkeye( + (0.03, 0.97), size=0.2, anchor="ul", extent=(60, 110, -60, -20), connector=True + ) + fig.canvas.draw() + visible = [bool(c.get_visible()) for c in inset._hawkeye_indicator.connectors] + assert visible == [True, False, False, True] + uplt.close(fig) + + +def test_hawkeye_auto_relation_handles_disjoint_detail_extent(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + ax[0].set_extent((30, 100, -20, 30)) + inset = ax[0].hawkeye((0.9, 0.9), size=0.2, extent=(120, 180, 10, 30)) + + assert inset._hawkeye_relation == "detail" + uplt.close(fig) + + +def test_hawkeye_connectors_require_extent(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + with pytest.raises(ValueError, match="requires extent"): + ax[0].hawkeye((0.9, 0.9), size=0.2, connector=True) + uplt.close(fig) + + +def test_hawkeye_circular_cutout_and_leader(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + inset = ax[0].hawkeye( + (0.9, 0.9), + size=0.2, + extent=(120, 180, 10, 30), + connector="line", + shape="circle", + target="circle", + ) + fig.canvas.draw() + + assert isinstance(inset._hawkeye_indicator, mpatches.Circle) + assert len(inset._hawkeye_connectors) == 1 + connector = inset._hawkeye_connectors[0] + assert connector.patchA is inset.patch + assert connector.patchB is inset._hawkeye_indicator + assert connector.axes is ax[0] + assert inset.get_in_layout() + bbox = inset.get_position() + figure_bbox = fig.bbox + assert bbox.width * figure_bbox.width == pytest.approx( + bbox.height * figure_bbox.height + ) + assert inset.get_aspect() == 1 + assert not inset._gridlines_major.xline_artists + assert not inset._gridlines_major.yline_artists + uplt.close(fig) + + +def test_hawkeye_outside_parent_claims_layout_space(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + inset = ax[0].hawkeye( + (0.97, 0.97), + size=0.25, + anchor="c", + extent=(120, 180, 10, 30), + shape="circle", + target="circle", + ) + fig.canvas.draw() + + # The inset protrudes past the parent axes; automatic layout must reserve + # space for it so it is not clipped at the figure edge. + bbox = inset.get_position() + assert bbox.x1 <= 1 + assert bbox.y1 <= 1 + uplt.close(fig) + + +def test_hawkeye_overview_connectors(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + parent = ax[0] + parent.set_extent((145, 155, -38, -32)) + inset = parent.hawkeye( + (0.9, 0.9), + size=0.2, + extent=(110, 180, -50, 0), + connector=True, + ) + fig.canvas.draw() + + assert inset._hawkeye_relation == "overview" + assert inset._hawkeye_indicator in inset.patches + assert len(inset._hawkeye_connectors) == 2 + uplt.close(fig) + + +def test_hawkeye_overview_indicator(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + parent = ax[0] + parent.set_extent((145, 155, -38, -32)) + inset = parent.hawkeye( + (0.9, 0.9), size=0.2, extent=(110, 180, -50, 0), relation="overview" + ) + fig.canvas.draw() + + assert inset._hawkeye_indicator in inset.patches + assert inset._hawkeye_indicator not in parent.patches + uplt.close(fig) + + +def test_hawkeye_transform_accepts_projection_name(): + ccrs = pytest.importorskip("cartopy.crs") + + # A projection name resolves to a CRS so xy can be given in projected + # coordinates; the result matches passing the equivalent CRS instance. + fig, ax = uplt.subplots(proj="cyl") + by_name = ax[0].hawkeye((10, 20), size=0.2, transform="cyl") + by_crs = ax[0].hawkeye((10, 20), size=0.2, transform=ccrs.PlateCarree()) + fig.canvas.draw() + np.testing.assert_allclose( + by_name.get_position().bounds, by_crs.get_position().bounds + ) + uplt.close(fig) + + +def test_hawkeye_extent_transform_accepts_projection_name(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + inset = ax[0].hawkeye( + (0.9, 0.9), size=0.2, extent=(120, 180, 10, 30), extent_transform="cyl" + ) + fig.canvas.draw() + assert inset._hawkeye_relation == "detail" + uplt.close(fig) + + +def test_hawkeye_geographic_anchor_pins_point(): + ccrs = pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + parent = ax[0] + # Pin a specific lon/lat point on the inset map to xy on the parent. + xy = (0.5, 0.5) + lonlat = (103.8, 1.3) + inset = parent.hawkeye( + xy, size=0.3, extent=(90, 120, 0, 30), anchor=lonlat, anchor_transform="map" + ) + fig.canvas.draw() + + projected = inset.projection.transform_point(*lonlat, ccrs.PlateCarree()) + display = inset.transData.transform(projected) + fraction = parent.transAxes.inverted().transform(display) + np.testing.assert_allclose(fraction, xy, atol=1e-6) + uplt.close(fig) + + +def test_hawkeye_geographic_anchor_outside_extent_errors(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + with pytest.raises(ValueError, match="outside the inset extent"): + ax[0].hawkeye( + (0.5, 0.5), + size=0.3, + extent=(90, 120, 0, 30), + anchor=(0, 0), + anchor_transform="map", + ) + uplt.close(fig) + + +def test_hawkeye_string_anchor_rejects_anchor_transform(): + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + with pytest.raises(ValueError, match="String anchors are axes-relative"): + ax[0].hawkeye( + (0.5, 0.5), + size=0.3, + extent=(90, 120, 0, 30), + anchor="ul", + anchor_transform="map", + ) + uplt.close(fig) + + +def test_hawkeye_zoom_indicator_normalizes_legacy_tuple(monkeypatch) -> None: + """The corners+detail indicator exposes ``.connectors`` on every mpl version. + + matplotlib < 3.10 returns a ``(rectangle, connectors)`` tuple from + ``indicate_inset_zoom`` instead of an ``InsetIndicator``; the helper must + normalize both to something exposing ``.rectangle`` / ``.connectors``. + """ + import matplotlib.axes as maxes + from ultraplot.axes import geo + + fig, ax = uplt.subplots() + inset = fig.add_axes([0.6, 0.6, 0.3, 0.3]) + + # Force the pre-3.10 tuple return regardless of the installed matplotlib. + original = maxes.Axes.indicate_inset_zoom + + def legacy(self, inset_ax, **kwargs): + indicator = original(self, inset_ax, **kwargs) + if isinstance(indicator, tuple): + return indicator + return indicator.rectangle, tuple(indicator.connectors) + + monkeypatch.setattr(maxes.Axes, "indicate_inset_zoom", legacy) + normalized = geo._add_hawkeye_zoom_indicator(ax[0], inset) + assert hasattr(normalized, "rectangle") + assert len(normalized.connectors) == 4 + uplt.close(fig) + + +def test_hawkeye_indicator_disabled() -> None: + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + inset = ax[0].hawkeye( + (0.9, 0.9), size=0.2, extent=(120, 180, 10, 30), indicator=False + ) + + # Relation is still resolved, but no indicator artist is drawn. + assert inset._hawkeye_relation == "detail" + assert not hasattr(inset, "_hawkeye_indicator") + uplt.close(fig) + + +def test_hawkeye_overview_leader() -> None: + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + parent = ax[0] + parent.set_extent((145, 155, -38, -32)) + inset = parent.hawkeye( + (0.9, 0.9), + size=0.2, + extent=(110, 180, -50, 0), + connector="line", + relation="overview", + ) + fig.canvas.draw() + + assert inset._hawkeye_relation == "overview" + assert inset._hawkeye_indicator in inset.patches + assert len(inset._hawkeye_connectors) == 1 + uplt.close(fig) + + +def test_hawkeye_invalid_relation_raises() -> None: + pytest.importorskip("cartopy.crs") + + fig, ax = uplt.subplots(proj="cyl") + with pytest.raises(ValueError, match="relation must be"): + ax[0].hawkeye((0.9, 0.9), size=0.2, relation="sideways") + uplt.close(fig)