From d152dcb41d1224327ec8f043121c55040c0c4d8f Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 16:57:30 +1000 Subject: [PATCH 1/6] Add circular inset options --- docs/insets_panels.py | 76 ++++++ ultraplot/axes/base.py | 19 +- ultraplot/axes/geo.py | 438 ++++++++++++++++++++++++++++++++++ ultraplot/tests/test_inset.py | 147 +++++++++++- 4 files changed, 678 insertions(+), 2 deletions(-) diff --git a/docs/insets_panels.py b/docs/insets_panels.py index 03f9d6cbd..c4afbbe8b 100644 --- a/docs/insets_panels.py +++ b/docs/insets_panels.py @@ -90,6 +90,8 @@ # %% import ultraplot as uplt import numpy as np +from cartopy import crs as ccrs +import osmnx as ox state = np.random.RandomState(51423) data = (state.rand(20, 20) - 0.48).cumsum(axis=1).cumsum(axis=0) @@ -192,3 +194,77 @@ ) 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 example downloads and caches `OpenStreetMap +# `__ street geometry with `OSMnx +# `__. Its first render requires network access. The +# OpenStreetMap attribution must be retained when adapting this example. + +# %% +singapore = (103.8198, 1.3521) +ox.settings.use_cache = True +network = ox.graph_from_point( + (singapore[1], singapore[0]), dist=8_000, network_type="drive" +) +streets = ox.graph_to_gdfs(network, nodes=False) +major_roads = streets[ + streets["highway"].isin(("motorway", "trunk", "primary", "secondary", "tertiary")) +] +minor_roads = streets.drop(index=major_roads.index).iloc[::4] +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", + connectors="line", + color="red", + indicator_kw={"linewidth": 1.5}, +) +inax.format(land=True, landcolor="gray9", oceancolor="blue9") +minor_roads.plot( + ax=inax, color="gray6", alpha=0.65, linewidth=0.22, transform=ccrs.PlateCarree() +) +major_roads.plot(ax=inax, color="white", linewidth=1.25, transform=ccrs.PlateCarree()) +major_roads.plot(ax=inax, color="gray2", linewidth=0.6, transform=ccrs.PlateCarree()) +inax.plot( + *singapore, + marker="o", + color="red", + markeredgecolor="white", + markeredgewidth=0.8, + ms=5, + transform="cyl", +) +fig.text(0.99, 0.01, "© OpenStreetMap contributors", ha="right", size=6) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index e32159b18..4a2df1fd2 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.""" @@ -1057,8 +1071,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) @@ -1076,6 +1091,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 d364e1591..344c5e128 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -8,6 +8,7 @@ import copy import inspect from functools import partial +from numbers import Real try: # From python 3.12 @@ -19,6 +20,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 +73,232 @@ _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_connectors(connectors): + """Normalize connector shorthand to a named presentation mode.""" + if connectors is False: + return None + if connectors is True: + return "corners" + if connectors in ("corners", "line"): + return connectors + raise ValueError("connectors 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 # Format docstring @@ -228,6 +456,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. +connectors : {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 + ``connectors='line'`` or no connectors. +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. @@ -1078,6 +1360,162 @@ 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, + connectors: bool | str = False, + shape: str = "box", + target: str = "box", + indicator_kw: Optional[Mapping[str, Any]] = None, + **kwargs: Any, + ) -> "GeoAxes": + """ + %(geo.hawkeye)s + """ + 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") + connectors = _parse_hawkeye_connectors(connectors) + shape = _parse_hawkeye_shape(shape, "shape") + target = _parse_hawkeye_shape(target, "target") + if connectors and extent is None: + raise ValueError("connectors requires extent.") + if connectors == "corners" and target == "circle": + raise ValueError("target='circle' requires connectors='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_transform = _parse_hawkeye_extent_transform(extent_transform) + if relation == "auto": + relation = _infer_hawkeye_relation( + self.get_extent(crs=extent_transform), extent + ) + kwargs.setdefault("grid", False) + inset = self._add_inset_axes((0, 0, *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 extent is not None: + if isinstance(extent_transform, ccrs.PlateCarree) and np.allclose( + extent, (-180, 180, -90, 90) + ): + inset.set_global() + else: + inset.set_extent(extent, crs=extent_transform) + if aspect == "projection": + aspect = inset.get_aspect() + inset.set_aspect(aspect) + inset.set_anchor(anchor) + inset.set_axes_locator( + _AnchoredInsetLocator( + self, + xy, + size, + transform, + anchor, + square=shape == "circle", + ) + ) + if shape == "circle": + 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) + inset._hawkeye_relation = relation + if indicator and extent is not None: + indicator_kw = dict(indicator_kw or {}) + indicator_kw.setdefault( + "edgecolor", kwargs.get("color", rc["axes.edgecolor"]) + ) + indicator_kw.setdefault("facecolor", "none") + indicator_kw.setdefault("linewidth", rc["axes.linewidth"]) + indicator_kw.setdefault("zorder", 3.5) + if connectors == "corners" and relation == "detail": + inset._hawkeye_indicator = maxes.Axes.indicate_inset_zoom( + self, inset, **indicator_kw + ) + else: + outline_axes = self if relation == "detail" else inset + outline_extent = ( + extent + if relation == "detail" + else self.get_extent(crs=extent_transform) + ) + west, east, south, north = outline_extent + if target == "circle": + patch = mpatches.Circle( + ((west + east) / 2, (south + north) / 2), + min(east - west, north - south) / 2, + transform=extent_transform, + **indicator_kw, + ) + else: + patch = mpatches.Rectangle( + (west, south), + east - west, + north - south, + transform=extent_transform, + **indicator_kw, + ) + outline_axes.add_patch(patch) + inset._hawkeye_indicator = patch + if connectors == "corners": + inset._hawkeye_connectors = _add_hawkeye_overview_connectors( + self, + inset, + outline_extent, + extent_transform, + **indicator_kw, + ) + elif connectors == "line": + inset._hawkeye_connectors = ( + _add_hawkeye_leader( + inset, + outline_axes, + ((west + east) / 2, (south + north) / 2), + extent_transform, + target_patch=patch, + **indicator_kw, + ), + ) + return inset + @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..96ae54626 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,144 @@ 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), + connectors=True, + ) + fig.canvas.draw() + + assert inset._hawkeye_relation == "detail" + assert len(inset._hawkeye_indicator.connectors) == 4 + 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, connectors=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), + connectors="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), + connectors=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) From be490f12dd949e231bbce6a49bf873e18b2667d6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 17:31:13 +1000 Subject: [PATCH 2/6] Decompose the monolithic GeoAxes.hawkeye method into focused pieces The method previously handled argument validation, inset construction, and indicator drawing in one long block. It now delegates to a _HawkeyeSpec dataclass carrying the normalized inputs and three narrow helpers: _resolve_hawkeye_spec validates and normalizes the arguments, _build_hawkeye_inset creates and geometrically configures the inset, and _add_hawkeye_indicator draws the extent indicator and connectors. The circular-boundary and indicator-patch logic move into stateless module helpers. Behavior is unchanged; the hawkeye tests gain coverage of the indicator-disabled path, the overview leader connector, and the invalid-relation guard. --- ultraplot/axes/geo.py | 264 +++++++++++++++++++++++----------- ultraplot/tests/test_inset.py | 44 ++++++ 2 files changed, 223 insertions(+), 85 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 308ac446a..15e3101dd 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -7,6 +7,7 @@ import copy import inspect +from dataclasses import dataclass from functools import partial from numbers import Real @@ -301,6 +302,71 @@ def _add_hawkeye_leader( return connector +@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 + connectors: 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 _format_docstring = """ aspect : {'auto', 'equal'} or float, optional @@ -1391,6 +1457,40 @@ def hawkeye( """ %(geo.hawkeye)s """ + spec = self._resolve_hawkeye_spec( + xy, + size, + transform, + anchor, + extent, + extent_transform, + relation, + connectors, + 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, + connectors: 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) @@ -1418,112 +1518,106 @@ def hawkeye( "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 ) - kwargs.setdefault("grid", False) - inset = self._add_inset_axes((0, 0, *size), transform="axes", **kwargs) + return _HawkeyeSpec( + xy=tuple(xy), + size=size, + anchor=anchor, + transform=transform, + extent=extent, + extent_transform=extent_transform, + relation=relation, + connectors=connectors, + 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 extent is not None: - if isinstance(extent_transform, ccrs.PlateCarree) and np.allclose( - extent, (-180, 180, -90, 90) + 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(extent, crs=extent_transform) + inset.set_extent(spec.extent, crs=spec.extent_transform) if aspect == "projection": aspect = inset.get_aspect() inset.set_aspect(aspect) - inset.set_anchor(anchor) + inset.set_anchor(spec.anchor) inset.set_axes_locator( _AnchoredInsetLocator( self, - xy, - size, - transform, - anchor, - square=shape == "circle", + spec.xy, + spec.size, + spec.transform, + spec.anchor, + square=spec.shape == "circle", ) ) - if shape == "circle": - 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), - ) - ) + 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.connectors == "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. + inset._hawkeye_indicator = maxes.Axes.indicate_inset_zoom( + self, inset, **indicator_kw + ) + 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.connectors == "corners": + inset._hawkeye_connectors = _add_hawkeye_overview_connectors( + self, inset, outline_extent, spec.extent_transform, **indicator_kw ) - inset.set_boundary(circle_path, transform=inset.transData) - inset._hawkeye_relation = relation - if indicator and extent is not None: - indicator_kw = dict(indicator_kw or {}) - indicator_kw.setdefault( - "edgecolor", kwargs.get("color", rc["axes.edgecolor"]) + elif spec.connectors == "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, + ), ) - indicator_kw.setdefault("facecolor", "none") - indicator_kw.setdefault("linewidth", rc["axes.linewidth"]) - indicator_kw.setdefault("zorder", 3.5) - if connectors == "corners" and relation == "detail": - inset._hawkeye_indicator = maxes.Axes.indicate_inset_zoom( - self, inset, **indicator_kw - ) - else: - outline_axes = self if relation == "detail" else inset - outline_extent = ( - extent - if relation == "detail" - else self.get_extent(crs=extent_transform) - ) - west, east, south, north = outline_extent - if target == "circle": - patch = mpatches.Circle( - ((west + east) / 2, (south + north) / 2), - min(east - west, north - south) / 2, - transform=extent_transform, - **indicator_kw, - ) - else: - patch = mpatches.Rectangle( - (west, south), - east - west, - north - south, - transform=extent_transform, - **indicator_kw, - ) - outline_axes.add_patch(patch) - inset._hawkeye_indicator = patch - if connectors == "corners": - inset._hawkeye_connectors = _add_hawkeye_overview_connectors( - self, - inset, - outline_extent, - extent_transform, - **indicator_kw, - ) - elif connectors == "line": - inset._hawkeye_connectors = ( - _add_hawkeye_leader( - inset, - outline_axes, - ((west + east) / 2, (south + north) / 2), - extent_transform, - target_patch=patch, - **indicator_kw, - ), - ) - return inset @override def _sharey_limits(self, sharey: "GeoAxes") -> None: diff --git a/ultraplot/tests/test_inset.py b/ultraplot/tests/test_inset.py index 96ae54626..d0eab6eb2 100644 --- a/ultraplot/tests/test_inset.py +++ b/ultraplot/tests/test_inset.py @@ -172,3 +172,47 @@ def test_hawkeye_overview_indicator(): assert inset._hawkeye_indicator in inset.patches assert inset._hawkeye_indicator not in parent.patches 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), + connectors="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) From 8593dee8840c906fff6df6ff532ea2c43ae8ea5d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 17:32:15 +1000 Subject: [PATCH 3/6] Normalize the hawkeye zoom indicator across matplotlib versions matplotlib 3.10 changed Axes.indicate_inset_zoom to return an InsetIndicator artist exposing .rectangle and .connectors, whereas earlier versions such as 3.9 return a plain (rectangle, connectors) tuple. The corners-plus-detail hawkeye path stored that raw return on the inset, so accessing .connectors raised AttributeError on matplotlib 3.9. A new _add_hawkeye_zoom_indicator helper wraps the legacy tuple in a simple namespace so callers can rely on the same .rectangle and .connectors accessors on every supported version. A test forces the legacy tuple return to exercise the normalization on newer matplotlib as well. --- ultraplot/axes/geo.py | 17 ++++++++++++++++- ultraplot/tests/test_inset.py | 27 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 15e3101dd..182e3fe1c 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -10,6 +10,7 @@ from dataclasses import dataclass from functools import partial from numbers import Real +from types import SimpleNamespace try: # From python 3.12 @@ -302,6 +303,20 @@ def _add_hawkeye_leader( 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 + + @dataclass class _HawkeyeSpec: """ @@ -1587,7 +1602,7 @@ def _add_hawkeye_indicator( # 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. - inset._hawkeye_indicator = maxes.Axes.indicate_inset_zoom( + inset._hawkeye_indicator = _add_hawkeye_zoom_indicator( self, inset, **indicator_kw ) return diff --git a/ultraplot/tests/test_inset.py b/ultraplot/tests/test_inset.py index d0eab6eb2..cc108add5 100644 --- a/ultraplot/tests/test_inset.py +++ b/ultraplot/tests/test_inset.py @@ -174,6 +174,33 @@ def test_hawkeye_overview_indicator(): 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) + 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") From b4746c5faad60aa80e034ede36fc157bbf5521a7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 17:49:41 +1000 Subject: [PATCH 4/6] Envelop the hawkeye zoom connectors around the inset When corner connectors join a detail hawkeye to a diagonally placed inset, matplotlib's indicate_inset_zoom chose a parallel pair of connectors running along one side instead of the two outer-tangent connectors that wrap the inset, so the callout did not read as a zoom frustum. matplotlib fixes connector visibility only once, from a bounding-box rule, so a draw-time hook now recomputes the enveloping pair from the sign of the inset-to-indicator centre offset once the inset locator has resolved the final positions. This covers both the modern InsetIndicator and the legacy tuple return. A test pins the visible pair for both diagonal placements. --- ultraplot/axes/geo.py | 45 ++++++++++++++++++++++++++++++++--- ultraplot/tests/test_inset.py | 26 ++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 182e3fe1c..d0eb28c26 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -317,6 +317,45 @@ def _add_hawkeye_zoom_indicator(parent: "GeoAxes", inset: "GeoAxes", **kwargs: A 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: """ @@ -1602,9 +1641,9 @@ def _add_hawkeye_indicator( # 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. - inset._hawkeye_indicator = _add_hawkeye_zoom_indicator( - self, inset, **indicator_kw - ) + 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 = ( diff --git a/ultraplot/tests/test_inset.py b/ultraplot/tests/test_inset.py index cc108add5..dedd6ac92 100644 --- a/ultraplot/tests/test_inset.py +++ b/ultraplot/tests/test_inset.py @@ -86,6 +86,32 @@ def test_hawkeye_extent_and_connectors(): 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), connectors=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), connectors=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") From a04eb83c9c7e9d31babed225d25ae47dd7c2cf0a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 21:33:17 +1000 Subject: [PATCH 5/6] Rename the hawkeye connectors parameter to singular connector Every other hawkeye keyword is singular and the argument selects a single mode rather than a sequence, so plural was both inconsistent with its siblings and misleading about what it accepts. The public parameter, its docstring, the spec field, and the parsing helper are now singular, while the result attribute inset._hawkeye_connectors stays plural because it holds the collection of drawn connector lines. Since hawkeye is unreleased this needs no deprecation. --- docs/insets_panels.py | 2 +- ultraplot/axes/geo.py | 42 +++++++++++++++++------------------ ultraplot/tests/test_inset.py | 14 ++++++------ 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/docs/insets_panels.py b/docs/insets_panels.py index 7ce445a16..e56010049 100644 --- a/docs/insets_panels.py +++ b/docs/insets_panels.py @@ -249,7 +249,7 @@ extent=(103.76, 103.90, 1.27, 1.41), shape="circle", target="circle", - connectors="line", + connector="line", color="red", indicator_kw={"linewidth": 1.5}, ) diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index d0eb28c26..4efe06a25 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -165,15 +165,15 @@ def _parse_hawkeye_extent_transform(transform): raise ValueError("extent_transform must be 'map' or a cartopy CRS.") -def _parse_hawkeye_connectors(connectors): +def _parse_hawkeye_connector(connector): """Normalize connector shorthand to a named presentation mode.""" - if connectors is False: + if connector is False: return None - if connectors is True: + if connector is True: return "corners" - if connectors in ("corners", "line"): - return connectors - raise ValueError("connectors must be False, True, 'corners', or 'line'.") + if connector in ("corners", "line"): + return connector + raise ValueError("connector must be False, True, 'corners', or 'line'.") def _parse_hawkeye_shape(value, name): @@ -375,7 +375,7 @@ class _HawkeyeSpec: extent: Optional[tuple[float, float, float, float]] extent_transform: Any relation: str - connectors: Optional[str] + connector: Optional[str] shape: str target: str @@ -617,13 +617,13 @@ def _make_hawkeye_indicator_patch( 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. -connectors : {False, True, 'corners', 'line'}, default: False +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 - ``connectors='line'`` or no connectors. + ``connector='line'`` or no connector. indicator_kw : dict-like, optional Patch properties for the extent outline and connector lines. @@ -1502,7 +1502,7 @@ def hawkeye( extent_transform: Any = "map", relation: str = "auto", indicator: bool = True, - connectors: bool | str = False, + connector: bool | str = False, shape: str = "box", target: str = "box", indicator_kw: Optional[Mapping[str, Any]] = None, @@ -1519,7 +1519,7 @@ def hawkeye( extent, extent_transform, relation, - connectors, + connector, shape, target, ) @@ -1540,7 +1540,7 @@ def _resolve_hawkeye_spec( extent: Optional[Sequence[float]], extent_transform: Any, relation: str, - connectors: bool | str, + connector: bool | str, shape: str, target: str, ) -> "_HawkeyeSpec": @@ -1550,13 +1550,13 @@ def _resolve_hawkeye_spec( size = _parse_hawkeye_size(size) anchor = _parse_hawkeye_anchor(anchor) transform = self._get_transform(transform, default="axes") - connectors = _parse_hawkeye_connectors(connectors) + connector = _parse_hawkeye_connector(connector) shape = _parse_hawkeye_shape(shape, "shape") target = _parse_hawkeye_shape(target, "target") - if connectors and extent is None: - raise ValueError("connectors requires extent.") - if connectors == "corners" and target == "circle": - raise ValueError("target='circle' requires connectors='line' or False.") + 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: @@ -1586,7 +1586,7 @@ def _resolve_hawkeye_spec( extent=extent, extent_transform=extent_transform, relation=relation, - connectors=connectors, + connector=connector, shape=shape, target=target, ) @@ -1637,7 +1637,7 @@ def _add_hawkeye_indicator( indicator_kw.setdefault("facecolor", "none") indicator_kw.setdefault("linewidth", rc["axes.linewidth"]) indicator_kw.setdefault("zorder", 3.5) - if spec.connectors == "corners" and spec.relation == "detail": + 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. @@ -1656,11 +1656,11 @@ def _add_hawkeye_indicator( ) outline_axes.add_patch(patch) inset._hawkeye_indicator = patch - if spec.connectors == "corners": + if spec.connector == "corners": inset._hawkeye_connectors = _add_hawkeye_overview_connectors( self, inset, outline_extent, spec.extent_transform, **indicator_kw ) - elif spec.connectors == "line": + elif spec.connector == "line": west, east, south, north = outline_extent inset._hawkeye_connectors = ( _add_hawkeye_leader( diff --git a/ultraplot/tests/test_inset.py b/ultraplot/tests/test_inset.py index dedd6ac92..e0b39dfd9 100644 --- a/ultraplot/tests/test_inset.py +++ b/ultraplot/tests/test_inset.py @@ -77,7 +77,7 @@ def test_hawkeye_extent_and_connectors(): (0.9, 0.9), size=0.2, extent=(120, 180, 10, 30), - connectors=True, + connector=True, ) fig.canvas.draw() @@ -93,7 +93,7 @@ def test_hawkeye_corner_connectors_envelop_inset() -> None: # 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), connectors=True + (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] @@ -104,7 +104,7 @@ def test_hawkeye_corner_connectors_envelop_inset() -> None: # 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), connectors=True + (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] @@ -128,7 +128,7 @@ def test_hawkeye_connectors_require_extent(): fig, ax = uplt.subplots(proj="cyl") with pytest.raises(ValueError, match="requires extent"): - ax[0].hawkeye((0.9, 0.9), size=0.2, connectors=True) + ax[0].hawkeye((0.9, 0.9), size=0.2, connector=True) uplt.close(fig) @@ -140,7 +140,7 @@ def test_hawkeye_circular_cutout_and_leader(): (0.9, 0.9), size=0.2, extent=(120, 180, 10, 30), - connectors="line", + connector="line", shape="circle", target="circle", ) @@ -174,7 +174,7 @@ def test_hawkeye_overview_connectors(): (0.9, 0.9), size=0.2, extent=(110, 180, -50, 0), - connectors=True, + connector=True, ) fig.canvas.draw() @@ -251,7 +251,7 @@ def test_hawkeye_overview_leader() -> None: (0.9, 0.9), size=0.2, extent=(110, 180, -50, 0), - connectors="line", + connector="line", relation="overview", ) fig.canvas.draw() From 78e7b4b22ec609e3af3beaade2f979d94ec47580 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Jul 2026 21:58:25 +1000 Subject: [PATCH 6/6] Keep Hawkeye compatibility checks clean across Matplotlib versions --- pyproject.toml | 1 + ultraplot/tests/test_inset.py | 2 ++ 2 files changed, 3 insertions(+) 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/tests/test_inset.py b/ultraplot/tests/test_inset.py index e0b39dfd9..875a9cf87 100644 --- a/ultraplot/tests/test_inset.py +++ b/ultraplot/tests/test_inset.py @@ -218,6 +218,8 @@ def test_hawkeye_zoom_indicator_normalizes_legacy_tuple(monkeypatch) -> None: 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)