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..8da6e6409 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -750,6 +750,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.""" @@ -1058,8 +1072,9 @@ def _add_inset_axes( Add an inset axes using arbitrary projection. """ # Converting transform to figure-relative coordinates + bounds_input = bounds transform = self._get_transform(transform, "axes") - locator = self._make_inset_locator(bounds, transform) + 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 +1092,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 diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 4df8ff08f..4efe06a25 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,350 @@ _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, + ) + + +def _parse_hawkeye_anchor(anchor): + """Translate a named hawkeye anchor to normalized axes coordinates.""" + if isinstance(anchor, str): + 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 _parse_hawkeye_extent_transform(transform): + """Translate hawkeye extent transforms to cartopy coordinate systems.""" + if transform == "map": + return ccrs.PlateCarree() + if isinstance(transform, ccrs.CRS): + return transform + raise ValueError("extent_transform must be 'map' or a cartopy CRS.") + + +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`). + """ + + xy: tuple[float, float] + size: tuple[float, float] + anchor: tuple[float, float] + 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 @@ -237,6 +585,60 @@ """ 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 : {'axes', 'data', 'figure', 'subfigure', 'map'} or transform, default: 'axes' + Coordinate system for *xy*. ``'map'`` uses `cartopy.crs.PlateCarree`. +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'``. +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 : {'map'} or cartopy CRS, default: 'map' + Coordinate system for *extent*. ``'map'`` uses `cartopy.crs.PlateCarree`. +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. @@ -1087,6 +1489,190 @@ 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", + 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, + 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], + 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 = _parse_hawkeye_anchor(anchor) + transform = self._get_transform(transform, default="axes") + 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, + transform=transform, + extent=extent, + extent_transform=extent_transform, + relation=relation, + connector=connector, + shape=shape, + target=target, + ) + + 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) + # Hawkeyes may intentionally extend outside their parent axes. They must not + # claim that space when the figure performs automatic layout. + inset.set_in_layout(False) + 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) + inset.set_anchor(spec.anchor) + inset.set_axes_locator( + _AnchoredInsetLocator( + self, + spec.xy, + spec.size, + spec.transform, + spec.anchor, + square=spec.shape == "circle", + ) + ) + if spec.shape == "circle": + _apply_hawkeye_circle_boundary(inset, aspect) + 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_inset.py b/ultraplot/tests/test_inset.py index 9a1dfc611..875a9cf87 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,243 @@ 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 not 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_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_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)