Skip to content

[Feature] Add Hawkeye option#771

Open
cvanelteren wants to merge 16 commits into
mainfrom
feature/hawkeye
Open

[Feature] Add Hawkeye option#771
cvanelteren wants to merge 16 commits into
mainfrom
feature/hawkeye

Conversation

@cvanelteren

@cvanelteren cvanelteren commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Closes #770

This PR extends the options for annotation on GeoAxes. It add specific "hawkeye" insets for GeoAxes to highlight specific areas of the map. It both adds square as well as circular annotations. Examples:

test2 test1

Succinctly this is now wired in as:

  • Scalar or (width, height) size in parent-axes fractions.
  • Anchoring with xy, transform, and anchor.
  • Geographic extent plus automatic detail/overview relation inference.
  • Extent indicators and either corner connectors or a single leader line.
  • Rectangular or circular inset/target shapes.
  • Projection-preserving circular callouts that pad the shorter map dimension;
    aspect="auto" instead fits an exact extent with distortion.
  • Optional alternative inset projection via proj=.
  • Gridlines disabled by default.

@cvanelteren

cvanelteren commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

TODOS

  • fix the indicator inset behaving differently on mpl 3.9
  • Do a refactor on hawkeye as it is much too long
  • Run through the docs to check the examples and visual fidelity
  • Need to check what is causing the visual regression
  • Get a review

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.
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.
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.
Comment thread ultraplot/axes/geo.py Outdated
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.
origin/main only contains #772, which reverts the hawkeye/circular-inset
commits that were mis-merged there; that work belongs on this branch. Merge with
the 'ours' strategy so main is recorded as integrated while the feature branch
keeps its hawkeye implementation intact. When this branch later merges back into
main via its PR, the revert becomes the merge base and the hawkeye code is
re-added cleanly.
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.37931% with 45 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
ultraplot/axes/geo.py 85.95% 22 Missing and 19 partials ⚠️
ultraplot/axes/base.py 88.88% 1 Missing and 1 partial ⚠️
ultraplot/tests/test_inset.py 98.95% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@cvanelteren

Copy link
Copy Markdown
Collaborator Author

I think we can ignore the visual regression as I didn't touch the code for the inset. I am opening this up.

@cvanelteren
cvanelteren marked this pull request as ready for review July 17, 2026 22:32
Comment thread ultraplot/axes/geo.py Outdated
)


def _parse_hawkeye_anchor(anchor):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can you add a paramter for anchor transform, to support point of coordinates to be anchor. Like ax.hawkeye(anchor=(103, 38), anchor_transform='map/or data/or crs'

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This could be possible as this:

ax.hawkeye(
      (103, 38),
      size=0.2,
      transform="map",        # longitude/latitude (PlateCarree)
      anchor="upper right",   # inset box corner
  )

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, it's not now.

@gepcel

gepcel commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Now the inset axes isn't including in the ultraplot layout engine. If I add an inset axes, it must be inside the figure space, otherwise, it will be cutted.

image

@cvanelteren

Copy link
Copy Markdown
Collaborator Author

I don´t think it has too, placement can be controlled by the user. For example for Cartesian this is possible:
image

@cvanelteren

Copy link
Copy Markdown
Collaborator Author

Ah wait I musunderstood you; yeah I agree that it has to account for it -- do you have the source for this cuz the example code for me renders correctly:

image

@gepcel

gepcel commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

inax = ax.hawkeye(
...
anchor="c", # Just changed this single line, with your example.
...
)

@cvanelteren

Copy link
Copy Markdown
Collaborator Author

The circular inset will now prompt redraw on the limits similar to how the inset do it.

Comment thread ultraplot/axes/base.py
locator = self._make_inset_locator(bounds, transform)
# Convert coordinates to figure-relative coordinates while retaining the
# original bounds for aspect-aware geographic inset locators.
bounds_input, transform = self._parse_anchor(bounds, transform, default="axes")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

@gepcel

gepcel commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

I'd like to discuss a few coordinate‑system design aspects of the hawkeye function. For clarity, let the parent axes be ax and the hawkeye (inset) axes be iax.

The hawkeye function currently takes three parameters that involve coordinate transformations:

  1. xy – the position of ax to put iax on. Its coordinate system is specified by the transform parameter.
  2. extent – the data extent of iax. Its coordinate system is specified by extent_transform.
  3. anchor – the point on iax that will be placed at xy. When anchor is a tuple of floats, it is always interpreted in iax.transAxes coordinates (axes‑relative).

There is also a proj parameter to set the map projection of iax.

My questions and suggestions are as follows:

1. About the transform parameter

  • transform=axxy uses ax.transAxes (parent axes coordinates). This is correct.
  • transform='data'xy should use ax.transData (parent data coordinates). Does 'data' effectively mean ax.projection (i.e., the parent's map projection), or others? I didn't dig deep into the source code and didn't do more tests.
  • transform='map' – I left a poor comment about this earlier, I haven't thought it through, please disregard it if I mislead you.
  • Ideally, I would like transform to accept projection names or crs directly (similar to the Proj), so that xy can be specified in arbitrary projected coordinates.

2. About the anchor coordinate system
I would like to have an additional parameter, say anchor_transform, to define the coordinate system for the anchor point. Its default should be 'axes' (matching the current behaviour with iax.transAxes, and if anchor is str, it is converted to 0~1, also use iax.transAxes as coordinate system).

However, the current implementation forces anchor=(0.2, 0.3) to always be axes‑relative. This makes it impossible to:

  • set the anchor to a specific geographic point (e.g., (103.8, 1.3) as lon/lat) unless I can pass anchor_transform='cyl' (or another projection),
  • Or use a point defined in other projections as the anchor.

I hope this clarifies my design intent. I look forward to your thoughts.

@cvanelteren

Copy link
Copy Markdown
Collaborator Author

1.2 'data' -> ax.transData, that is, I assume here that your input will already be in your projected units. A CRS will go through projection._as_mpl_transform(ax) I believe which is cartopy's internal way of casting it into a native mpl transform. A transform="data" I read as "coordinates in ax.projection". The transform "data" will only be "PlateCarree" when the parent also has this.
1.3 Ok. Just for reference when set to map this will work as ccrs.PlateCarree which is how I understood your earlier comment. That is, it is read as "xy" are lon/lat coordinates.
1.4: A CRS can be directly accepted now, but you are right that I have not wired in the full strings yet. I need to add this in.

For 2 I think this is a good addition to also allow pinning to the data coordinates, it currently defaults to axes.

Does this clear things up?

@cvanelteren

cvanelteren commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

So my read is that

  • Add project parsing of strings
  • Add anchor_transform to place anchor within arbitrary transform

@gepcel

gepcel commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Everything is fine now, apart from the "map" transform. I apologize for my earlier comment—it was poorly considered and may have misled you. Since "map" is not supported as a transform name in the GeoAxes transform system, I believe we should drop it from hawkeye.

Let's keep the transform parsing consistent for both GeoAxes and the hawkeye method.

All others works fine as I tested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Suggestion: improve inset positioning for geoaxes

2 participants