Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions anyplotlib/FIGURE_ESM.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,18 +148,25 @@ inside the figure). Minimize / maximize / restore work for both — a maximized
inset floats centred at ~72 % (z 45); a minimized one collapses to its title
bar in place.

#### Region indications (callouts — `_drawCallouts`)
#### Region/point indications (callouts — `_drawCallouts`)
`layout.indications` is an array of mark_inset-style callouts, each
`{inset_id, parent_id, region:[x,y,w,h], color, linestyle, linewidth}`.
`_drawCallouts()` renders them onto a figure-level `calloutCanvas` (z 30, above
panels + insets, below maximized-inset float and the resize handle,
`pointer-events:none`):
`{inset_id, parent_id, region:[x,y,w,h], color, linestyle, linewidth}` (from
`indicate_region`) or `{inset_id, parent_id, point:[x,y], color, linestyle,
linewidth, marker_size}` (from `indicate_point` — the `point` key selects the
branch). `_drawCallouts()` renders them onto a figure-level `calloutCanvas`
(z 30, above panels + insets, below maximized-inset float and the resize
handle, `pointer-events:none`):
- The **dashed source rect** maps `region` (parent DATA coords) through the
parent's `_imgToCanvas2d` every draw, so it tracks the parent's zoom/pan; it
is clipped to the parent's image area.
- Two **leader lines** connect the rect's corners facing the inset to the
inset's nearest corners (loc1/loc2-auto by comparing centres); they follow
the inset's live DOM rect and are **hidden while the inset is minimized**.
- A **point indication** draws a solid circle-and-cross marker (radius
`marker_size`, clipped to the parent image area like the rect) at the mapped
data point, plus ONE leader from the marker's rim to the inset's nearest
corner (same minimized-hide rule; the leader uses the indication's
linestyle, the marker itself is always solid).

`_drawCallouts()` is called at the end of `_redrawPanel` / `redrawAll` (tracks
zoom/pan), at the end of `_applyAllInsetStates` (inset moved), on `applyLayout`
Expand Down Expand Up @@ -345,9 +352,13 @@ draws each **visible** layer bottom-up on `plotCanvas`:
- `_layerBytes(st, layer)` prefers `layer_<id>_b64_bytes` (binary) over the entry
`image_b64` base64;
- `_layerBitmap(p, st, layer)` builds a LUT-colormapped RGBA `OffscreenCanvas`,
**cached per layer id** by `(pixel key, cmap, clim)` — rebuilt only when the
layer's data or appearance changes (a live scrub that only swaps one layer's
data rebuilds just that layer);
**cached per layer id** by `(pixel key, cmap, tint, has-alpha, clim)` —
rebuilt only when the layer's data or appearance changes (a live scrub that
only swaps one layer's data rebuilds just that layer). The LUT honours a 4th
(alpha) channel when present (`cmapData[i][3] ?? 255`) — a `tint=` layer
ships a 256×4 clear→colour ramp (`_build_tint_lut`), so per-texel alpha
composites through the unpremultiplied `ImageData` and multiplies naturally
with the per-layer `ctx.globalAlpha`;
- it blits with the SAME fit-rect + zoom/pan transform as the base blit
(`_imgFitRect` + the `zoom>=1` window math) at `ctx.globalAlpha = layer.alpha`,
so zoom/pan track the base exactly.
Expand Down Expand Up @@ -493,9 +504,14 @@ figure onto one offscreen canvas at `devicePixelRatio × scale`:

- **WebGPU hazard first**: a WebGPU canvas's drawing buffer is only valid right
after its render pass, so exportPNG force-calls `draw2d(p)` on every
active-GPU 2-D panel (`p._gpu==='active' && p.gpuCanvas` visible) to re-submit
its pass, THEN composites in the SAME synchronous task — so
`drawImage(gpuCanvas,…)` reads live pixels, not a blank buffer.
active-GPU 2-D panel and `draw3d(p)` on every active-GPU 3-D panel
(`p._gpu==='active' && p.gpuCanvas` visible, `_gpuImg`/`_gpuObj` present) to
re-submit its pass, THEN composites in the SAME synchronous task — so
`drawImage(gpuCanvas,…)` reads live pixels, not a blank buffer. (draw3d's
active-GPU path uploads + submits in-task, no rAF, so the same-task re-render
suffices for 3-D too — without it a scatter3d/voxels panel exported as an
empty background rectangle; see `TestExportGpu3d` in
`tests/test_embed/test_export_png.py`.)
- **Extent**: `fig_width/height + 2×8 px` gridDiv padding (NOT the measured
`gridDiv` width — a bare `mount()` page has no `.apl-outer` inline-block CSS,
so the grid container can stretch to the viewport). **Origin**: `gridDiv`'s
Expand Down
38 changes: 38 additions & 0 deletions anyplotlib/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,44 @@ def _build_colormap_lut(name: str) -> list:
return [[v, v, v] for v in range(256)]


def _parse_hex_color(color: str) -> tuple:
"""Parse a ``#rgb`` / ``#rrggbb`` hex string → ``(r, g, b)`` ints (0–255).

Raises ``ValueError`` for anything else — tint colours are user API input
and a silent fallback would just render the wrong colour.
"""
if not isinstance(color, str):
raise ValueError(f"expected a hex colour string, got {color!r}")
h = color.strip().lstrip("#")
try:
if len(h) == 3:
return tuple(int(c * 2, 16) for c in h)
if len(h) == 6:
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
except ValueError:
pass
raise ValueError(
f"invalid hex colour {color!r} — expected '#rgb' or '#rrggbb'")


