"""
axes/_inset_axes.py
===================
Floating overlay inset (not in the grid).
"""
from __future__ import annotations
import math
import uuid as _uuid
from anyplotlib.axes._axes import Axes
from anyplotlib.plot1d import PlotBar
from anyplotlib.plot2d import Plot2D, PlotMesh
from anyplotlib.plot3d import Plot3D
def _plot_kind(plot) -> str:
"""Return the JS panel-kind string for a plot object.
Used in ``Figure._push_layout()`` and ``InsetAxes.__repr__``.
"""
if isinstance(plot, Plot3D):
return "3d"
if isinstance(plot, (Plot2D, PlotMesh)):
return "2d"
if isinstance(plot, PlotBar):
return "bar"
return "1d"
_VALID_CORNERS = ("top-right", "top-left", "bottom-right", "bottom-left")
[docs]
class InsetAxes(Axes):
"""A floating inset sub-plot that overlays the main Figure grid.
Created via :meth:`Figure.add_inset`. Supports the same plot-factory
methods as :class:`Axes` (``imshow``, ``plot``, ``pcolormesh``, etc.).
The inset is positioned at a corner of the figure and can be minimized
(title bar only), maximized (expanded to fill ~72% of the figure), or
restored to its normal size.
Parameters
----------
fig : Figure
w_frac, h_frac : float
Width and height as fractions of the figure dimensions (0–1).
corner : str, optional
One of ``"top-right"``, ``"top-left"``, ``"bottom-right"``,
``"bottom-left"``. Default ``"top-right"``. Mutually exclusive with
*anchor* — pass exactly one.
anchor : (x_frac, y_frac), optional
Position of the inset's TOP-LEFT corner as fractions of the figure
size (0–1), measured from the figure's top-left. When given, the
inset floats freely at that anchor instead of snapping to a corner
(``corner`` is then ignored / ``None``). Minimize / maximize / restore
all still work: a minimized anchored inset collapses to its title bar
in place, a maximized one floats centred, and restore returns it to the
anchor.
title : str, optional
Text shown in the inset title bar. Default ``""``.
Examples
--------
>>> fig, ax = apl.subplots(1, 1, figsize=(640, 480))
>>> ax.imshow(data)
>>> inset = fig.add_inset(0.3, 0.25, corner="top-right", title="Zoom")
>>> inset.imshow(data[64:128, 64:128])
>>> # arbitrary placement:
>>> free = fig.add_inset(0.3, 0.25, anchor=(0.55, 0.1), title="Callout")
>>> free.imshow(data[64:128, 64:128])
"""
def __init__(self, fig, w_frac: float, h_frac: float, *,
corner: str = "top-right", anchor=None, title: str = ""):
if anchor is not None:
ax_, ay_ = anchor
self.anchor = (float(ax_), float(ay_))
# anchor placement supersedes corner; keep corner=None so both the
# layout math and the callout corner-pairing know it is free-floating.
self.corner = None
else:
if corner not in _VALID_CORNERS:
raise ValueError(
f"corner must be one of {_VALID_CORNERS!r}, got {corner!r}"
)
self.anchor = None
self.corner = corner
# Pass a dummy SubplotSpec so Axes.__init__ doesn't fail — InsetAxes
# never occupies a grid cell, only overlays the figure.
from anyplotlib.figure._gridspec import SubplotSpec
super().__init__(fig, SubplotSpec(None, 0, 1, 0, 1))
self.w_frac = w_frac
self.h_frac = h_frac
self.title = title
self._inset_state: str = "normal"
# Region/point indication (mark_inset-style callout) tied to this
# inset, or None. Set via :meth:`indicate_region` /
# :meth:`indicate_point`, cleared via :meth:`clear_indication`.
# Persisted in Figure.layout_json.
self._indication: dict | None = None
# ── state API ─────────────────────────────────────────────────────────
@property
def inset_state(self) -> str:
"""Current state: ``"normal"``, ``"minimized"``, or ``"maximized"``."""
return self._inset_state
[docs]
def minimize(self) -> None:
"""Collapse the inset to its title bar only (idempotent)."""
if self._inset_state == "minimized":
return
self._inset_state = "minimized"
self._fig._push_layout()
[docs]
def maximize(self) -> None:
"""Expand the inset to ~72 % of the figure, centred (idempotent)."""
if self._inset_state == "maximized":
return
self._inset_state = "maximized"
self._fig._push_layout()
[docs]
def restore(self) -> None:
"""Return the inset to its normal corner position (idempotent)."""
if self._inset_state == "normal":
return
self._inset_state = "normal"
self._fig._push_layout()
[docs]
def set_geometry(self, *, anchor=None, w_frac: float | None = None,
h_frac: float | None = None) -> "InsetAxes":
"""Move and/or resize the inset in figure-fraction coordinates.
This is the Python-side counterpart to the interactive drag / resize
of an inset in the renderer's edit mode: the ``inset_geometry_change``
event dispatches here so the authoritative Python state converges with
what the user did on screen. It is also callable directly to place or
size an inset programmatically.
Passing *anchor* switches the inset to free (anchor) placement and
drops any corner snapping (``corner`` becomes ``None``), mirroring the
renderer's corner-to-anchor conversion on drag start.
Parameters
----------
anchor : (x_frac, y_frac), optional
New position of the inset's TOP-LEFT corner as fractions of the
figure size (0–1), measured from the figure's top-left. Each
component is clamped into ``[0, 1]``. When given, ``corner`` is
set to ``None`` (free placement). When omitted, the current
placement (corner or existing anchor) is left unchanged.
w_frac, h_frac : float, optional
New width / height as fractions of the figure size. Each is
clamped into ``(0, 1]`` (must be positive, at most the full
figure). When omitted, the corresponding dimension is unchanged.
Returns
-------
InsetAxes
``self``, for chaining.
Raises
------
ValueError
If *anchor* is not a pair of finite numbers, or ``w_frac`` /
``h_frac`` is not a finite number.
"""
if anchor is not None:
try:
ax_, ay_ = (float(v) for v in anchor)
except (TypeError, ValueError):
raise ValueError(
f"set_geometry: anchor must be 2 numbers (x_frac, y_frac), "
f"got {anchor!r}") from None
if not all(math.isfinite(v) for v in (ax_, ay_)):
raise ValueError(
f"set_geometry: anchor values must be finite, got "
f"({ax_}, {ay_})")
ax_ = min(1.0, max(0.0, ax_))
ay_ = min(1.0, max(0.0, ay_))
self.anchor = (ax_, ay_)
# Anchor placement supersedes corner (matches __init__ / the JS
# corner→anchor conversion on drag start).
self.corner = None
if w_frac is not None:
w = float(w_frac)
if not math.isfinite(w):
raise ValueError(
f"set_geometry: w_frac must be finite, got {w_frac!r}")
self.w_frac = min(1.0, max(1e-6, w))
if h_frac is not None:
h = float(h_frac)
if not math.isfinite(h):
raise ValueError(
f"set_geometry: h_frac must be finite, got {h_frac!r}")
self.h_frac = min(1.0, max(1e-6, h))
self._fig._push_layout()
return self
# ── region indication (mark_inset-style callout) ──────────────────────
[docs]
def indicate_region(self, parent_plot, region, *,
color: str = "#ff9800",
linestyle: str = "dashed",
linewidth: float = 1.5) -> "InsetAxes":
"""Draw a callout tying this inset to a region of *parent_plot*.
Renders — on the parent panel's overlay — a rectangle around *region*
(in the parent image's DATA coordinates) plus two leader lines joining
the rectangle's corners that face the inset to the inset's nearest
corners, the classic matplotlib ``mark_inset`` look. The rectangle
tracks the parent's zoom / pan and the leaders follow the inset as it
moves or minimizes (leaders hide while the inset is minimized).
Calling ``indicate_region`` again REPLACES any previous indication for
this inset. Remove it with :meth:`clear_indication`.
Parameters
----------
parent_plot : Plot2D
The parent image plot the region lives on. Must be a 2-D image
panel in the SAME figure as this inset (typically the panel the
inset overlays) — a plot registered on a different ``Figure``
raises ``ValueError``.
region : tuple of float
The source rectangle in the parent image's data coordinates, as
``(x, y, w, h)``: top-left ``(x, y)`` plus width and height. The
values follow the same convention as the parent's axes (pixel
indices for an uncalibrated image; physical units when the axes
are calibrated).
All four must be finite; ``w`` and ``h`` must be strictly
positive. The rectangle MAY extend outside the parent's data
bounds (e.g. a region near an edge) — that is allowed by design
and simply clips visually; only degenerate/non-finite values are
rejected.
color : str, optional
Stroke colour of both the rectangle and the leader lines.
Default warm orange ``"#ff9800"``.
linestyle : str, optional
``"dashed"`` (default), ``"solid"``, or ``"dotted"``.
linewidth : float, optional
Stroke width in CSS px. Default ``1.5``.
Returns
-------
InsetAxes
``self``, for chaining.
Raises
------
ValueError
If ``parent_plot`` has no panel id, is not registered on this
inset's Figure, or ``region`` is not 4 finite numbers with
``w > 0`` and ``h > 0``.
"""
pid = getattr(parent_plot, "_id", None)
if pid is None:
raise ValueError("indicate_region: parent_plot has no panel id "
"(attach it to the figure first)")
if self._fig._plots_map.get(pid) is not parent_plot:
raise ValueError(
"indicate_region: parent_plot is not registered on this "
"inset's Figure — pass a plot created on the same figure "
"as this inset (fig.add_inset / fig.subplots)")
try:
x, y, w, h = (float(v) for v in region)
except (TypeError, ValueError):
raise ValueError(
f"indicate_region: region must be 4 numbers (x, y, w, h), "
f"got {region!r}") from None
if not all(math.isfinite(v) for v in (x, y, w, h)):
raise ValueError(
f"indicate_region: region values must be finite, got "
f"(x={x}, y={y}, w={w}, h={h})")
if not (w > 0 and h > 0):
raise ValueError(
f"indicate_region: region width and height must be > 0, "
f"got (w={w}, h={h})")
self._indication = {
"parent_id": pid,
"region": [x, y, w, h],
"color": color,
"linestyle": linestyle,
"linewidth": float(linewidth),
}
self._fig._push_layout()
return self
[docs]
def indicate_point(self, parent_plot, point, *,
color: str = "#ff9800",
linestyle: str = "dashed",
linewidth: float = 1.5,
marker_size: float = 5.0) -> "InsetAxes":
"""Draw a callout tying this inset to a single POINT of *parent_plot*.
The point sibling of :meth:`indicate_region`: renders a small circular
marker (with a centre cross) at *point* — in the parent image's DATA
coordinates — plus ONE leader line joining the marker to the inset's
nearest corner. The marker tracks the parent's zoom / pan and the
leader follows the inset as it moves (the leader hides while the inset
is minimized), exactly like the region callout.
Calling ``indicate_point`` (or ``indicate_region``) again REPLACES any
previous indication for this inset — an inset carries at most one.
Remove it with :meth:`clear_indication`.
Parameters
----------
parent_plot : Plot2D
The parent image plot the point lives on. Must be a 2-D image
panel registered on the SAME figure as this inset.
point : tuple of float
The source point in the parent image's data coordinates, as
``(x, y)`` — same convention as :meth:`indicate_region`'s region
origin. Both values must be finite; a point outside the parent's
data bounds is allowed (it simply clips visually).
color : str, optional
Stroke colour of the marker and the leader line. Default warm
orange ``"#ff9800"``.
linestyle : str, optional
Leader style — ``"dashed"`` (default), ``"solid"``, or
``"dotted"``. The marker itself is always drawn solid.
linewidth : float, optional
Stroke width in CSS px. Default ``1.5``.
marker_size : float, optional
Marker circle radius in CSS px. Default ``5.0``. Must be > 0.
Returns
-------
InsetAxes
``self``, for chaining.
Raises
------
ValueError
If ``parent_plot`` has no panel id, is not registered on this
inset's Figure, ``point`` is not 2 finite numbers, or
``marker_size`` is not > 0.
"""
pid = getattr(parent_plot, "_id", None)
if pid is None:
raise ValueError("indicate_point: parent_plot has no panel id "
"(attach it to the figure first)")
if self._fig._plots_map.get(pid) is not parent_plot:
raise ValueError(
"indicate_point: parent_plot is not registered on this "
"inset's Figure — pass a plot created on the same figure "
"as this inset (fig.add_inset / fig.subplots)")
try:
x, y = (float(v) for v in point)
except (TypeError, ValueError):
raise ValueError(
f"indicate_point: point must be 2 numbers (x, y), "
f"got {point!r}") from None
if not all(math.isfinite(v) for v in (x, y)):
raise ValueError(
f"indicate_point: point values must be finite, got "
f"(x={x}, y={y})")
ms = float(marker_size)
if not (math.isfinite(ms) and ms > 0):
raise ValueError(
f"indicate_point: marker_size must be > 0, got {marker_size!r}")
self._indication = {
"parent_id": pid,
"point": [x, y],
"color": color,
"linestyle": linestyle,
"linewidth": float(linewidth),
"marker_size": ms,
}
self._fig._push_layout()
return self
[docs]
def clear_indication(self) -> None:
"""Remove any region/point indication attached to this inset
(idempotent)."""
if self._indication is None:
return
self._indication = None
self._fig._push_layout()
@property
def indication(self) -> "dict | None":
"""The current indication spec (``dict`` with either a ``region`` or a
``point`` key) or ``None``."""
return self._indication
# ── internal ──────────────────────────────────────────────────────────
def _attach(self, plot) -> None:
"""Register the plot on this inset via Figure._register_inset."""
if self._plot is not None:
panel_id = self._plot._id
else:
panel_id = str(_uuid.uuid4())[:8]
plot._id = panel_id
plot._fig = self._fig
self._plot = plot
self._fig._register_inset(self, plot)
def __repr__(self) -> str:
kind = _plot_kind(self._plot) if self._plot else "empty"
pos = (f"anchor={self.anchor!r}" if self.anchor is not None
else f"corner={self.corner!r}")
return (
f"InsetAxes({pos}, "
f"size=({self.w_frac:.2f}, {self.h_frac:.2f}), "
f"state={self._inset_state!r}, kind={kind!r})"
)