@functools.lru_cache(maxsize=64)
def _build_tint_lut(color: str) -> list:
"""Return a 256-entry ``[[r, g, b, a], ...]`` clear→colour TINT LUT.

RGB is the parsed *color* at every entry; alpha ramps linearly 0 → 255, so
a layer using this LUT is fully transparent at low intensity and the
opaque tint colour at high intensity (a solid-colour intensity ramp, the
overlay look, vs a named colormap's opaque colour gradient).

CACHED like :func:`_build_colormap_lut` and treated as read-only by
callers (they JSON-serialise it); do NOT mutate the returned list. The
4th (alpha) channel is honoured by the JS layer compositor
(``_layerBitmap`` reads ``cmapData[i][3] ?? 255``).
"""
r, g, b = _parse_hex_color(color)
return [[r, g, b, a] for a in range(256)]


def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray:
"""Resample a mesh to a regular pixel grid via nearest-neighbour lookup.

Expand Down
168 changes: 163 additions & 5 deletions anyplotlib/axes/_inset_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,10 @@ def __init__(self, fig, w_frac: float, h_frac: float, *,
self.h_frac = h_frac
self.title = title
self._inset_state: str = "normal"
# Region indication (mark_inset-style callout) tied to this inset, or
# None. Set via :meth:`indicate_region`, cleared via
# :meth:`clear_indication`. Persisted in Figure.layout_json.
# 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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -129,6 +130,76 @@ def restore(self) -> None:
self._inset_state = "normal"
self._fig._push_layout()

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) ──────────────────────

def indicate_region(self, parent_plot, region, *,
Expand Down Expand Up @@ -218,16 +289,103 @@ def indicate_region(self, parent_plot, region, *,
self._fig._push_layout()
return self

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

def clear_indication(self) -> None:
"""Remove any region indication attached to this inset (idempotent)."""
"""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 region-indication spec (``dict``) or ``None``."""
"""The current indication spec (``dict`` with either a ``region`` or a
``point`` key) or ``None``."""
return self._indication

# ── internal ──────────────────────────────────────────────────────────
Expand Down
28 changes: 26 additions & 2 deletions anyplotlib/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
VALID_EVENT_TYPES = frozenset({
"pointer_down", "pointer_up", "pointer_move", "pointer_settled",
"pointer_enter", "pointer_leave", "double_click", "wheel",
"key_down", "key_up", "close", "view_changed", "*",
"key_down", "key_up", "close", "view_changed",
# Figure-level inset drag / resize (JS → Python), so a host can persist the
# inset's new geometry. Fired through the figure's CallbackRegistry.
"inset_geometry_change", "*",
})


Expand All @@ -43,6 +46,7 @@ class Event:
ray — Plot3D only: {"origin": [...], "direction": [...]}
line_id — Plot1D only: set when pointer is over a line
dwell_ms — pointer_settled only: actual dwell time
target — double_click only: hit chrome element, or None for plot-area (title/x_label/x_ticks/y_label/y_ticks/colorbar_label/legend)

PlotBar extra fields (pointer_down only):
bar_index, value, x_label, group_index
Expand All @@ -54,6 +58,14 @@ class Event:
key — key name e.g. "q", "Enter", "ArrowLeft"
last_widget_id — id of the last widget the user clicked, or None

Panel-swap fields (figure-level panel_swap events):
source_panel_id, target_panel_id — the two panel ids dragged between

Inset drag/resize fields (figure-level inset_geometry_change events):
inset_id — the inset panel id
anchor — new top-left position [fx, fy] in figure fractions
w_frac, h_frac — new size in figure fractions

Propagation:
stop_propagation — set True inside a handler to halt remaining handlers
"""
Expand Down Expand Up @@ -94,14 +106,26 @@ class Event:
# ids the user dragged between. Set only on panel_swap; None otherwise.
source_panel_id: str | None = None
target_panel_id: str | None = None
# Inset drag / resize (figure-level inset_geometry_change events): the
# inset panel id plus its new geometry in figure fractions. Set only on
# inset_geometry_change; None otherwise.
inset_id: str | None = None
anchor: list | None = None
w_frac: float | None = None
h_frac: float | None = None
# Tagged double-click hit-target (double_click events): identifies WHICH
# text/chrome element was double-clicked — one of 'title', 'x_label',
# 'x_ticks', 'y_label', 'y_ticks', 'colorbar_label', 'legend'. None for a
# plain plot-area double_click (back-compatible) and every other event.
target: str | None = None
# Propagation (not repr'd)
stop_propagation: bool = field(default=False, repr=False)

def __repr__(self) -> str:
src = type(self.source).__name__ if self.source is not None else "None"
parts = [f"event_type={self.event_type!r}", f"source={src}"]
for fname in ("x", "y", "xdata", "ydata", "button", "key",
"line_id", "bar_index", "dwell_ms"):
"line_id", "bar_index", "dwell_ms", "target"):
v = getattr(self, fname)
if v is not None:
parts.append(f"{fname}={v!r}")
Expand Down
Loading
Loading