diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md
index ee99b848..e4c06be6 100644
--- a/anyplotlib/FIGURE_ESM.md
+++ b/anyplotlib/FIGURE_ESM.md
@@ -136,7 +136,66 @@ geometry changes (visibility, label, sizes) re-layout automatically.
#### `applyLayout()` (line 590)
Reads `layout_json`. Builds CSS grid tracks from `panel_specs[].panel_width/height`.
Creates panels that don't exist yet, resizes existing ones, removes stale ones.
-Also creates/updates inset panels from `inset_specs`.
+Also creates/updates inset panels from `inset_specs`, then draws region
+indications from `layout.indications` (on the next frame — see below).
+
+#### Inset placement (`_applyAllInsetStates`)
+Each `inset_specs[]` entry carries EITHER `corner` (one of the four corners;
+`anchor` is `null`) OR `anchor` (`[x_frac, y_frac]` of the inset's top-left in
+figure fraction; `corner` is `null`). Corner insets stack per-corner with
+`INSET_GAP`; anchored insets are placed directly at their fraction (clamped
+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`)
+`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`):
+- 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**.
+
+`_drawCallouts()` is called at the end of `_redrawPanel` / `redrawAll` (tracks
+zoom/pan), at the end of `_applyAllInsetStates` (inset moved), on `applyLayout`
+(deferred one rAF so `getBoundingClientRect` is real), and inside `exportPNG`
+(forced fresh draw, then `calloutCanvas` composited last). All coordinates go
+through element bounding rects relative to the callout canvas, so no layout math
+is duplicated. Cheap no-op when `indications` is empty (just clears the canvas).
+The `if (!parent || !parent.state || !inset || !inset.isInset) continue;` guard
+per indication is defensive/permanent — kept even though a foreign-figure
+`parent_plot` can no longer reach this array at all (see validation below); it
+still protects against other edge cases (e.g. a panel mid-teardown).
+
+**`InsetAxes.indicate_region(parent_plot, region, …)` validates both arguments**
+before recording an indication: `parent_plot` must be a panel registered on
+THIS inset's own `Figure` (`self._fig._plots_map.get(pid) is parent_plot` —
+not just "has some `_id`", which is the pre-existing check for "never attached
+to any figure") — a plot that belongs to a *different* `Figure` raises
+`ValueError`. `region` must be exactly 4 finite numbers `(x, y, w, h)` with
+`w > 0` and `h > 0` — `NaN`/`inf`, a degenerate/negative size, or the wrong
+number of values raises `ValueError`. A region that extends OUTSIDE the
+parent's data bounds is explicitly **allowed** (clipping is a visual concern
+handled by `_drawCallouts`'s clip-to-image-area, not a validation error). See
+`test_indicate_region_foreign_figure_parent_raises`,
+`test_indicate_region_foreign_inset_parent_raises`,
+`test_indicate_region_degenerate_region_raises`, and
+`test_indicate_region_out_of_bounds_is_allowed` in
+`tests/test_layouts/test_inset_callout.py`.
+
+**Inset removal**: as of this writing there is no `remove_inset` (or
+equivalent) API — `Figure._insets_map` / `_plots_map` are only ever appended
+to, never deleted from, so `indications` (rebuilt fresh from `_insets_map` on
+every `_push_layout()` call) cannot go stale from a removed inset today. If a
+removal API is added later, it MUST also delete the inset's entry from both
+maps — otherwise `layout.indications` would keep emitting an entry whose
+`inset_id` no longer resolves to a live panel (caught by the `_drawCallouts`
+guard above, but a dangling entry all the same).
#### `_createPanelDOM(id, kind, pw, ph, spec)` (line 763)
Builds all canvas/DOM elements for one panel (via `_buildCanvasStack`),
@@ -208,6 +267,35 @@ mask). `Layer.set(...)`, `Layer.set_data(frame)`, `Layer.remove()`,
exclusive** (guard raises in both directions: `add_layer` on a tiled plot, and
`enable_tile` / `set_data(tile=True)` / auto-tile on a layered plot).
+**A shape-changing `Plot2D.set_data` on a plot with layers raises `ValueError`.**
+Each layer entry keeps the `(width, height)` it had at `add_layer` / its last
+`Layer.set_data` time (`_encode_layer_pixels`), but `_drawLayers2d` always fits
+every layer's bitmap into the BASE image's *current* `_imgFitRect(iw, ih, …)`
+(`iw`/`ih` = the live `image_width`/`image_height`). So a base `set_data` that
+changes shape while a stale-sized layer is still attached would silently stretch
+that layer's old pixels over the new image instead of erroring — `set_data`
+now checks `data.shape[:2]` against the current `image_height`/`image_width`
+whenever `st.layers` is non-empty and raises before touching any state if they
+differ (same-shape updates, the common live-update case, are unaffected). Remove
+all layers first (`remove_layer`), change the base shape, then re-add them at the
+new size. A layer-FREE plot's shape-changing `set_data` is unaffected and always
+refreshes `image_width`/`image_height` (they're set unconditionally in the
+pushed `fields` dict). See `TestTileGuards` / `TestShapeChangeNoLayers` in
+`tests/test_plot2d/test_layers.py`.
+
+**`Layer.set(clim=…)` has three distinct meanings** — `None` (default) leaves
+the clim UNCHANGED (a no-op on that field, not "reset to auto"); a `(vmin, vmax)`
+tuple sets an explicit range and re-quantises the cached frame over it;
+`"auto"` is the sentinel to explicitly RESET to auto — recomputes the display
+range from the layer's own current data (`self._layer_raw[layer_id]`) min/max,
+the same auto-ranging `add_layer(..., clim=None)` does at creation time, and
+re-quantises. Before this, `clim=None` was documented as "auto" but actually
+behaved as a no-op, and there was no way to get back to auto range after
+setting an explicit clim short of `remove()` + `add_layer()` again. See
+`TestSet::test_set_clim_auto_resets_to_data_range` /
+`test_set_clim_auto_matches_add_layer_auto` /
+`test_set_clim_none_is_a_noop` in `tests/test_plot2d/test_layers.py`.
+
### State + transport (dynamic per-layer pixel keys)
The layer *metadata* lives in `st.layers` (a list of small dicts on the light view
@@ -416,7 +504,26 @@ figure onto one offscreen canvas at `devicePixelRatio × scale`:
`getBoundingClientRect()` relative to the root): gpuCanvas (z0) → plotCanvas
(z1) → x/yAxisCanvas → cbCanvas → [overlayCanvas z5 only if `includeWidgets`]
→ markersCanvas (z6) → scaleBar (z7) → titleCanvas (z8). Grid panels first,
- then insets (`p.isInset`) on top. Status bars / stats overlays are excluded.
+ then insets (`p.isInset`) on top — **each titled inset's title bar text is
+ drawn directly onto the output canvas right after its canvas stack**
+ (`_drawInsetTitle`; the title bar is plain DOM — a `
`/`
`, not a
+ canvas — so `_drawEl` alone never captures it; approximates the on-screen
+ CSS: 11px sans-serif, `theme.tickText` colour, left-padded to the titleBar's
+ rect) — then the figure-level `calloutCanvas` (region indications) composited
+ LAST. Status bars / stats overlays are excluded.
+- **Coordinate snapping** (`_drawEl`): `dx`/`dy` are `Math.round()`ed from the
+ element's `left`/`top`, and `dw`/`dh` are the ROUNDED `right`/`bottom` edge
+ minus the rounded `dx`/`dy` — never `Math.round(width)` directly. This makes
+ two elements that share a CSS edge (e.g. adjacent grid panels, or a
+ panel's axis-gutter canvas against its plotCanvas) round that shared edge to
+ the *same* output pixel on both sides. Without it, at a fractional effective
+ scale (`devicePixelRatio × opts.scale` — e.g. a real 150% Windows display, or
+ `scale: 1.25`), each element's `dx`/`dw` were computed independently as raw
+ floats, and adjacent elements could round their common boundary to different
+ output pixels — a 1px background-coloured seam (or overlap) exactly at the
+ join. See `TestExportMultiPanel::test_fractional_scale_no_seam_between_panels`
+ in `tests/test_embed/test_export_png.py` (reproduced with
+ `device_scale_factor=1.5`).
- Ends with `out.toDataURL('image/png')`; rejects the promise with a message on
failure (no 2-D context, `toDataURL` throw).
diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py
index 30b3cd66..f9d0b172 100644
--- a/anyplotlib/_repr_utils.py
+++ b/anyplotlib/_repr_utils.py
@@ -144,6 +144,20 @@ def _widget_px(widget) -> tuple[int, int]:
const _data = Object.assign({{}}, state);
const _cbs = {{}};
const _anyCbs = [];
+ // Keys set() has touched while _fromParent (an inbound awi_state push) is
+ // in effect, awaiting the matching save_changes() flush. Mirrors the
+ // dirty-set pattern figure_esm.js's own createLocalModel() uses (see
+ // mount()): the _fromParent branch below defers listener firing from
+ // set() to save_changes() (see the comment there), but save_changes() was
+ // firing EVERY registered change:* callback instead of only the key(s)
+ // that changed. Concretely: a single set('event_json', ...) (the Python
+ // targeted-widget-update side channel, _push_widget) also re-fired every
+ // panel's change:panel__json listener, which re-parses that panel's
+ // (untouched, stale) trait and overwrites p.state wholesale — silently
+ // reverting the very widget edit event_json just applied, in the same
+ // synchronous cascade. Only the _fromParent path needs tracking — the
+ // local (!_fromParent) path already fires synchronously in set() itself.
+ const _parentDirty = new Set();
return {{
get(key) {{ return _data[key]; }},
set(key, val) {{
@@ -155,12 +169,32 @@ def _widget_px(widget) -> tuple[int, int]:
const ev = 'change:' + key;
if (_cbs[ev]) for (const cb of [..._cbs[ev]]) try {{ cb({{ new: val }}); }} catch(_) {{}}
for (const cb of [..._anyCbs]) try {{ cb(); }} catch(_) {{}}
+ }} else {{
+ _parentDirty.add(key);
}}
}},
save_changes() {{
- for (const [ev, cbs] of Object.entries(_cbs))
- for (const cb of cbs) try {{ cb({{ new: _data[ev.slice(7)] }}); }} catch(_) {{}}
- for (const cb of _anyCbs) try {{ cb(); }} catch(_) {{}}
+ if (_parentDirty.size) {{
+ // Fire only listeners for keys an inbound awi_state set() actually
+ // touched since the last flush (each at most once) — not the whole
+ // _cbs table. Snapshot + clear BEFORE invoking callbacks, since a
+ // callback may itself call set()/save_changes() (re-entrant-safe).
+ const keys = [..._parentDirty];
+ _parentDirty.clear();
+ for (const key of keys) {{
+ const ev = 'change:' + key;
+ if (_cbs[ev]) for (const cb of [..._cbs[ev]]) try {{ cb({{ new: _data[key] }}); }} catch(_) {{}}
+ }}
+ for (const cb of [..._anyCbs]) try {{ cb(); }} catch(_) {{}}
+ }} else {{
+ // Local (!_fromParent) path — unchanged: set() already fired
+ // synchronously per-key, so this refires the full table (matches the
+ // pre-existing local-set behaviour; harmless since _cbs entries are
+ // typically no-ops for a key that hasn't just changed).
+ for (const [ev, cbs] of Object.entries(_cbs))
+ for (const cb of cbs) try {{ cb({{ new: _data[ev.slice(7)] }}); }} catch(_) {{}}
+ for (const cb of _anyCbs) try {{ cb(); }} catch(_) {{}}
+ }}
// Forward interaction events to the parent-page Pyodide instance.
if (!_fromParent && FIG_ID && window.parent !== window && _eventJsonDirty) {{
_eventJsonDirty = false;
diff --git a/anyplotlib/axes/_inset_axes.py b/anyplotlib/axes/_inset_axes.py
index 9a0b8523..31d20ba4 100644
--- a/anyplotlib/axes/_inset_axes.py
+++ b/anyplotlib/axes/_inset_axes.py
@@ -6,6 +6,7 @@
from __future__ import annotations
+import math
import uuid as _uuid
from anyplotlib.axes._axes import Axes
@@ -48,7 +49,16 @@ class InsetAxes(Axes):
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"``.
+ ``"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 ``""``.
@@ -58,23 +68,38 @@ class InsetAxes(Axes):
>>> 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", title: str = ""):
- if corner not in _VALID_CORNERS:
- raise ValueError(
- f"corner must be one of {_VALID_CORNERS!r}, got {corner!r}"
- )
+ 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.corner = corner
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.
+ self._indication: dict | None = None
# ── state API ─────────────────────────────────────────────────────────
@@ -104,6 +129,106 @@ def restore(self) -> None:
self._inset_state = "normal"
self._fig._push_layout()
+ # ── region indication (mark_inset-style callout) ──────────────────────
+
+ 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 : (x, y, w, h)
+ The source rectangle in the parent image's data coordinates:
+ 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
+
+ def clear_indication(self) -> None:
+ """Remove any region 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``."""
+ return self._indication
+
# ── internal ──────────────────────────────────────────────────────────
def _attach(self, plot) -> None:
@@ -119,8 +244,10 @@ def _attach(self, plot) -> None:
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(corner={self.corner!r}, "
+ f"InsetAxes({pos}, "
f"size=({self.w_frac:.2f}, {self.h_frac:.2f}), "
f"state={self._inset_state!r}, kind={kind!r})"
)
diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py
index ed30ab14..ac65e643 100644
--- a/anyplotlib/callbacks.py
+++ b/anyplotlib/callbacks.py
@@ -90,6 +90,10 @@ class Event:
# Key
key: str | None = None
last_widget_id: str | None = None
+ # Panel drag-swap (figure-level panel_swap events): the two panel dispatch
+ # ids the user dragged between. Set only on panel_swap; None otherwise.
+ source_panel_id: str | None = None
+ target_panel_id: str | None = None
# Propagation (not repr'd)
stop_propagation: bool = field(default=False, repr=False)
diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py
index 6037c7d4..52741da9 100644
--- a/anyplotlib/figure/_figure.py
+++ b/anyplotlib/figure/_figure.py
@@ -14,12 +14,20 @@
import anywidget
import traitlets
+import uuid as _uuid
+
from anyplotlib.axes import Axes, InsetAxes
from anyplotlib.axes._inset_axes import _plot_kind
from anyplotlib.figure._gridspec import SubplotSpec
-from anyplotlib.callbacks import Event
+from anyplotlib.callbacks import CallbackRegistry, Event, _EventMixin
from anyplotlib._repr_utils import repr_html_iframe
+
+# Recognised figure-level annotation kinds and the fields each carries (beyond
+# ``id`` + ``kind``). Positions/sizes are all in FIGURE FRACTIONS (0..1, origin
+# top-left), so a marker keeps its relative place across figure resizes.
+_FIGURE_MARKER_KINDS = {"text", "circle", "rect", "arrow"}
+
_HERE = pathlib.Path(__file__).parent.parent
_ESM_SOURCE = (_HERE / "figure_esm.js").read_text(encoding="utf-8")
@@ -34,7 +42,7 @@ def _binary_wire() -> bool:
return os.environ.get("APL_BINARY_TRANSPORT") == "1"
-class Figure(anywidget.AnyWidget):
+class Figure(anywidget.AnyWidget, _EventMixin):
"""Multi-panel interactive figure widget.
The top-level container for all plots and the only ``anywidget.AnyWidget``
@@ -77,6 +85,18 @@ class Figure(anywidget.AnyWidget):
# Figure-level help text shown in a '?' badge overlay in JS.
# Empty string means no badge. Gated by apl.show_help at the Python level.
help_text = traitlets.Unicode("").tag(sync=True)
+ # ── Edit-mode chrome (Report Builder) ─────────────────────────────────────
+ # edit_chrome — when True the JS renderer shows per-panel hover outlines,
+ # makes figure-level markers hit-testable/draggable, and
+ # emits figure-background clicks. When False all of this
+ # is inert — normal interaction is untouched.
+ # selected_panel — a panel id (or "") that gets a persistent solid outline;
+ # all others clear. Pure JS-local DOM styling, no export.
+ edit_chrome = traitlets.Bool(False).tag(sync=True)
+ selected_panel = traitlets.Unicode("").tag(sync=True)
+ # Figure-level annotation layer: a JSON list of marker dicts positioned in
+ # FIGURE FRACTIONS, drawn over all panels. See set_figure_markers.
+ figure_markers_json = traitlets.Unicode("[]").tag(sync=True)
_esm = _ESM_SOURCE
# Static CSS injected by anywidget alongside _esm.
# .apl-scale-wrap — outer container; width:100% means it always fills
@@ -142,6 +162,12 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480),
# ships them straight to a PLOTBIN frame with NO base64 encode/decode
# and NO megabyte JSON. Empty (and ignored) on every non-Electron path.
self._raw_pixels: dict = {}
+ # Figure-level (not per-panel) callback registry + the _EventMixin API
+ # (add_event_handler / remove_handler / pause_events / hold_events).
+ # Fired for figure-background clicks and figure-marker pointer events.
+ self.callbacks: CallbackRegistry = CallbackRegistry()
+ # Authoritative Python-side copy of the figure-level annotation list.
+ self._figure_markers: list = []
with self.hold_trait_notifications():
self.fig_width = figsize[0]
self.fig_height = figsize[1]
@@ -407,6 +433,7 @@ def _mg(flag, key):
})
inset_specs = []
+ indications = []
for pid, inset_ax in self._insets_map.items():
plot = self._plots_map.get(pid)
pw = max(64, round(self.fig_width * inset_ax.w_frac))
@@ -416,12 +443,21 @@ def _mg(flag, key):
"kind": _plot_kind(plot) if plot else "1d",
"w_frac": inset_ax.w_frac,
"h_frac": inset_ax.h_frac,
+ # corner is None for anchor-placed insets; anchor is None for
+ # corner-placed ones. JS picks the placement mode by which is set.
"corner": inset_ax.corner,
+ "anchor": list(inset_ax.anchor)
+ if getattr(inset_ax, "anchor", None) is not None
+ else None,
"title": inset_ax.title,
"panel_width": pw,
"panel_height": ph,
"inset_state": inset_ax._inset_state,
})
+ # Region indication (mark_inset callout) is keyed by the inset id.
+ ind = getattr(inset_ax, "_indication", None)
+ if ind is not None:
+ indications.append({"inset_id": pid, **ind})
self.layout_json = json.dumps({
"nrows": self._nrows,
@@ -433,30 +469,42 @@ def _mg(flag, key):
"panel_specs": panel_specs,
"share_groups": share_groups,
"inset_specs": inset_specs,
+ "indications": indications,
"hspace": self._hspace,
"wspace": self._wspace,
})
# ── inset creation ────────────────────────────────────────────────────────
def add_inset(self, w_frac: float, h_frac: float, *,
- corner: str = "top-right", title: str = "") -> "InsetAxes":
+ corner: str = "top-right", anchor=None,
+ title: str = "") -> "InsetAxes":
"""Create and return a floating inset axes.
- The inset overlays the figure at the specified corner. Call
- plot-factory methods on the returned :class:`InsetAxes` to attach
- data::
+ The inset overlays the figure at the specified corner (default) or at
+ an arbitrary *anchor* position. Call plot-factory methods on the
+ returned :class:`InsetAxes` to attach data::
inset = fig.add_inset(0.3, 0.25, corner="top-right", title="Zoom")
inset.imshow(data) # returns Plot2D
inset.plot(profile) # returns Plot1D
+ # arbitrary placement (top-left corner at 55% across, 10% down):
+ free = fig.add_inset(0.3, 0.25, anchor=(0.55, 0.10))
+ free.imshow(data)
+
Parameters
----------
w_frac, h_frac : float
Width and height as fractions of the figure size (0–1).
corner : str, optional
Positioning corner: ``"top-right"`` (default), ``"top-left"``,
- ``"bottom-right"``, or ``"bottom-left"``.
+ ``"bottom-right"``, or ``"bottom-left"``. Mutually exclusive with
+ *anchor*.
+ anchor : (x_frac, y_frac), optional
+ Position of the inset's TOP-LEFT corner as fractions of the figure
+ size (0–1), from the figure's top-left. When given, the inset
+ floats at that anchor and *corner* is ignored. Use this for
+ free placement (e.g. a callout panel next to the region it marks).
title : str, optional
Text displayed in the inset title bar.
@@ -464,7 +512,8 @@ def add_inset(self, w_frac: float, h_frac: float, *,
-------
InsetAxes
"""
- return InsetAxes(self, w_frac, h_frac, corner=corner, title=title)
+ return InsetAxes(self, w_frac, h_frac, corner=corner,
+ anchor=anchor, title=title)
def _register_inset(self, inset_ax: "InsetAxes", plot) -> None:
"""Register an inset plot, allocating its trait and updating layout."""
@@ -515,6 +564,28 @@ def _dispatch_event(self, raw: str) -> None:
event_type = msg.get("event_type", "pointer_move")
widget_id = msg.get("widget_id")
+ # ── Figure-level events (edit mode) — handled BEFORE per-panel lookup ──
+ # A click on the bare figure background (no panel underneath).
+ if msg.get("figure_background"):
+ self._fire_figure_event(event_type, msg)
+ return
+
+ # A drag/click on a figure-level annotation marker. On pointer_up the
+ # JS ships the marker's updated FRACTION fields; merge them into the
+ # stored list so Python state converges, then fire figure callbacks.
+ if msg.get("figure_marker"):
+ self._apply_figure_marker_event(msg)
+ self._fire_figure_event(event_type, msg)
+ return
+
+ # A panel drag-swap: the user dragged one panel's move-grip and released
+ # over a DIFFERENT panel. anyplotlib performs NO layout change itself —
+ # it only fires a figure-level event carrying the two panel dispatch ids
+ # so the host (e.g. SpyDE) can swap + rebuild.
+ if msg.get("panel_swap"):
+ self._fire_figure_event(event_type, msg)
+ return
+
# Inset state changes handled before regular plot dispatch
if event_type == "inset_state_change":
inset_ax = self._insets_map.get(panel_id)
@@ -588,6 +659,103 @@ def _dispatch_event(self, raw: str) -> None:
)
plot.callbacks.fire(event)
+ # ── figure-level annotation layer ─────────────────────────────────────────
+ def _fire_figure_event(self, event_type: str, msg: dict) -> None:
+ """Fire the FIGURE-level callback registry with a flat Event.
+
+ Used for figure-background clicks, figure-marker pointer events, and
+ panel drag-swaps — events that belong to the figure as a whole, not to
+ any one panel. The marker id (if any) rides in ``last_widget_id`` so a
+ host can tell which annotation moved; a panel_swap carries the two panel
+ dispatch ids in ``source_panel_id`` / ``target_panel_id``.
+ """
+ event = Event(
+ event_type=event_type,
+ source=self,
+ time_stamp=msg.get("time_stamp", time.perf_counter()),
+ modifiers=msg.get("modifiers", []),
+ x=msg.get("x"),
+ y=msg.get("y"),
+ button=msg.get("button"),
+ buttons=msg.get("buttons", 0),
+ xdata=msg.get("xdata"),
+ ydata=msg.get("ydata"),
+ last_widget_id=msg.get("marker_id"),
+ source_panel_id=msg.get("source_panel_id"),
+ target_panel_id=msg.get("target_panel_id"),
+ )
+ self.callbacks.fire(event)
+
+ def _apply_figure_marker_event(self, msg: dict) -> None:
+ """Merge a figure-marker drag's updated FRACTION fields into the stored
+ marker list (matched by ``marker_id``) and re-sync the trait.
+
+ The JS side already wrote ``figure_markers_json`` back on mouseup, but
+ we converge Python's authoritative ``_figure_markers`` here too so a
+ host reading ``fig.figure_markers`` inside its callback sees the new
+ position immediately (and the two never drift)."""
+ marker_id = msg.get("marker_id")
+ if marker_id is None:
+ return
+ # Fraction fields the JS emits per kind.
+ _pos_keys = ("x", "y", "u", "v", "r", "w", "h")
+ for m in self._figure_markers:
+ if m.get("id") == marker_id:
+ for k in _pos_keys:
+ if k in msg:
+ m[k] = msg[k]
+ break
+ # Re-sync the trait from the authoritative list (source:"python" is not
+ # relevant here — figure_markers_json is a plain state trait, not the
+ # event bus, so this does not echo back through _dispatch_event).
+ self.figure_markers_json = json.dumps(self._figure_markers)
+
+ def set_figure_markers(self, markers: list) -> None:
+ """Set the figure-level annotation layer.
+
+ Parameters
+ ----------
+ markers : list of dict
+ Each dict is ``{"id"?, "kind", ...}`` with positions/sizes in
+ FIGURE FRACTIONS (0..1, origin top-left). ``kind`` is one of:
+
+ - ``"text"`` — ``x, y, text``; optional ``color``, ``fontsize``
+ - ``"circle"`` — ``x, y, r`` (``r`` as a fraction of
+ ``min(fig_width, fig_height)``); optional ``color``, ``linewidth``
+ - ``"rect"`` — ``x, y`` (centre), ``w, h``; optional ``color``,
+ ``linewidth``
+ - ``"arrow"`` — ``x, y`` (tail), ``u, v`` (vector); optional
+ ``color``, ``linewidth``
+
+ Any dict missing an ``id`` is assigned a fresh one.
+
+ Raises
+ ------
+ ValueError
+ If a marker has an unrecognised ``kind``.
+ """
+ out = []
+ for m in markers:
+ m = dict(m)
+ kind = m.get("kind")
+ if kind not in _FIGURE_MARKER_KINDS:
+ raise ValueError(
+ f"figure marker kind must be one of "
+ f"{sorted(_FIGURE_MARKER_KINDS)}, got {kind!r}")
+ if not m.get("id"):
+ m["id"] = str(_uuid.uuid4())[:8]
+ out.append(m)
+ self._figure_markers = out
+ self.figure_markers_json = json.dumps(out)
+
+ @property
+ def figure_markers(self) -> list:
+ """The current figure-level annotation list (list of dicts, fractions).
+
+ Returns a shallow copy so external mutation doesn't desync the trait;
+ call :meth:`set_figure_markers` to change it."""
+ return [dict(m) for m in self._figure_markers]
+
def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None:
"""Send a targeted widget-position update to JS (no image data)."""
payload = {"source": "python", "panel_id": panel_id,
diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js
index 03b13a62..65101abd 100644
--- a/anyplotlib/figure_esm.js
+++ b/anyplotlib/figure_esm.js
@@ -3,7 +3,7 @@
// Each panel gets its own three-canvas stack (plot / overlay / markers).
// Panels are drawn independently; only the changed panel's listener fires.
-function render({ model, el }) {
+function render({ model, el, onResize }) {
const dpr = window.devicePixelRatio || 1;
// ── shared plot-area padding (mirrors 1D drawing constants) ─────────────
@@ -519,6 +519,32 @@ function render({ model, el }) {
'position:absolute;top:8px;left:8px;pointer-events:none;z-index:20;overflow:visible;';
outerDiv.appendChild(insetsContainer);
+ // ── Callout overlay canvas ────────────────────────────────────────────────
+ // A figure-level canvas covering the grid content area (inside gridDiv's
+ // 8 px padding), used to draw mark_inset-style region indications: a dashed
+ // source rectangle on the parent panel + leader lines out to the inset.
+ // It spans the whole figure because leaders cross panel boundaries. Sits
+ // ABOVE panel content / insets but BELOW the maximized-inset float (z 45) and
+ // the resize handle (z 100); pointer-events:none so it never eats clicks.
+ const calloutCanvas = document.createElement('canvas');
+ calloutCanvas.style.cssText =
+ 'position:absolute;top:8px;left:8px;pointer-events:none;z-index:30;';
+ outerDiv.appendChild(calloutCanvas);
+ const calloutCtx = calloutCanvas.getContext('2d');
+
+ // ── Figure-level annotation overlay canvas ────────────────────────────────
+ // Content annotations (text / circle / rect / arrow) positioned in FIGURE
+ // FRACTIONS, drawn OVER all panels + insets + callouts. These are CONTENT
+ // (always exported), unlike the hover/selection outlines (DOM chrome, never
+ // exported). pointer-events default OFF so normal panel interaction is
+ // untouched; toggled ON only while edit_chrome is active so the markers can
+ // be dragged. Sits above callouts (z 30) but below the resize handle (z 100).
+ const figMarkerCanvas = document.createElement('canvas');
+ figMarkerCanvas.style.cssText =
+ 'position:absolute;top:8px;left:8px;pointer-events:none;z-index:35;';
+ outerDiv.appendChild(figMarkerCanvas);
+ const figMarkerCtx = figMarkerCanvas.getContext('2d');
+
// Inset layout constants
const INSET_TITLE_H = 22; // px — title bar height
const INSET_GAP = 8; // px — gap between stacked insets in same corner
@@ -780,6 +806,18 @@ function render({ model, el }) {
insetsContainer.style.width = (layout.fig_width || 640) + 'px';
insetsContainer.style.height = (layout.fig_height || 480) + 'px';
if (insetSpecs.length) _applyAllInsetStates(layout);
+ // Region indications depend on the browser having laid out the panels +
+ // insets (getBoundingClientRect must be real). During the synchronous
+ // applyLayout the DOM isn't measured yet, so draw once on the next frame.
+ if ((layout.indications || []).length) {
+ requestAnimationFrame(() => _drawCallouts());
+ } else {
+ _drawCallouts(); // clears any stale indication canvas
+ }
+ // Figure-level annotation layer tracks the figure size (fractions → px) and
+ // re-binds per-panel edit-chrome hover handlers for any newly-created cells.
+ _drawFigureMarkers();
+ _applyPanelChrome();
}
// ── _buildCanvasStack ─────────────────────────────────────────────────────
@@ -1201,9 +1239,10 @@ function render({ model, el }) {
// ── _applyAllInsetStates ──────────────────────────────────────────────────
// Positions every inset for the given layout snapshot.
- // Groups insets by corner, stacks with INSET_GAP spacing.
- // Minimized insets contribute only INSET_TITLE_H to the stack height.
- // Maximized insets float centred at z-index:45, outside the stack.
+ // Corner-placed insets are grouped by corner and stacked with INSET_GAP
+ // spacing (minimized ones contribute only INSET_TITLE_H to the stack).
+ // Anchor-placed insets (spec.anchor != null) float at their anchor fraction.
+ // Maximized insets float centred at z-index:45, outside any stack.
function _applyAllInsetStates(layout) {
const insetSpecs = layout.inset_specs || [];
const fw = layout.fig_width || 640;
@@ -1212,12 +1251,62 @@ function render({ model, el }) {
insetsContainer.style.width = fw + 'px';
insetsContainer.style.height = fh + 'px';
- // Group by corner, preserving insertion order
+ // Split anchored insets from corner insets. Anchored ones are placed
+ // directly at their fraction; corner ones stack per-corner as before.
+ const anchored = [];
const byCorner = {};
for (const spec of insetSpecs) {
+ if (spec.anchor) { anchored.push(spec); continue; }
(byCorner[spec.corner] = byCorner[spec.corner] || []).push(spec);
}
+ // ── anchor-placed insets ────────────────────────────────────────────────
+ for (const spec of anchored) {
+ const p = panels.get(spec.id);
+ if (!p || !p.isInset) continue;
+ const pw = spec.panel_width;
+ const ph = spec.panel_height;
+ const state = spec.inset_state;
+ const stackH = state === 'minimized' ? INSET_TITLE_H : INSET_TITLE_H + ph;
+
+ // Maximized floats centred (same treatment as corner insets below).
+ if (state === 'maximized') {
+ const mw = Math.round(fw * 0.72), mh = Math.round(fh * 0.72);
+ p.insetDiv.style.left = Math.round((fw - mw) / 2) + 'px';
+ p.insetDiv.style.top = Math.round((fh - mh) / 2) + 'px';
+ p.insetDiv.style.width = mw + 'px';
+ p.insetDiv.style.height = (INSET_TITLE_H + mh) + 'px';
+ p.insetDiv.style.zIndex = '45';
+ p.contentDiv.style.display = 'block';
+ p.contentDiv.style.height = mh + 'px';
+ if (p.pw !== mw || p.ph !== mh) {
+ p.pw = mw; p.ph = mh; _resizePanelDOM(spec.id, mw, mh); _redrawPanel(p);
+ }
+ continue;
+ }
+
+ // Normal / minimized: top-left corner sits at the anchor fraction,
+ // clamped so the inset stays inside the figure.
+ let left = Math.round(spec.anchor[0] * fw);
+ let top = Math.round(spec.anchor[1] * fh);
+ left = Math.max(0, Math.min(left, fw - pw));
+ top = Math.max(0, Math.min(top, fh - stackH));
+ p.insetDiv.style.left = left + 'px';
+ p.insetDiv.style.top = top + 'px';
+ p.insetDiv.style.width = pw + 'px';
+ p.insetDiv.style.height = stackH + 'px';
+ p.insetDiv.style.zIndex = '25';
+ if (state === 'minimized') {
+ p.contentDiv.style.display = 'none';
+ } else {
+ p.contentDiv.style.display = 'block';
+ p.contentDiv.style.height = ph + 'px';
+ if (p.pw !== pw || p.ph !== ph) {
+ p.pw = pw; p.ph = ph; _resizePanelDOM(spec.id, pw, ph); _redrawPanel(p);
+ }
+ }
+ }
+
for (const [corner, group] of Object.entries(byCorner)) {
const isBottom = corner.startsWith('bottom');
const isRight = corner.endsWith('right');
@@ -1261,8 +1350,538 @@ function render({ model, el }) {
offset += stackH + INSET_GAP;
}
}
+ // Inset positions changed → refresh leader lines / rects. Use the layout's
+ // own size (may be a live-resize snapshot ahead of the model).
+ _drawCallouts([fw, fh]);
+ }
+
+ // ── _drawCallouts ─────────────────────────────────────────────────────────
+ // Draw every mark_inset-style region indication onto the figure-level
+ // calloutCanvas. Called after any parent redraw / inset move / resize, so
+ // the dashed source rect tracks the parent's zoom+pan (region is mapped
+ // through _imgToCanvas2d every draw) and the leaders follow the inset's
+ // current DOM rect (leaders hide while the inset is minimized).
+ //
+ // Coordinate space: everything is mapped into calloutCanvas CSS px via each
+ // element's getBoundingClientRect relative to the canvas's own rect — so no
+ // layout math is duplicated and the leaders line up across panel boundaries.
+ function _drawCallouts(sizeOverride) {
+ let layout;
+ try { layout = JSON.parse(model.get('layout_json')); } catch (_) { return; }
+ const inds = layout.indications || [];
+
+ // Nothing to draw → collapse the overlay to a 0×0 backing store instead of
+ // sizing it to the full figure. An empty full-figure transparent canvas
+ // would otherwise be the LARGEST canvas in the DOM (it spans the whole
+ // figure, wider than any single panel), shadowing the panel content for
+ // any consumer/test that samples "the largest canvas". Setting width/height
+ // to 0 also clears any prior drawing, so no clearRect is needed here.
+ if (!inds.length) {
+ if (calloutCanvas.width || calloutCanvas.height) {
+ calloutCanvas.width = 0; calloutCanvas.height = 0;
+ calloutCanvas.style.width = '0px'; calloutCanvas.style.height = '0px';
+ }
+ return;
+ }
+
+ // Size the canvas to the figure content (fig size + no padding — it is
+ // already offset by 8 px like insetsContainer). During a live figure
+ // resize the model hasn't been written yet, so accept an explicit size.
+ const fw = sizeOverride ? sizeOverride[0]
+ : (Number(model.get('fig_width')) || layout.fig_width || 640);
+ const fh = sizeOverride ? sizeOverride[1]
+ : (Number(model.get('fig_height')) || layout.fig_height || 480);
+ if (calloutCanvas.width !== Math.round(fw * dpr) ||
+ calloutCanvas.height !== Math.round(fh * dpr)) {
+ calloutCanvas.style.width = fw + 'px';
+ calloutCanvas.style.height = fh + 'px';
+ calloutCanvas.width = Math.round(fw * dpr);
+ calloutCanvas.height = Math.round(fh * dpr);
+ }
+ calloutCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ calloutCtx.clearRect(0, 0, fw, fh);
+
+ const base = calloutCanvas.getBoundingClientRect();
+
+ for (const ind of inds) {
+ const parent = panels.get(ind.parent_id);
+ const inset = panels.get(ind.inset_id);
+ if (!parent || !parent.state || !inset || !inset.isInset) continue;
+
+ // ── source rectangle in parent-canvas px → figure px ─────────────────
+ // Map the region's four data-space corners through the parent's
+ // data→canvas transform (tracks zoom/pan), then offset by the parent
+ // markers canvas's position relative to the callout canvas.
+ const st = parent.state;
+ const imgW = parent.imgW || 1, imgH = parent.imgH || 1;
+ const [rx, ry, rw, rh] = ind.region;
+ const mkRect = (parent.markersCanvas || parent.plotCanvas).getBoundingClientRect();
+ const offX = mkRect.left - base.left;
+ const offY = mkRect.top - base.top;
+ const toFig = (dx, dy) => {
+ const [cx, cy] = _imgToCanvas2d(dx, dy, st, imgW, imgH);
+ return [offX + cx, offY + cy];
+ };
+ const [x0, y0] = toFig(rx, ry);
+ const [x1, y1] = toFig(rx + rw, ry);
+ const [x2, y2] = toFig(rx + rw, ry + rh);
+ const [x3, y3] = toFig(rx, ry + rh);
+ // Axis-aligned bounds of the (untransformed) rect in figure px.
+ const rL = Math.min(x0, x1, x2, x3), rR = Math.max(x0, x1, x2, x3);
+ const rT = Math.min(y0, y1, y2, y3), rB = Math.max(y0, y1, y2, y3);
+
+ const color = ind.color || '#ff9800';
+ const lw = ind.linewidth != null ? ind.linewidth : 1.5;
+ const dash = ind.linestyle === 'solid' ? []
+ : ind.linestyle === 'dotted' ? [2, 3]
+ : [6, 4]; // dashed (default)
+
+ calloutCtx.save();
+ calloutCtx.strokeStyle = color;
+ calloutCtx.lineWidth = lw;
+ calloutCtx.setLineDash(dash);
+
+ // Dashed source rectangle (drawn as the 4 transformed corners so it
+ // stays correct even if a future transform rotates/skews it). Clipped
+ // to the parent's image area so a zoomed-in region that runs off the
+ // panel doesn't paint over neighbouring panels (the leaders below are
+ // intentionally NOT clipped — they cross panel boundaries).
+ calloutCtx.save();
+ calloutCtx.beginPath();
+ calloutCtx.rect(offX, offY, imgW, imgH);
+ calloutCtx.clip();
+ calloutCtx.beginPath();
+ calloutCtx.moveTo(x0, y0);
+ calloutCtx.lineTo(x1, y1);
+ calloutCtx.lineTo(x2, y2);
+ calloutCtx.lineTo(x3, y3);
+ calloutCtx.closePath();
+ calloutCtx.stroke();
+ calloutCtx.restore();
+
+ // ── leader lines to the inset (skip while minimized) ─────────────────
+ const insSpec = (layout.inset_specs || []).find(s => s.id === ind.inset_id);
+ const minimized = insSpec && insSpec.inset_state === 'minimized';
+ if (!minimized) {
+ const iRect = inset.insetDiv.getBoundingClientRect();
+ const iL = iRect.left - base.left, iT = iRect.top - base.top;
+ const iR = iL + iRect.width, iB = iT + iRect.height;
+
+ // Pick which pair of rect corners face the inset (mark_inset loc1/loc2
+ // auto): compare the inset's centre to the rect's centre and connect
+ // the two corners on the facing side(s). Deterministic and simple.
+ const rcx = (rL + rR) / 2, rcy = (rT + rB) / 2;
+ const icx = (iL + iR) / 2, icy = (iT + iB) / 2;
+ const dxc = icx - rcx, dyc = icy - rcy;
+
+ // Rect corners (TL, TR, BR, BL) paired with the inset corner nearest
+ // to each, so each leader is short and non-crossing.
+ const rectCorners = {
+ TL: [rL, rT], TR: [rR, rT], BR: [rR, rB], BL: [rL, rB],
+ };
+ const insCorners = {
+ TL: [iL, iT], TR: [iR, iT], BR: [iR, iB], BL: [iL, iB],
+ };
+ // Choose the two rect corners on the side facing the inset.
+ let pair;
+ if (Math.abs(dxc) >= Math.abs(dyc)) {
+ // Inset predominantly left/right of the rect → connect that vertical edge.
+ pair = dxc >= 0 ? [['TR', 'TL'], ['BR', 'BL']]
+ : [['TL', 'TR'], ['BL', 'BR']];
+ } else {
+ // Inset predominantly above/below → connect that horizontal edge.
+ pair = dyc >= 0 ? [['BL', 'TL'], ['BR', 'TR']]
+ : [['TL', 'BL'], ['TR', 'BR']];
+ }
+ for (const [rc, ic] of pair) {
+ const [px, py] = rectCorners[rc];
+ const [qx, qy] = insCorners[ic];
+ calloutCtx.beginPath();
+ calloutCtx.moveTo(px, py);
+ calloutCtx.lineTo(qx, qy);
+ calloutCtx.stroke();
+ }
+ }
+ calloutCtx.restore();
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════
+ // Figure-level annotation layer + edit chrome (Report Builder edit mode)
+ // ═══════════════════════════════════════════════════════════════════════════
+ const EDIT_ACCENT = '#4b78d2'; // outline / handle accent for edit chrome
+ let _figMarkers = []; // parsed figure_markers_json (fractions)
+ let _figMarkerDrag = null; // active drag: {id, mode, snap, startMX/MY}
+
+ function _editOn() { return !!model.get('edit_chrome'); }
+
+ function _loadFigMarkers() {
+ try { _figMarkers = JSON.parse(model.get('figure_markers_json') || '[]') || []; }
+ catch (_) { _figMarkers = []; }
+ if (!Array.isArray(_figMarkers)) _figMarkers = [];
+ }
+
+ // Current figure content size in CSS px (grid content, no padding).
+ function _figSizePx() {
+ const fw = Number(model.get('fig_width')) || 640;
+ const fh = Number(model.get('fig_height')) || 480;
+ return [fw, fh];
+ }
+
+ // Fraction (0..1) → figure-content CSS px.
+ function _fracToFigPx(fx, fy, fw, fh) { return [fx * fw, fy * fh]; }
+
+ // ── _drawFigureMarkers ────────────────────────────────────────────────────
+ // Draw every figure-level annotation onto figMarkerCanvas. Fraction→px uses
+ // the figure content size; circle radius scales with min(fw,fh) so it stays
+ // round under a non-square figure. Handles are drawn only in edit mode.
+ function _drawFigureMarkers(sizeOverride, forceNoHandles) {
+ const [fw, fh] = sizeOverride || _figSizePx();
+ // No markers → collapse the overlay to a 0×0 backing store rather than
+ // sizing it to the full figure (same rationale as _drawCallouts: an empty
+ // full-figure transparent canvas is the largest canvas in the DOM and
+ // shadows panel content for "largest canvas" pixel probes). Zeroing the
+ // size also clears any prior drawing.
+ if (!_figMarkers.length) {
+ if (figMarkerCanvas.width || figMarkerCanvas.height) {
+ figMarkerCanvas.width = 0; figMarkerCanvas.height = 0;
+ figMarkerCanvas.style.width = '0px'; figMarkerCanvas.style.height = '0px';
+ }
+ return;
+ }
+ if (figMarkerCanvas.width !== Math.round(fw * dpr) ||
+ figMarkerCanvas.height !== Math.round(fh * dpr)) {
+ figMarkerCanvas.style.width = fw + 'px';
+ figMarkerCanvas.style.height = fh + 'px';
+ figMarkerCanvas.width = Math.round(fw * dpr);
+ figMarkerCanvas.height = Math.round(fh * dpr);
+ }
+ figMarkerCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ figMarkerCtx.clearRect(0, 0, fw, fh);
+ const rmin = Math.min(fw, fh);
+ const edit = _editOn() && !forceNoHandles;
+
+ for (const m of _figMarkers) {
+ const color = m.color || EDIT_ACCENT;
+ const lw = m.linewidth != null ? m.linewidth : 2;
+ figMarkerCtx.save();
+ figMarkerCtx.strokeStyle = color;
+ figMarkerCtx.fillStyle = color;
+ figMarkerCtx.lineWidth = lw;
+ if (m.kind === 'text') {
+ const [px, py] = _fracToFigPx(m.x, m.y, fw, fh);
+ const fs = m.fontsize || 16;
+ figMarkerCtx.font = `${fs}px sans-serif`;
+ figMarkerCtx.textAlign = 'left';
+ figMarkerCtx.textBaseline = 'top';
+ figMarkerCtx.fillText(m.text || '', px, py);
+ if (edit) _drawHandle2d(figMarkerCtx, px, py, color);
+ } else if (m.kind === 'circle') {
+ const [px, py] = _fracToFigPx(m.x, m.y, fw, fh);
+ const r = (m.r || 0) * rmin;
+ figMarkerCtx.beginPath(); figMarkerCtx.arc(px, py, r, 0, Math.PI*2); figMarkerCtx.stroke();
+ if (edit) _drawHandle2d(figMarkerCtx, px, py, color);
+ } else if (m.kind === 'rect') {
+ // x,y = CENTRE; w,h = fractions of figure size.
+ const [cx, cy] = _fracToFigPx(m.x, m.y, fw, fh);
+ const rw = (m.w || 0) * fw, rh = (m.h || 0) * fh;
+ figMarkerCtx.strokeRect(cx - rw/2, cy - rh/2, rw, rh);
+ if (edit) _drawHandle2d(figMarkerCtx, cx, cy, color);
+ } else if (m.kind === 'arrow') {
+ const [tx, ty] = _fracToFigPx(m.x, m.y, fw, fh);
+ const [hx, hy] = _fracToFigPx(m.x + (m.u||0), m.y + (m.v||0), fw, fh);
+ const ang = Math.atan2(hy-ty, hx-tx), HL = 12;
+ figMarkerCtx.beginPath(); figMarkerCtx.moveTo(tx, ty); figMarkerCtx.lineTo(hx, hy); figMarkerCtx.stroke();
+ figMarkerCtx.beginPath(); figMarkerCtx.moveTo(hx, hy);
+ figMarkerCtx.lineTo(hx-HL*Math.cos(ang-Math.PI/6), hy-HL*Math.sin(ang-Math.PI/6));
+ figMarkerCtx.lineTo(hx-HL*Math.cos(ang+Math.PI/6), hy-HL*Math.sin(ang+Math.PI/6));
+ figMarkerCtx.closePath(); figMarkerCtx.fill();
+ if (edit) { _drawHandle2d(figMarkerCtx, tx, ty, color); _drawHandle2d(figMarkerCtx, hx, hy, color); }
+ }
+ figMarkerCtx.restore();
+ }
+ }
+
+ // Mouse (figMarkerCanvas CSS px) for a pointer event.
+ function _figMarkerMouse(e) {
+ const r = figMarkerCanvas.getBoundingClientRect();
+ const sx = r.width ? figMarkerCanvas.width / dpr / r.width : 1;
+ const sy = r.height ? figMarkerCanvas.height / dpr / r.height : 1;
+ return [(e.clientX - r.left) * sx, (e.clientY - r.top) * sy];
+ }
+
+ // Hit-test the figure markers (topmost first). Returns {id, mode} or null.
+ function _figMarkerHitTest(mx, my) {
+ const [fw, fh] = _figSizePx();
+ const rmin = Math.min(fw, fh);
+ const HR = 9;
+ for (let i = _figMarkers.length - 1; i >= 0; i--) {
+ const m = _figMarkers[i];
+ if (m.kind === 'arrow') {
+ const [tx, ty] = _fracToFigPx(m.x, m.y, fw, fh);
+ const [hx, hy] = _fracToFigPx(m.x + (m.u||0), m.y + (m.v||0), fw, fh);
+ if (Math.hypot(mx-hx, my-hy) <= HR) return { id: m.id, mode: 'resize_head' };
+ if (Math.hypot(mx-tx, my-ty) <= HR) return { id: m.id, mode: 'move' };
+ if (_distToSegment2d(mx, my, tx, ty, hx, hy) <= HR) return { id: m.id, mode: 'move' };
+ } else if (m.kind === 'circle') {
+ const [px, py] = _fracToFigPx(m.x, m.y, fw, fh);
+ const r = (m.r || 0) * rmin;
+ if (Math.abs(Math.hypot(mx-px, my-py) - r) <= Math.max(HR, r*0.18) ||
+ Math.hypot(mx-px, my-py) <= HR) return { id: m.id, mode: 'move' };
+ } else if (m.kind === 'rect') {
+ const [cx, cy] = _fracToFigPx(m.x, m.y, fw, fh);
+ const rw = (m.w||0) * fw, rh = (m.h||0) * fh;
+ if (mx >= cx-rw/2-HR && mx <= cx+rw/2+HR && my >= cy-rh/2-HR && my <= cy+rh/2+HR)
+ return { id: m.id, mode: 'move' };
+ } else if (m.kind === 'text') {
+ const [px, py] = _fracToFigPx(m.x, m.y, fw, fh);
+ // Approximate text box: measure width, one line tall.
+ figMarkerCtx.save();
+ figMarkerCtx.font = `${m.fontsize||16}px sans-serif`;
+ const tw = figMarkerCtx.measureText(m.text || '').width;
+ figMarkerCtx.restore();
+ const th = m.fontsize || 16;
+ if (mx >= px-HR && mx <= px+tw+HR && my >= py-HR && my <= py+th+HR)
+ return { id: m.id, mode: 'move' };
+ }
+ }
+ return null;
+ }
+
+ function _figMarkerById(id) { return _figMarkers.find(m => m.id === id) || null; }
+
+ // Persist the current _figMarkers to the model (no event emission).
+ function _writeFigMarkers() {
+ model.set('figure_markers_json', JSON.stringify(_figMarkers));
+ model.save_changes();
}
+ // Apply a drag delta (in figure fractions) to the dragged marker.
+ function _doFigMarkerDrag(e) {
+ const d = _figMarkerDrag; if (!d) return;
+ const [fw, fh] = _figSizePx();
+ const [mx, my] = _figMarkerMouse(e);
+ const m = _figMarkerById(d.id); if (!m) return;
+ const s = d.snap;
+ const dfx = (mx - d.startMX) / fw, dfy = (my - d.startMY) / fh;
+ if (d.mode === 'move') {
+ m.x = s.x + dfx; m.y = s.y + dfy;
+ } else if (d.mode === 'resize_head' && m.kind === 'arrow') {
+ m.u = (mx / fw) - m.x; m.v = (my / fh) - m.y;
+ }
+ _drawFigureMarkers();
+ e.preventDefault();
+ }
+
+ // ── Panel drag-swap (edit mode) ───────────────────────────────────────────
+ // A ~16px "⋮⋮" move grip in each panel cell's top-left corner (edit mode
+ // only). Dragging it starts a swap: the source panel dims, the panel under
+ // the cursor is highlighted as the drop target, and releasing over a
+ // DIFFERENT panel emits a figure-level {panel_swap} event. anyplotlib does
+ // NOT reorder anything itself — the host swaps + rebuilds the layout. The
+ // grip only exists / captures events under edit_chrome; off-edit it is
+ // display:none so it never steals a widget drag or a panel selection click.
+ const GRIP_PX = 16;
+ let _panelSwapDrag = null; // {sourceId, startX, startY} while dragging
+
+ function _panelAt(clientX, clientY) {
+ // Topmost panel cell containing the point (grid cells don't overlap).
+ for (const p of panels.values()) {
+ const cell = p.cell; if (!cell) continue;
+ // Skip insets — swap is grid-panel only.
+ if (p.isInset) continue;
+ const r = cell.getBoundingClientRect();
+ if (clientX >= r.left && clientX <= r.right &&
+ clientY >= r.top && clientY <= r.bottom) return p;
+ }
+ return null;
+ }
+
+ function _clearSwapFeedback() {
+ for (const p of panels.values()) {
+ if (!p.cell) continue;
+ p.cell.style.opacity = '';
+ // Restore the panel's normal chrome (selection/hover outline).
+ }
+ _applyPanelChrome();
+ }
+
+ function _ensureGrip(p) {
+ if (p.grip || !p.cell) return;
+ const g = document.createElement('div');
+ g.className = 'apl-panel-grip';
+ // Dark-theme dotted-grid affordance; grab cursor; sits above the canvas
+ // stack (canvases are z 5) but is display:none unless edit mode is on.
+ g.style.cssText =
+ `position:absolute;top:2px;left:2px;width:${GRIP_PX}px;height:${GRIP_PX}px;` +
+ `z-index:12;cursor:grab;display:none;border-radius:3px;` +
+ `align-items:center;justify-content:center;font-size:12px;line-height:1;` +
+ `color:${theme.fg||'#cdd6f4'};background:${theme.bg||'#1e1e2e'};` +
+ `border:1px solid ${theme.border||'#44475a'};` +
+ `box-shadow:0 1px 3px rgba(0,0,0,0.4);user-select:none;`;
+ g.textContent = '⠿'; // ⠿ braille dots — a clean "grab" grid glyph
+ g.title = 'Drag to swap this panel';
+ g.addEventListener('mousedown', (e) => {
+ if (!_editOn() || e.button !== 0) return;
+ // Grip wins over panel selection / marker grab within its own bounds.
+ e.preventDefault(); e.stopPropagation();
+ _panelSwapDrag = { sourceId: p.id, startX: e.clientX, startY: e.clientY };
+ g.style.cursor = 'grabbing';
+ p.cell.style.opacity = '0.4'; // ghost the source
+ });
+ p.cell.appendChild(g);
+ p.grip = g;
+ }
+
+ // Global move / up handlers for an in-progress panel swap.
+ document.addEventListener('mousemove', (e) => {
+ if (!_panelSwapDrag) return;
+ const target = _panelAt(e.clientX, e.clientY);
+ // Highlight the drop target (not the source) with a solid accent outline.
+ for (const p of panels.values()) {
+ if (!p.cell || p.isInset) continue;
+ if (p.id === _panelSwapDrag.sourceId) continue;
+ if (target && p.id === target.id) {
+ p.cell.style.outline = `2px solid ${EDIT_ACCENT}`;
+ p.cell.style.outlineOffset = '-2px';
+ } else {
+ // Clear any transient swap highlight (leave the source alone).
+ p.cell.style.outline = '';
+ p.cell.style.outlineOffset = '';
+ }
+ }
+ e.preventDefault();
+ });
+
+ document.addEventListener('mouseup', (e) => {
+ if (!_panelSwapDrag) return;
+ const d = _panelSwapDrag; _panelSwapDrag = null;
+ if (d.sourceId && panels.get(d.sourceId) && panels.get(d.sourceId).grip)
+ panels.get(d.sourceId).grip.style.cursor = 'grab';
+ const target = _panelAt(e.clientX, e.clientY);
+ // Emit ONLY when released over a different, non-inset panel.
+ if (target && !target.isInset && target.id !== d.sourceId) {
+ _emitEvent('', 'pointer_up', null, {
+ panel_swap: true,
+ source_panel_id: d.sourceId,
+ target_panel_id: target.id,
+ ..._pointerFields(e), button: e.button,
+ });
+ }
+ // Released on the source panel / empty space → cancel cleanly (no emit).
+ _clearSwapFeedback();
+ e.preventDefault();
+ });
+
+ // ── Edit chrome: per-panel hover + selection outlines ─────────────────────
+ // Purely JS-local DOM styling; never exported. Hover uses a dashed accent
+ // outline via a data-attr-driven inline style; selection a solid one.
+ function _applyPanelChrome() {
+ const edit = _editOn();
+ const sel = model.get('selected_panel') || '';
+ for (const p of panels.values()) {
+ const cell = p.cell; if (!cell) continue;
+ // Move grip: created lazily, shown only in edit mode on grid panels.
+ if (!p.isInset) {
+ _ensureGrip(p);
+ if (p.grip) p.grip.style.display = edit ? 'flex' : 'none';
+ }
+ // Selection outline (persistent, solid) wins over hover.
+ // outline-offset is fully NEGATIVE (−width) so the whole ring is inset
+ // INSIDE the cell bounds — otherwise an edge/corner panel's outline is
+ // clipped at the figure's right/bottom edge (half the stroke spills out).
+ if (edit && p.id === sel) {
+ cell.style.outline = `2px solid ${EDIT_ACCENT}`;
+ cell.style.outlineOffset = '-2px';
+ } else if (!p._editHover || !edit) {
+ cell.style.outline = '';
+ cell.style.outlineOffset = '';
+ }
+ // (Re)bind hover handlers exactly once per cell.
+ if (!p._editHoverBound) {
+ p._editHoverBound = true;
+ cell.addEventListener('mouseenter', () => {
+ p._editHover = true;
+ if (_editOn() && p.id !== (model.get('selected_panel') || '')) {
+ cell.style.outline = `2px dashed ${EDIT_ACCENT}`;
+ cell.style.outlineOffset = '-2px';
+ }
+ });
+ cell.addEventListener('mouseleave', () => {
+ p._editHover = false;
+ if (p.id !== (model.get('selected_panel') || '')) {
+ cell.style.outline = '';
+ cell.style.outlineOffset = '';
+ }
+ });
+ }
+ }
+ }
+
+ // Edit mode redraws the marker layer (so handles appear/disappear) and the
+ // panel chrome. figMarkerCanvas ALWAYS stays pointer-events:none — a
+ // full-figure canvas set to `auto` would sit above every panel's overlay
+ // (z 35 > z 5) and swallow all panel interaction. Instead the outerDiv
+ // capture handler below hit-tests markers itself, so a marker is grabbable
+ // where it lies and panels stay fully interactive everywhere else.
+ function _applyEditMode() {
+ _applyPanelChrome();
+ _drawFigureMarkers();
+ }
+
+ document.addEventListener('mousemove', (e) => {
+ if (!_figMarkerDrag) return;
+ _doFigMarkerDrag(e);
+ });
+ document.addEventListener('mouseup', (e) => {
+ if (!_figMarkerDrag) return;
+ const d = _figMarkerDrag; _figMarkerDrag = null;
+ const m = _figMarkerById(d.id);
+ // Persist + emit a figure_marker pointer_up carrying the updated FRACTIONS.
+ _writeFigMarkers();
+ const upd = { figure_marker: true, marker_id: d.id };
+ if (m) { for (const k of ['x','y','u','v','r','w','h']) if (m[k] !== undefined) upd[k] = m[k]; }
+ _emitEvent('', 'pointer_up', null, { ...upd, ..._pointerFields(e), button: e.button });
+ e.preventDefault();
+ });
+
+ // ── Edit-mode mousedown capture on outerDiv ───────────────────────────────
+ // Runs BEFORE any panel handler (capture phase). Two jobs, both edit-only:
+ // 1. If a figure marker is under the cursor → start a marker drag and
+ // STOP propagation so the panel underneath doesn't also react.
+ // 2. Else, if the target is NOT inside any panel cell / inset / chrome →
+ // emit a figure-background pointer_down.
+ // A plain click inside a panel does neither (its own path handles it).
+ outerDiv.addEventListener('mousedown', (e) => {
+ if (!_editOn() || e.button !== 0) return;
+
+ // (0) A panel move-grip owns its own bounds — let its own mousedown handler
+ // start the swap; do not marker-grab or background-emit here.
+ if (e.target && e.target.classList &&
+ e.target.classList.contains('apl-panel-grip')) return;
+
+ const [mx, my] = _figMarkerMouse(e);
+
+ // (1) Marker grab — highest priority, even over a panel underneath.
+ const hit = _figMarkerHitTest(mx, my);
+ if (hit) {
+ const m = _figMarkerById(hit.id);
+ _figMarkerDrag = { id: hit.id, mode: hit.mode, snap: {...m},
+ startMX: mx, startMY: my };
+ e.preventDefault(); e.stopPropagation();
+ return;
+ }
+
+ // (2) Background detection.
+ const t = e.target;
+ for (const p of panels.values()) {
+ if (p.cell && p.cell.contains(t)) return; // inside a panel / inset
+ }
+ if (t === resizeHandle || (helpBtn && helpBtn.contains(t)) ||
+ (helpCard && helpCard.contains(t))) return;
+ _emitEvent('', 'pointer_down', null, { figure_background: true, ..._pointerFields(e), button: e.button });
+ }, true);
+
function _resizePanelDOM(id, pw, ph) {
const p = panels.get(id);
if (!p) return;
@@ -2194,10 +2813,19 @@ function render({ model, el }) {
}
}
- function drawOverlay2d(p) {
+ // opts (optional): { ctx, imgW, imgH, forceNoHandles }
+ // ctx/imgW/imgH default to the panel's own live overlay canvas/dims — pass
+ // an offscreen ctx + matching dims to redraw the SAME widgets elsewhere
+ // (e.g. exportPNG's handle-free export redraw; see _drawOverlay2dNoHandles).
+ // forceNoHandles suppresses the handle dots regardless of each widget's
+ // own show_handles (mirrors _drawFigureMarkers(sizeOverride, forceNoHandles)).
+ function drawOverlay2d(p, opts) {
const st=p.state; if(!st) return;
- const {pw,ph,ovCtx} = p;
- const imgW=p.imgW||Math.max(1,pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,ph-PAD_T-PAD_B);
+ const o = opts || {};
+ const {pw,ph} = p;
+ const ovCtx = o.ctx || p.ovCtx;
+ const imgW = o.imgW || p.imgW || Math.max(1,pw-PAD_L-PAD_R);
+ const imgH = o.imgH || p.imgH || Math.max(1,ph-PAD_T-PAD_B);
ovCtx.clearRect(0,0,imgW,imgH);
const widgets=st.overlay_widgets||[];
const scale=_imgScale2d(st,imgW,imgH);
@@ -2206,28 +2834,39 @@ function render({ model, el }) {
ovCtx.beginPath(); ovCtx.rect(vr.x, vr.y, vr.w, vr.h); ovCtx.clip();
for(const w of widgets){
if(w.visible === false) continue;
+ // Handle dots are drawn unless the widget opts out (show_handles:false),
+ // or the caller forces them off entirely (export redraw).
+ const _handles = !o.forceNoHandles && w.show_handles !== false;
ovCtx.save(); ovCtx.strokeStyle=w.color||'#00e5ff'; ovCtx.lineWidth=2;
if(w.type==='circle'){
const [ccx,ccy]=_imgToCanvas2d(w.cx,w.cy,st,imgW,imgH);
ovCtx.beginPath(); ovCtx.arc(ccx,ccy,w.r*scale,0,Math.PI*2); ovCtx.stroke();
- _drawHandle2d(ovCtx,ccx+w.r*scale,ccy,w.color);
+ if(_handles){
+ // Centre node = move; east-point node = resize radius.
+ _drawHandle2d(ovCtx,ccx,ccy,w.color);
+ _drawHandle2d(ovCtx,ccx+w.r*scale,ccy,w.color);
+ }
} else if(w.type==='annular'){
const [ccx,ccy]=_imgToCanvas2d(w.cx,w.cy,st,imgW,imgH);
ovCtx.beginPath();ovCtx.arc(ccx,ccy,w.r_outer*scale,0,Math.PI*2);ovCtx.stroke();
ovCtx.beginPath();ovCtx.arc(ccx,ccy,w.r_inner*scale,0,Math.PI*2);ovCtx.stroke();
- _drawHandle2d(ovCtx,ccx+w.r_outer*scale,ccy,w.color);
- _drawHandle2d(ovCtx,ccx+w.r_inner*scale,ccy-w.r_inner*scale*0.3,w.color);
+ if(_handles){
+ _drawHandle2d(ovCtx,ccx+w.r_outer*scale,ccy,w.color);
+ _drawHandle2d(ovCtx,ccx+w.r_inner*scale,ccy-w.r_inner*scale*0.3,w.color);
+ }
} else if(w.type==='rectangle'){
const [rx,ry]=_imgToCanvas2d(w.x,w.y,st,imgW,imgH);
const rw=w.w*scale, rh=w.h*scale;
ovCtx.strokeRect(rx,ry,rw,rh);
- _drawHandle2d(ovCtx,rx,ry,w.color);_drawHandle2d(ovCtx,rx+rw,ry,w.color);
- _drawHandle2d(ovCtx,rx,ry+rh,w.color);_drawHandle2d(ovCtx,rx+rw,ry+rh,w.color);
+ if(_handles){
+ _drawHandle2d(ovCtx,rx,ry,w.color);_drawHandle2d(ovCtx,rx+rw,ry,w.color);
+ _drawHandle2d(ovCtx,rx,ry+rh,w.color);_drawHandle2d(ovCtx,rx+rw,ry+rh,w.color);
+ }
} else if(w.type==='crosshair'){
const [ccx,ccy]=_imgToCanvas2d(w.cx,w.cy,st,imgW,imgH);
ovCtx.beginPath();ovCtx.moveTo(0,ccy);ovCtx.lineTo(imgW,ccy);ovCtx.stroke();
ovCtx.beginPath();ovCtx.moveTo(ccx,0);ovCtx.lineTo(ccx,imgH);ovCtx.stroke();
- ovCtx.beginPath();ovCtx.arc(ccx,ccy,4,0,Math.PI*2);ovCtx.fillStyle=w.color||'#00e5ff';ovCtx.fill();
+ if(_handles){ovCtx.beginPath();ovCtx.arc(ccx,ccy,4,0,Math.PI*2);ovCtx.fillStyle=w.color||'#00e5ff';ovCtx.fill();}
} else if(w.type==='polygon'){
const verts=w.vertices||[];
if(verts.length>=2){
@@ -2236,13 +2875,27 @@ function render({ model, el }) {
ovCtx.moveTo(px0,py0);
for(let k=1;k @location(0) vec4 {
: (m==='resize_br'||m==='resize_tl') ? 'nwse-resize'
: (m==='resize_bl'||m==='resize_tr') ? 'nesw-resize'
: (m==='resize_r'||m==='resize_ir') ? 'ew-resize'
+ : (m==='resize_head'||m==='resize_tail') ? 'crosshair'
: m.startsWith('vertex_') ? 'crosshair'
: 'move';
} else if(!p.isPanning){
@@ -5964,11 +6618,34 @@ fn fs(in : VsOut) -> @location(0) vec4 {
const [lx, ly] = _imgToCanvas2d(w.x, w.y, st, imgW, imgH);
if (Math.hypot(mx-lx, my-ly) <= HR + 6)
return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my };
+
+ } else if (w.type === 'arrow') {
+ const [tx, ty] = _imgToCanvas2d(w.x, w.y, st, imgW, imgH);
+ const [hx, hy] = _imgToCanvas2d(w.x + w.u, w.y + w.v, st, imgW, imgH);
+ // head handle → re-aim the arrow (head moves, tail fixed)
+ if (Math.hypot(mx-hx, my-hy) <= HR)
+ return { idx:i, mode:'resize_head', snapW:{...w}, startMX:mx, startMY:my };
+ // tail handle → reshape (tail moves, HEAD stays anchored)
+ if (Math.hypot(mx-tx, my-ty) <= HR)
+ return { idx:i, mode:'resize_tail', snapW:{...w}, startMX:mx, startMY:my };
+ // near the shaft → move whole arrow
+ if (_distToSegment2d(mx, my, tx, ty, hx, hy) <= HR)
+ return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my };
}
}
return null;
}
+ // Perpendicular distance from point (px,py) to the segment (ax,ay)-(bx,by).
+ function _distToSegment2d(px, py, ax, ay, bx, by) {
+ const dx = bx - ax, dy = by - ay;
+ const len2 = dx*dx + dy*dy;
+ if (len2 === 0) return Math.hypot(px-ax, py-ay);
+ let t = ((px-ax)*dx + (py-ay)*dy) / len2;
+ t = Math.max(0, Math.min(1, t));
+ return Math.hypot(px - (ax + t*dx), py - (ay + t*dy));
+ }
+
function _pointInPolygon2d(mx, my, verts, st, imgW, imgH) {
let inside = false;
const cverts = verts.map(v => _imgToCanvas2d(v[0], v[1], st, imgW, imgH));
@@ -6045,6 +6722,19 @@ fn fs(in : VsOut) -> @location(0) vec4 {
}
} else if (w.type === 'label') {
w.x = s.x + dix; w.y = s.y + diy;
+ } else if (w.type === 'arrow') {
+ if (d.mode === 'move') {
+ w.x = s.x + dix; w.y = s.y + diy;
+ } else if (d.mode === 'resize_head') {
+ // head follows the cursor → u,v = imgMouse − tail (tail fixed)
+ w.u = imgMX - s.x; w.v = imgMY - s.y;
+ } else if (d.mode === 'resize_tail') {
+ // tail follows the cursor, HEAD stays anchored: the fixed head is
+ // (s.x + s.u, s.y + s.v). x,y = tail; u,v = head − tail so the head
+ // point is invariant under the drag.
+ w.x = s.x + dix; w.y = s.y + diy;
+ w.u = s.u - dix; w.v = s.v - diy;
+ }
}
drawOverlay2d(p);
@@ -6330,6 +7020,7 @@ fn fs(in : VsOut) -> @location(0) vec4 {
_resizePanelDOM(p.id, p.pw, p.ph);
_redrawPanel(p);
}
+ _drawFigureMarkers([nfw, nfh]);
}
document.addEventListener('mousemove', (e) => {
@@ -6346,6 +7037,8 @@ fn fs(in : VsOut) -> @location(0) vec4 {
_rafPending = false;
if (!isResizing) return;
_applyFigResizeDOM(_pendingNfw, _pendingNfh);
+ // Track the figure-marker overlay to the live drag size.
+ _drawFigureMarkers([_pendingNfw, _pendingNfh]);
});
}
e.preventDefault();
@@ -7025,10 +7718,16 @@ fn fs(in : VsOut) -> @location(0) vec4 {
else if(p.kind==='3d') draw3d(p);
else if(p.kind==='bar') drawBar(p);
else draw1d(p);
+ // Region indications track the parent's zoom/pan: any panel redraw
+ // refreshes ALL callouts (cheap no-op when there are none). Redrawing all
+ // (not just this panel's) keeps rect + leaders consistent when a parent
+ // and its inset live in different panels.
+ _drawCallouts();
}
function redrawAll() {
for(const p of panels.values()) _redrawPanel(p);
+ _drawCallouts();
}
// ── PNG export ────────────────────────────────────────────────────────────
@@ -7095,21 +7794,62 @@ fn fs(in : VsOut) -> @location(0) vec4 {
// Skips hidden (display:none) or zero-area elements; catches per-element
// draw failures (a tainted / uninitialised canvas) so one bad layer never
// aborts the whole export.
- const _drawEl = (elm) => {
+ //
+ // Coordinates are SNAPPED to output pixels: dx/dy round the element's
+ // top-left, and dw/dh are derived from the ROUNDED right/bottom edges
+ // (not by rounding width/height directly). Two adjacent elements that
+ // share a boundary (e.g. neighbouring grid panels) therefore round to the
+ // exact same output-pixel edge on both sides, instead of each picking up
+ // an independent +/-1 px from its own width/height rounding — which is
+ // what caused 1 px seams / overlaps at fractional scale (e.g. scale:1.25).
+ // `rectElm` (defaults to `elm`) supplies the on-screen position/visibility
+ // — used to blit an offscreen scratch canvas (no DOM rect of its own) at
+ // the position of the live element it stands in for (see
+ // _drawPanelWidgetsNoHandles below).
+ const _drawEl = (elm, rectElm) => {
if (!elm) return;
- const cs = window.getComputedStyle(elm);
+ const re = rectElm || elm;
+ const cs = window.getComputedStyle(re);
if (cs.display === 'none' || cs.visibility === 'hidden') return;
// A canvas with 0×0 backing store has nothing to blit.
if (elm.width === 0 || elm.height === 0) return;
- const r = elm.getBoundingClientRect();
+ const r = re.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return;
- const dx = (r.left - rootRect.left) * outScale;
- const dy = (r.top - rootRect.top) * outScale;
- const dw = r.width * outScale;
- const dh = r.height * outScale;
+ const left = (r.left - rootRect.left) * outScale;
+ const top = (r.top - rootRect.top) * outScale;
+ const right = (r.right - rootRect.left) * outScale;
+ const bottom = (r.bottom - rootRect.top) * outScale;
+ const dx = Math.round(left);
+ const dy = Math.round(top);
+ const dw = Math.round(right) - dx;
+ const dh = Math.round(bottom) - dy;
+ if (dw <= 0 || dh <= 0) return;
try { octx.drawImage(elm, dx, dy, dw, dh); } catch (_) {}
};
+ // Widget handle/node dots are edit-mode chrome (like figure-marker handles
+ // — see _drawFigureMarkers(null, true) below), not exported content. For
+ // includeWidgets:true, redraw the panel's widgets onto a scratch canvas
+ // (same backing size + DPR transform as the live overlayCanvas) with
+ // handles forced off, and blit THAT (positioned via the live
+ // overlayCanvas's own on-screen rect) instead of the live p.overlayCanvas
+ // — so the on-screen canvas (and any handles currently visible on it) is
+ // never touched, no flicker.
+ const _drawPanelWidgetsNoHandles = (p) => {
+ if (!p.overlayCanvas || !p.ovCtx) return null;
+ const oc = p.overlayCanvas;
+ if (oc.width === 0 || oc.height === 0) return null;
+ const scratch = document.createElement('canvas');
+ scratch.width = oc.width; scratch.height = oc.height;
+ const sctx = scratch.getContext('2d');
+ if (!sctx) return null;
+ sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ try {
+ drawOverlay2d(p, { ctx: sctx, imgW: p.imgW, imgH: p.imgH, forceNoHandles: true });
+ } catch (_) { return null; }
+ return scratch;
+ };
+
// Per-panel canvas stack, in visual z-order (see _buildCanvasStack).
const _drawPanel = (p) => {
_drawEl(p.gpuCanvas); // z 0 — GPU image raster (beneath)
@@ -7117,16 +7857,58 @@ fn fs(in : VsOut) -> @location(0) vec4 {
_drawEl(p.yAxisCanvas); // physical axis gutters (no explicit z)
_drawEl(p.xAxisCanvas);
_drawEl(p.cbCanvas); // colorbar strip + label
- if (includeWidgets) _drawEl(p.overlayCanvas); // z 5 — interactive widgets
+ if (includeWidgets) { // z 5 — interactive widgets, handles suppressed
+ const scratch = _drawPanelWidgetsNoHandles(p);
+ if (scratch) _drawEl(scratch, p.overlayCanvas); else _drawEl(p.overlayCanvas);
+ }
_drawEl(p.markersCanvas); // z 6 — static marker groups
_drawEl(p.scaleBar); // z 7 — physical scale bar
_drawEl(p.titleCanvas); // z 8 — 2-D title strip
// (statusBar / statsDiv are hover chrome — intentionally excluded.)
};
+ // Inset title bars are plain DOM (titleSpan text node), not a canvas, so
+ // _drawEl (which only blits drawImage-able elements) never captures them.
+ // Draw the title text directly onto the output canvas, positioned at the
+ // titleBar's on-screen rect, approximating the CSS declared in
+ // _createInsetDOM (11px sans-serif, theme.tickText colour, left-padded).
+ const _drawInsetTitle = (p) => {
+ if (!p.isInset || !p.titleBar || !p.insetSpec) return;
+ const title = p.insetSpec.title;
+ if (!title) return;
+ const cs = window.getComputedStyle(p.titleBar);
+ if (cs.display === 'none' || cs.visibility === 'hidden') return;
+ const r = p.titleBar.getBoundingClientRect();
+ if (r.width === 0 || r.height === 0) return;
+ const left = (r.left - rootRect.left) * outScale;
+ const top = (r.top - rootRect.top) * outScale;
+ octx.save();
+ octx.fillStyle = theme.tickText;
+ octx.textBaseline = 'middle';
+ octx.textAlign = 'left';
+ // 8px left padding matches titleBar's `padding:0 5px 0 8px`.
+ _drawTex(octx, title, left + 8 * outScale, top + r.height * outScale / 2,
+ 11 * outScale, { align: 'left' });
+ octx.restore();
+ };
+
// Grid panels first, then insets on top (insets sit above grid content).
for (const p of panels.values()) if (!p.isInset) _drawPanel(p);
- for (const p of panels.values()) if (p.isInset) _drawPanel(p);
+ for (const p of panels.values()) if (p.isInset) { _drawPanel(p); _drawInsetTitle(p); }
+
+ // Callout indications (dashed source rects + leader lines) sit above panel
+ // content and insets — composite them last so they overlay everything.
+ // Force a fresh draw first so the canvas reflects the current view.
+ try { _drawCallouts(); } catch (_) {}
+ _drawEl(calloutCanvas);
+
+ // Figure-level annotation layer is CONTENT — always composited (handles
+ // suppressed for export). Hover/selection outlines are DOM styles and are
+ // never on a canvas, so they are inherently excluded.
+ try { _drawFigureMarkers(null, true); } catch (_) {}
+ _drawEl(figMarkerCanvas);
+ // Restore the on-screen draw (handles visible again if in edit mode).
+ try { _drawFigureMarkers(); } catch (_) {}
let dataUrl;
try {
@@ -7175,13 +7957,32 @@ fn fs(in : VsOut) -> @location(0) vec4 {
if (typeof ResizeObserver !== 'undefined') {
let _lastCellW = 0;
+ let _lastRoW = 0, _lastRoH = 0;
+ let _roTimer = null;
new ResizeObserver(entries => {
- // React only to width changes to avoid a feedback loop: our own
- // scaleWrap height updates would otherwise re-trigger the observer.
- const cellW = entries[0].contentRect.width;
- if (cellW === _lastCellW) return;
- _lastCellW = cellW;
- requestAnimationFrame(_applyScale);
+ const cr = entries[0].contentRect;
+ const cellW = cr.width, cellH = cr.height;
+ // (1) Jupyter fit-to-cell down-scale — width-only to avoid a feedback
+ // loop (our own scaleWrap height updates would re-trigger the observer).
+ if (cellW !== _lastCellW) {
+ _lastCellW = cellW;
+ requestAnimationFrame(_applyScale);
+ }
+ // (2) Container-resize hook for an embedding host (SpyDE): fire onResize
+ // with the new root-container CSS px so the host can drive a real
+ // relayout (e.g. handle.resize(w,h) → fig_width/height → applyLayout).
+ // Debounced (rAF-coalesced trailing) so a drag-resize fires once on
+ // settle, not per-frame. Fires on width OR height change; the host owns
+ // whether to scale, relayout, or ignore.
+ if (typeof onResize === 'function' &&
+ (Math.round(cellW) !== _lastRoW || Math.round(cellH) !== _lastRoH)) {
+ _lastRoW = Math.round(cellW); _lastRoH = Math.round(cellH);
+ if (_roTimer) cancelAnimationFrame(_roTimer);
+ _roTimer = requestAnimationFrame(() => {
+ _roTimer = null;
+ try { onResize({ width: _lastRoW, height: _lastRoH }); } catch (_) {}
+ });
+ }
}).observe(el);
requestAnimationFrame(_applyScale);
}
@@ -7225,8 +8026,15 @@ fn fs(in : VsOut) -> @location(0) vec4 {
} catch(_) {}
});
+ // ── edit-chrome / figure-marker listeners ─────────────────────────────────
+ model.on('change:edit_chrome', () => { _applyEditMode(); });
+ model.on('change:selected_panel', () => { _applyPanelChrome(); });
+ model.on('change:figure_markers_json', () => { _loadFigMarkers(); _drawFigureMarkers(); });
+
// ── initial render ────────────────────────────────────────────────────────
+ _loadFigMarkers();
applyLayout();
+ _applyEditMode();
// Internal API surface returned to mount(). anywidget ignores render()'s
// return value; mount() captures it so its handle can reach the closure's
@@ -7235,6 +8043,10 @@ fn fs(in : VsOut) -> @location(0) vec4 {
return {
panels,
exportPNG,
+ calloutCanvas, // figure-level region-indication overlay
+ _drawCallouts, // force a callout redraw (tests / external layout sync)
+ figMarkerCanvas, // figure-level annotation overlay (content, exported)
+ _drawFigureMarkers, // force a figure-marker redraw (tests / external sync)
_gpuDisposeImagePanel,
_gpuDisposePanel,
};
@@ -7316,6 +8128,9 @@ export function createLocalModel(initialState) {
// Mount a figure into *el* and return a control handle.
// opts.onEvent(ev) — parsed interaction events (pointer/key/wheel …)
// opts.onSync(key, value) — raw outbound model writes, for a Python bridge
+// opts.onResize({width,height}) — fired (debounced) when the ROOT CONTAINER
+// el resizes, so the host can relayout the figure
+// to its new box (e.g. call handle.resize(w,h)).
export function mount(el, state, opts) {
// Diagnostic marker: proves THIS (WebGPU-2D) build of figure_esm.js is loaded.
try { globalThis.__apl_build = 'webgpu-2d'; } catch (_) {}
@@ -7331,10 +8146,13 @@ export function mount(el, state, opts) {
});
}
// Capture render()'s internal API so the handle can reach the closure's
- // panel map + drawing helpers (exportPNG / GPU dispose).
- const api = render({ model, el }) || {};
+ // panel map + drawing helpers (exportPNG / GPU dispose). opts.onResize (if
+ // given) is fired with {width,height} when the ROOT CONTAINER resizes, so an
+ // embedding host can relayout the figure to its new box.
+ const api = render({ model, el, onResize: o.onResize }) || {};
return {
model,
+ api, // internal render() API (panels, calloutCanvas, _drawCallouts, …)
get(key) { return model.get(key); },
set(key, value) { model.set(key, value); model.save_changes(); },
// Replace one panel's full state (object or pre-serialised JSON string).
diff --git a/anyplotlib/plot2d/_layer.py b/anyplotlib/plot2d/_layer.py
index eb248287..69061b2d 100644
--- a/anyplotlib/plot2d/_layer.py
+++ b/anyplotlib/plot2d/_layer.py
@@ -81,11 +81,24 @@ def clim(self):
def set(self, *, cmap=None, alpha=None, clim=None, visible=None) -> "Layer":
"""Partial update of this layer's appearance (any subset of fields).
- ``cmap`` — colormap name. ``alpha`` — opacity in [0, 1]. ``clim`` —
- ``(vmin, vmax)`` display range, or ``None`` to auto (data min/max).
- ``visible`` — draw or hide. A pixel re-encode happens only when ``clim``
- changes (it re-quantises the cached frame); ``cmap``/``alpha``/``visible``
- are cheap LUT/compositor-only changes.
+ ``cmap`` — colormap name. ``alpha`` — opacity in [0, 1]. ``visible``
+ — draw or hide.
+
+ ``clim`` — display range, with three distinct meanings:
+
+ - ``None`` (default) — leave the current clim UNCHANGED (this call
+ doesn't touch it). This is a no-op, not "reset to auto" — pass
+ ``"auto"`` for that.
+ - ``(vmin, vmax)`` — set an explicit display range; re-quantises the
+ cached frame over it.
+ - ``"auto"`` — RESET to auto: recompute the display range from this
+ layer's current data min/max (the same auto-ranging ``add_layer``
+ does when it's given ``clim=None`` at creation time), discarding
+ any previously-set explicit clim.
+
+ A pixel re-encode happens only when ``clim`` is a tuple or ``"auto"``
+ (it re-quantises the cached frame); ``cmap``/``alpha``/``visible`` are
+ cheap LUT/compositor-only changes.
"""
self._plot._layer_set(self._id, cmap=cmap, alpha=alpha, clim=clim,
visible=visible)
diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py
index e4c5d109..a6e51656 100644
--- a/anyplotlib/plot2d/_plot2d.py
+++ b/anyplotlib/plot2d/_plot2d.py
@@ -26,7 +26,7 @@
from anyplotlib.widgets import (
Widget,
RectangleWidget, CircleWidget, AnnularWidget,
- CrosshairWidget, PolygonWidget, LabelWidget,
+ CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget,
)
from anyplotlib._utils import _normalize_image, _build_colormap_lut, _to_rgba_u8
@@ -817,6 +817,17 @@ def set_data(self, data: np.ndarray,
stretched over its full data range (wrong contrast) and the second
corrects it, producing a one-frame flash on every update. Passing
``clim`` here makes data + contrast a single atomic frame (no flash).
+
+ Raises
+ ------
+ ValueError
+ If ``data`` has an invalid shape/ndim, or if this plot has image
+ layers (:meth:`add_layer`) and ``data``'s ``(H, W)`` differs from
+ the current image shape. A layer keeps the size it had when it
+ was added/last updated, so a shape-changing base update would
+ silently stretch stale-sized layer pixels over the new image.
+ Remove all layers (``remove_layer``) before changing the base
+ image's shape, then re-add them at the new size.
"""
data = np.asarray(data)
is_rgb = data.ndim == 3 and data.shape[2] in (3, 4)
@@ -828,6 +839,25 @@ def set_data(self, data: np.ndarray,
raise ValueError(f"data must be 2-D or (H x W x 3|4), got {data.shape}")
h, w = data.shape[:2]
+ # A shape-changing set_data on a plot with image layers would silently
+ # corrupt the display: each layer keeps the (h, w) it had at add_layer /
+ # its last Layer.set_data time (see _encode_layer_pixels), but JS fits
+ # every layer's bitmap into the BASE image's CURRENT fit-rect
+ # (_drawLayers2d → _imgFitRect(iw, ih, ...) uses the new image_width/
+ # image_height) — so a stale-sized layer would be stretched over the new
+ # base image instead of erroring. Mirrors the tile-mode guard above:
+ # refuse instead of corrupting, and tell the caller how to proceed.
+ if self._state.get("layers"):
+ old_h = self._state.get("image_height")
+ old_w = self._state.get("image_width")
+ if (old_h, old_w) != (h, w):
+ raise ValueError(
+ f"set_data: new frame shape ({h}, {w}) does not match the "
+ f"current image shape ({old_h}, {old_w}) while this plot has "
+ f"image layers — remove all layers (remove_layer) before "
+ f"changing the base image shape, then re-add them at the "
+ f"new size.")
+
# ── Tile mode: a live consumer (e.g. a movie navigator) calls set_data with
# each new frame. Route it through the tile pipeline instead of clobbering the
# tile state. Writing the plain base frame here would corrupt tiling: it sets
@@ -1242,15 +1272,26 @@ def _layer_set(self, layer_id: str, *, cmap=None, alpha=None, clim=None,
entry["alpha"] = a
if visible is not None:
entry["visible"] = bool(visible)
+ # clim=None means "leave unchanged" (no-op) — the historical behaviour,
+ # kept as-is. clim="auto" is the sentinel to explicitly RESET to auto
+ # (recompute from the layer's current data min/max, like add_layer's
+ # clim=None-at-creation path) — see Layer.set / _layer.py docstring.
if clim is not None:
# A clim change must RE-QUANTISE the cached frame (like set_clim on the
# base), because the 8-bit codes are quantised over the old range and
# can't be re-windowed past it in the LUT alone. We keep the raw frame
# in _layer_raw for exactly this.
raw = self._layer_raw.get(layer_id)
- lo, hi = self._norm_clim(clim)
- if raw is not None:
+ if isinstance(clim, str):
+ if clim != "auto":
+ raise ValueError(
+ f"clim must be a (vmin, vmax) tuple or the string "
+ f"'auto', got {clim!r}")
+ parsed = None # None → _normalize_image auto-ranges to data min/max
+ else:
+ lo, hi = self._norm_clim(clim)
parsed = (lo, hi) if lo is not None else None
+ if raw is not None:
img_u8, vmin, vmax = _normalize_image(raw, clim=parsed)
pk = self._layer_pixel_key(layer_id)
token = self._encode_pixels(pk, np.ascontiguousarray(img_u8))
@@ -1258,8 +1299,10 @@ def _layer_set(self, layer_id: str, *, cmap=None, alpha=None, clim=None,
entry["image_b64"] = token
self._state[pk] = token
else:
- # No cached frame (unusual) — just record the requested endpoints.
- entry["clim_min"], entry["clim_max"] = lo, hi
+ # No cached frame (unusual) — just record the requested endpoints
+ # (auto with no raw frame to compute from leaves them at None).
+ entry["clim_min"], entry["clim_max"] = (
+ (None, None) if parsed is None else parsed)
self._push()
def _layer_set_data(self, layer_id: str, frame) -> None:
@@ -1500,7 +1543,10 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget:
Dispatches to the dedicated ``add__widget`` method.
Supported kinds: ``"circle"``, ``"rectangle"``, ``"annular"``,
- ``"polygon"``, ``"crosshair"``, ``"label"``.
+ ``"polygon"``, ``"crosshair"``, ``"label"``, ``"arrow"``.
+
+ Every kind also accepts ``show_handles`` (default ``True``) to toggle
+ the grab-handle dots without changing hit-testing / draggability.
"""
dispatch = {
"circle": self.add_circle_widget,
@@ -1509,6 +1555,7 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget:
"polygon": self.add_polygon_widget,
"crosshair": self.add_crosshair_widget,
"label": self.add_label_widget,
+ "arrow": self.add_arrow_widget,
}
key = kind.lower()
if key not in dispatch:
@@ -1516,14 +1563,15 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget:
return dispatch[key](color=color, **kwargs)
def add_circle_widget(self, cx: float | None = None, cy: float | None = None,
- r: float | None = None, color: str = "#00e5ff") -> CircleWidget:
+ r: float | None = None, color: str = "#00e5ff",
+ show_handles: bool = True) -> CircleWidget:
"""Add a draggable circle overlay."""
iw, ih = self._state["image_width"], self._state["image_height"]
widget = CircleWidget(lambda: None,
cx=float(cx) if cx is not None else iw / 2,
cy=float(cy) if cy is not None else ih / 2,
r=float(r) if r is not None else iw * 0.1,
- color=color)
+ color=color, show_handles=show_handles)
widget._push_fn = self._make_widget_push_fn(widget)
self._widgets[widget.id] = widget
self._push()
@@ -1531,7 +1579,8 @@ def add_circle_widget(self, cx: float | None = None, cy: float | None = None,
def add_rectangle_widget(self, x: float | None = None, y: float | None = None,
w: float | None = None, h: float | None = None,
- color: str = "#00e5ff") -> RectangleWidget:
+ color: str = "#00e5ff",
+ show_handles: bool = True) -> RectangleWidget:
"""Add a draggable rectangle overlay."""
iw, ih = self._state["image_width"], self._state["image_height"]
widget = RectangleWidget(lambda: None,
@@ -1539,7 +1588,7 @@ def add_rectangle_widget(self, x: float | None = None, y: float | None = None,
y=float(y) if y is not None else ih * 0.25,
w=float(w) if w is not None else iw * 0.5,
h=float(h) if h is not None else ih * 0.5,
- color=color)
+ color=color, show_handles=show_handles)
widget._push_fn = self._make_widget_push_fn(widget)
self._widgets[widget.id] = widget
self._push()
@@ -1547,7 +1596,8 @@ def add_rectangle_widget(self, x: float | None = None, y: float | None = None,
def add_annular_widget(self, cx: float | None = None, cy: float | None = None,
r_outer: float | None = None, r_inner: float | None = None,
- color: str = "#00e5ff") -> AnnularWidget:
+ color: str = "#00e5ff",
+ show_handles: bool = True) -> AnnularWidget:
"""Add a draggable annular (ring) overlay."""
iw, ih = self._state["image_width"], self._state["image_height"]
widget = AnnularWidget(lambda: None,
@@ -1555,32 +1605,35 @@ def add_annular_widget(self, cx: float | None = None, cy: float | None = None,
cy=float(cy) if cy is not None else ih / 2,
r_outer=float(r_outer) if r_outer is not None else iw * 0.2,
r_inner=float(r_inner) if r_inner is not None else iw * 0.1,
- color=color)
+ color=color, show_handles=show_handles)
widget._push_fn = self._make_widget_push_fn(widget)
self._widgets[widget.id] = widget
self._push()
return widget
- def add_polygon_widget(self, vertices=None, color: str = "#00e5ff") -> PolygonWidget:
+ def add_polygon_widget(self, vertices=None, color: str = "#00e5ff",
+ show_handles: bool = True) -> PolygonWidget:
"""Add a draggable polygon overlay."""
iw, ih = self._state["image_width"], self._state["image_height"]
if vertices is None:
vertices = [[iw * .25, ih * .25], [iw * .75, ih * .25],
[iw * .75, ih * .75], [iw * .25, ih * .75]]
- widget = PolygonWidget(lambda: None, vertices=vertices, color=color)
+ widget = PolygonWidget(lambda: None, vertices=vertices, color=color,
+ show_handles=show_handles)
widget._push_fn = self._make_widget_push_fn(widget)
self._widgets[widget.id] = widget
self._push()
return widget
def add_crosshair_widget(self, cx: float | None = None, cy: float | None = None,
- color: str = "#00e5ff") -> CrosshairWidget:
+ color: str = "#00e5ff",
+ show_handles: bool = True) -> CrosshairWidget:
"""Add a draggable crosshair overlay."""
iw, ih = self._state["image_width"], self._state["image_height"]
widget = CrosshairWidget(lambda: None,
cx=float(cx) if cx is not None else iw / 2,
cy=float(cy) if cy is not None else ih / 2,
- color=color)
+ color=color, show_handles=show_handles)
widget._push_fn = self._make_widget_push_fn(widget)
self._widgets[widget.id] = widget
self._push()
@@ -1588,13 +1641,36 @@ def add_crosshair_widget(self, cx: float | None = None, cy: float | None = None,
def add_label_widget(self, x: float | None = None, y: float | None = None,
text: str = "Label", fontsize: int = 14,
- color: str = "#00e5ff") -> LabelWidget:
+ color: str = "#00e5ff",
+ show_handles: bool = True) -> LabelWidget:
"""Add a draggable text label overlay."""
iw, ih = self._state["image_width"], self._state["image_height"]
widget = LabelWidget(lambda: None,
x=float(x) if x is not None else iw * 0.1,
y=float(y) if y is not None else ih * 0.1,
- text=str(text), fontsize=int(fontsize), color=color)
+ text=str(text), fontsize=int(fontsize), color=color,
+ show_handles=show_handles)
+ widget._push_fn = self._make_widget_push_fn(widget)
+ self._widgets[widget.id] = widget
+ self._push()
+ return widget
+
+ def add_arrow_widget(self, x: float | None = None, y: float | None = None,
+ u: float | None = None, v: float | None = None,
+ color: str = "#00e5ff", linewidth: float = 2,
+ show_handles: bool = True) -> ArrowWidget:
+ """Add a draggable arrow overlay (tail at ``(x, y)``, head at
+ ``(x + u, y + v)``). Defaults place the tail at 25 %, 25 % of the image
+ with a vector of 15 % of the image size, mirroring
+ :meth:`add_label_widget`'s defaulting."""
+ iw, ih = self._state["image_width"], self._state["image_height"]
+ widget = ArrowWidget(lambda: None,
+ x=float(x) if x is not None else iw * 0.25,
+ y=float(y) if y is not None else ih * 0.25,
+ u=float(u) if u is not None else iw * 0.15,
+ v=float(v) if v is not None else ih * 0.15,
+ color=color, linewidth=linewidth,
+ show_handles=show_handles)
widget._push_fn = self._make_widget_push_fn(widget)
self._widgets[widget.id] = widget
self._push()
diff --git a/anyplotlib/tests/test_embed/test_export_png.py b/anyplotlib/tests/test_embed/test_export_png.py
index 0a103dfc..28d59fad 100644
--- a/anyplotlib/tests/test_embed/test_export_png.py
+++ b/anyplotlib/tests/test_embed/test_export_png.py
@@ -63,7 +63,7 @@ def mount_page(_pw_browser):
"""Open a figure via the public mount() API; return the live Page."""
pages, paths = [], []
- def _open(fig):
+ def _open(fig, device_scale_factor=None):
html = (_MOUNT_PAGE
.replace("__STATE__", json.dumps(figure_state(fig)))
.replace("__ESM__", json.dumps(esm_path().read_text(encoding="utf-8"))))
@@ -73,7 +73,10 @@ def _open(fig):
fh.write(html)
tmp = pathlib.Path(fh.name)
paths.append(tmp)
- page = _pw_browser.new_page()
+ new_page_kwargs = {}
+ if device_scale_factor is not None:
+ new_page_kwargs["device_scale_factor"] = device_scale_factor
+ page = _pw_browser.new_page(**new_page_kwargs)
pages.append(page)
page.goto(tmp.as_uri())
page.wait_for_function("() => window._aplReady === true", timeout=15_000)
@@ -218,6 +221,275 @@ def test_widgets_excluded_by_default(self, mount_page):
"includeWidgets=True produced an identical image — overlay not drawn"
)
+ def test_widget_handles_not_exported(self, mount_page):
+ """Panel-overlay widget grab-handle dots are edit-mode chrome (like the
+ figure-marker handles covered by test_edit_chrome_handles_not_exported)
+ and must never be baked into an exported PNG, even with
+ includeWidgets=True and show_handles=True (SpyDE's report edit mode
+ uses show_handles=True on circle/rect/arrow widgets).
+
+ Compares an includeWidgets export of a scene with show_handles=True
+ widgets against the identical scene with show_handles=False — these
+ must be pixel-identical: the widget BODY (circle outline, rect
+ outline, arrow shaft/head) must still export, only the white
+ handle-node dots are suppressed.
+ """
+ def _build(show_handles):
+ fig, ax = apl.subplots(1, 1, figsize=(360, 300))
+ plot = ax.imshow(np.zeros((32, 32), dtype=np.float32),
+ cmap="gray", vmin=0.0, vmax=1.0)
+ plot.add_widget("circle", cx=8.0, cy=8.0, r=5.0,
+ color="#ff0000", show_handles=show_handles)
+ plot.add_widget("rectangle", x=18.0, y=4.0, w=10.0, h=8.0,
+ color="#00ff00", show_handles=show_handles)
+ plot.add_widget("arrow", x=4.0, y=24.0, u=10.0, v=5.0,
+ color="#0000ff", show_handles=show_handles)
+ return fig
+
+ page_handles = mount_page(_build(True))
+ page_no_handles = mount_page(_build(False))
+ for pg in (page_handles, page_no_handles):
+ pg.evaluate(
+ "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))"
+ )
+
+ arr_handles = _decode_data_url(
+ _export_via_handle(page_handles, {"includeWidgets": True})["dataUrl"])
+ arr_no_handles = _decode_data_url(
+ _export_via_handle(page_no_handles, {"includeWidgets": True})["dataUrl"])
+
+ assert arr_handles.shape == arr_no_handles.shape
+ # Widget body ink (coloured stroke) must be present in both — proves
+ # the export isn't just blank / widgets-excluded.
+ assert _is_nonblank(arr_handles) and _is_nonblank(arr_no_handles)
+ diff = np.abs(arr_handles.astype(np.int32) - arr_no_handles.astype(np.int32)).sum()
+ assert diff == 0, (
+ "show_handles=True export differs from show_handles=False — "
+ f"widget handle dots leaked into the exported image (diff={diff})"
+ )
+
+ def test_widget_body_still_exported_with_handles_suppressed(self, mount_page):
+ """Sanity check for the handle-suppression redraw: the widget BODY
+ (not just the handle dots) must still appear in the includeWidgets
+ export — i.e. the fix must not have blanked the overlay entirely.
+ (Handle-dot suppression itself is proven by the pixel-identical diff
+ in test_widget_handles_not_exported above; a global/local white-pixel
+ count is not a reliable signal here since the default light theme's
+ figure background is itself near white.)"""
+ fig, ax = apl.subplots(1, 1, figsize=(360, 300))
+ plot = ax.imshow(np.zeros((32, 32), dtype=np.float32),
+ cmap="gray", vmin=0.0, vmax=1.0)
+ plot.add_widget("rectangle", x=6.0, y=6.0, w=18.0, h=18.0,
+ color="#ff0000", show_handles=True)
+ page = mount_page(fig)
+ page.evaluate(
+ "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))"
+ )
+ res = _export_via_handle(page, {"includeWidgets": True})
+ assert "error" not in res, res.get("error")
+ arr = _decode_data_url(res["dataUrl"])
+ assert _closest_color(arr, (255, 0, 0)) > 10, (
+ "widget body stroke missing from includeWidgets export — "
+ "handle-suppression redraw dropped the widget entirely"
+ )
+
+ # Confirm the on-screen overlay canvas itself is untouched by the
+ # export redraw (no flicker): edit_chrome + a selected panel draws
+ # handles live, and that must be unaffected by having just exported.
+ fig2, ax2 = apl.subplots(1, 1, figsize=(360, 300))
+ plot2 = ax2.imshow(np.zeros((32, 32), dtype=np.float32),
+ cmap="gray", vmin=0.0, vmax=1.0)
+ plot2.add_widget("rectangle", x=6.0, y=6.0, w=18.0, h=18.0,
+ color="#ff0000", show_handles=True)
+ fig2.edit_chrome = True
+ fig2.selected_panel = list(fig2._plots_map)[0]
+ page2 = mount_page(fig2)
+ page2.evaluate(
+ "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))"
+ )
+ before = page2.evaluate(
+ """(pid) => {
+ const p = window._handle.api.panels.get(pid);
+ return p.overlayCanvas.toDataURL();
+ }""",
+ list(fig2._plots_map)[0],
+ )
+ page2.evaluate(
+ "() => window._handle.exportPNG({includeWidgets: true})"
+ )
+ after = page2.evaluate(
+ """(pid) => {
+ const p = window._handle.api.panels.get(pid);
+ return p.overlayCanvas.toDataURL();
+ }""",
+ list(fig2._plots_map)[0],
+ )
+ assert before == after, (
+ "exportPNG mutated the live on-screen overlayCanvas — "
+ "handle-suppression redraw must use a scratch canvas, not the "
+ "live one"
+ )
+
+
+# ---------------------------------------------------------------------------
+# 1c. Figure-level annotation layer export (always composited; edit chrome is
+# never exported).
+# ---------------------------------------------------------------------------
+
+class TestExportFigureMarkers:
+ RED = (255, 0, 0)
+
+ def test_figure_marker_always_exported(self, mount_page):
+ """A figure-level marker is CONTENT — it must appear in the default
+ export (no includeWidgets flag needed)."""
+ fig, ax = apl.subplots(1, 1, figsize=(360, 300))
+ ax.imshow(np.zeros((32, 32), dtype=np.float32),
+ cmap="gray", vmin=0.0, vmax=1.0)
+ fig.set_figure_markers([
+ {"kind": "rect", "x": 0.5, "y": 0.5, "w": 0.4, "h": 0.4,
+ "color": "#ff0000", "linewidth": 4},
+ ])
+ page = mount_page(fig)
+ page.evaluate(
+ "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))"
+ )
+ arr = _decode_data_url(_export_via_handle(page, {})["dataUrl"])
+ assert _closest_color(arr, self.RED) > 20, (
+ "figure-level red rect not present in the default export"
+ )
+
+ def test_edit_chrome_handles_not_exported(self, mount_page):
+ """With edit_chrome on the on-screen marker draws grab-handle dots, but
+ the export must be identical to the non-edit export (handles suppressed;
+ hover/selection outlines are DOM styles, never on a canvas)."""
+ def _build(edit):
+ fig, ax = apl.subplots(1, 1, figsize=(360, 300))
+ ax.imshow(np.zeros((32, 32), dtype=np.float32),
+ cmap="gray", vmin=0.0, vmax=1.0)
+ fig.set_figure_markers([
+ {"kind": "circle", "x": 0.5, "y": 0.5, "r": 0.25,
+ "color": "#ff0000", "linewidth": 3, "id": "c1"},
+ ])
+ fig.edit_chrome = edit
+ fig.selected_panel = list(fig._plots_map)[0] if edit else ""
+ return fig
+
+ page_off = mount_page(_build(False))
+ page_on = mount_page(_build(True))
+ for pg in (page_off, page_on):
+ pg.evaluate(
+ "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))"
+ )
+ arr_off = _decode_data_url(_export_via_handle(page_off, {})["dataUrl"])
+ arr_on = _decode_data_url(_export_via_handle(page_on, {})["dataUrl"])
+
+ assert arr_off.shape == arr_on.shape
+ # Handle dots are white with a coloured ring — if they were exported the
+ # red count / white-dot pixels would differ. Require pixel-identical.
+ diff = np.abs(arr_off.astype(np.int32) - arr_on.astype(np.int32)).sum()
+ assert diff == 0, (
+ "edit-mode export differs from non-edit — handles/outlines leaked "
+ f"into the exported image (diff={diff})"
+ )
+
+ def test_figMarkerCanvas_exposed_on_handle(self, mount_page):
+ """The render API exposes figMarkerCanvas + _drawFigureMarkers (SpyDE
+ and tests reach the marker layer through these)."""
+ fig, ax = apl.subplots(1, 1, figsize=(200, 160))
+ ax.imshow(np.zeros((8, 8), dtype=np.float32))
+ page = mount_page(fig)
+ has = page.evaluate(
+ """() => ({
+ canvas: !!window._handle.api.figMarkerCanvas,
+ draw: typeof window._handle.api._drawFigureMarkers === 'function',
+ })"""
+ )
+ assert has["canvas"] and has["draw"]
+
+
+# ---------------------------------------------------------------------------
+# 1b. Inset title bar export (exportPNG must draw the DOM title text)
+# ---------------------------------------------------------------------------
+
+def _inset_titlebar_rect(page, plot_id):
+ """CSS-px rect of an inset's titleBar, relative to the figure origin.
+
+ calloutCanvas shares the same 8px-padding offset from outerDiv as
+ gridDiv/insetsContainer (see figure_esm.js), so its rect is a stable,
+ API-exposed proxy for the figure content origin exportPNG measures from
+ (mirrors _inset_dom_rect in test_inset_callout.py).
+ """
+ return page.evaluate(
+ """(pid) => {
+ const p = window._handle.api.panels.get(pid);
+ const r = p.titleBar.getBoundingClientRect();
+ const base = window._handle.api.calloutCanvas.getBoundingClientRect();
+ return {left: r.left - base.left, top: r.top - base.top,
+ width: r.width, height: r.height};
+ }""",
+ plot_id,
+ )
+
+
+class TestExportInsetTitle:
+ def test_inset_title_drawn_in_titlebar_band(self, mount_page):
+ """A non-empty inset title must leave title-text-coloured ink in the
+ titlebar row band; an otherwise-identical inset with an empty title
+ must NOT — ruling out a false positive from the titlebar's own flat
+ fill colour (which both figures share) rather than actual text."""
+ dpr = None
+ GRID_PAD = 8
+
+ def _titlebar_band(fig_factory, title):
+ fig, ax = fig_factory()
+ ax.imshow(np.zeros((32, 32), dtype=np.float32), cmap="gray",
+ vmin=0.0, vmax=1.0)
+ inset = fig.add_inset(0.35, 0.35, corner="top-right", title=title)
+ plot = inset.imshow(np.zeros((16, 16), dtype=np.float32),
+ cmap="gray", vmin=0.0, vmax=1.0)
+ page = mount_page(fig)
+ rect = _inset_titlebar_rect(page, plot._id)
+ res = _export_via_handle(page)
+ assert "error" not in res, res.get("error")
+ arr = _decode_data_url(res["dataUrl"])
+ dpr_ = page.evaluate("() => window.devicePixelRatio || 1")
+ y0 = max(0, round((rect["top"] + GRID_PAD) * dpr_))
+ y1 = min(arr.shape[0], round((rect["top"] + rect["height"] + GRID_PAD) * dpr_))
+ x0 = max(0, round((rect["left"] + GRID_PAD) * dpr_))
+ x1 = min(arr.shape[1], round((rect["left"] + rect["width"] + GRID_PAD) * dpr_))
+ return arr[y0:y1, x0:x1, :3], dpr_
+
+ def _make_fig():
+ return apl.subplots(1, 1, figsize=(400, 300))
+
+ band_titled, dpr = _titlebar_band(_make_fig, "Zoom View")
+ band_empty, _ = _titlebar_band(_make_fig, "")
+ assert band_titled.size > 0 and band_empty.size > 0, (
+ f"empty titlebar band(s): titled={band_titled.shape} "
+ f"empty={band_empty.shape}"
+ )
+ assert band_titled.shape == band_empty.shape, (
+ "titlebar bands differ in size between the two figures — "
+ f"titled={band_titled.shape} empty={band_empty.shape}"
+ )
+
+ # Both bands share the identical titlebar chrome (same theme, corner,
+ # size — including its border-bottom row), so any per-pixel
+ # difference between the two must be the drawn title TEXT.
+ diff = np.abs(band_titled.astype(np.int32) - band_empty.astype(np.int32))
+ changed_px = int((diff.sum(axis=-1) > 20).sum())
+ assert changed_px > 0, (
+ "titled and empty-title exports are pixel-identical in the "
+ "titlebar band — exportPNG is not drawing the inset title text"
+ )
+ # The border-bottom row (~1 CSS px) is the only chrome difference a
+ # mis-measured band could pick up; text ink should account for far
+ # more than one scaled row's worth of pixels.
+ assert changed_px > band_titled.shape[1] * dpr, (
+ f"only {changed_px} px differ — looks like border noise, not text "
+ f"(band width {band_titled.shape[1]})"
+ )
+
# ---------------------------------------------------------------------------
# 2. Multi-panel export
@@ -257,6 +529,88 @@ def test_two_panels_both_present(self, mount_page):
# And they must NOT be swapped (right colour should be rare on the left).
assert _closest_color(left_half, right_hi) < _closest_color(right_half, right_hi)
+ def test_fractional_scale_no_seam_between_panels(self, mount_page):
+ """exportPNG at a fractional effective scale (devicePixelRatio ×
+ opts.scale) on touching (wspace=0) panels must not leave a
+ background-coloured seam between their interior content areas at
+ the shared boundary.
+
+ Reproduced with device_scale_factor=1.5 (a real fractional-DPR
+ display, e.g. 150% Windows scaling) and a panel width whose CSS
+ boundary lands exactly on a half-pixel (250px figure → two 125px
+ panels stretched by DPR 1.5 → boundary at 187.5 output px): with
+ unrounded per-element dx/dw, the two neighbouring canvases (each
+ independently measuring its own getBoundingClientRect() * outScale)
+ can round their shared edge to different output pixels, opening a
+ 1px background gap exactly at the seam. Confirmed to fail before
+ the _drawEl edge-rounding fix and pass after.
+ """
+ fig, axes = apl.subplots(1, 2, figsize=(250, 200))
+ fig.subplots_adjust(wspace=0)
+ # Full-bleed constant-colour panels (no axes/title/colorbar → each
+ # plotCanvas fills its entire grid cell, so the panels' image content
+ # touches directly at the shared boundary with no letterboxing).
+ axes[0].imshow(np.full((24, 24), 0.0, dtype=np.float32),
+ cmap="viridis", vmin=0.0, vmax=1.0)
+ axes[1].imshow(np.full((24, 24), 1.0, dtype=np.float32),
+ cmap="viridis", vmin=0.0, vmax=1.0)
+
+ page = mount_page(fig, device_scale_factor=1.5)
+ scale = 1.0
+ info = page.evaluate(
+ """(sc) => {
+ const ps = [...window._handle.api.panels.values()]
+ .filter(p => !p.isInset)
+ .sort((a, b) => a.plotCanvas.getBoundingClientRect().left
+ - b.plotCanvas.getBoundingClientRect().left);
+ const outScale = (window.devicePixelRatio || 1) * sc;
+ const l = ps[0].plotCanvas.getBoundingClientRect();
+ const r = ps[1].plotCanvas.getBoundingClientRect();
+ return {
+ leftRight: l.right * outScale,
+ rightLeft: r.left * outScale,
+ top: Math.max(l.top, r.top) * outScale,
+ bottom: Math.min(l.bottom, r.bottom) * outScale,
+ dpr: window.devicePixelRatio,
+ };
+ }""",
+ scale,
+ )
+ res = _export_via_handle(page, {"scale": scale})
+ assert "error" not in res, res.get("error")
+ arr = _decode_data_url(res["dataUrl"])
+
+ boundary = round(info["leftRight"])
+ assert abs(info["leftRight"] - info["rightLeft"]) < 1.0, (
+ "test precondition failed: panels aren't actually touching "
+ f"({info})"
+ )
+ y0 = max(0, round(info["top"]) + 2)
+ y1 = min(arr.shape[0], round(info["bottom"]) - 2)
+ assert y1 > y0, f"degenerate row range: {info}"
+
+ bg_rgb = page.evaluate(
+ """() => {
+ const el = [...document.querySelectorAll('div')]
+ .find(d => getComputedStyle(d).display === 'grid');
+ const m = getComputedStyle(el).backgroundColor
+ .match(/(\\d+),\\s*(\\d+),\\s*(\\d+)/);
+ return m ? [+m[1], +m[2], +m[3]] : [240, 240, 240];
+ }"""
+ )
+ bg = np.array(bg_rgb)
+
+ # A window of a few columns straddling the rounded boundary must
+ # contain no background-coloured pixel (a gap) — the two panels'
+ # distinct colours must butt together directly.
+ band = arr[y0:y1, max(0, boundary - 2):boundary + 3, :3]
+ is_bg = np.abs(band.astype(np.int32) - bg).sum(axis=-1) < 20
+ assert not is_bg.any(), (
+ f"background-coloured pixel(s) at the panel boundary "
+ f"(x≈{boundary}, dpr={info['dpr']}) — seam between touching "
+ f"panels at scale={scale}: band={band.tolist()}"
+ )
+
# ---------------------------------------------------------------------------
# 3. Iframe postMessage round-trip (standalone HTML template)
diff --git a/anyplotlib/tests/test_interactive/test_edit_chrome.py b/anyplotlib/tests/test_interactive/test_edit_chrome.py
new file mode 100644
index 00000000..dcd8326e
--- /dev/null
+++ b/anyplotlib/tests/test_interactive/test_edit_chrome.py
@@ -0,0 +1,435 @@
+"""
+tests/test_interactive/test_edit_chrome.py
+==========================================
+
+Unit tests for the Report-Builder edit-mode features:
+
+Batch 1 — widget polish + ArrowWidget
+ * ``show_handles`` lands in every 2-D widget's state / overlay_widgets
+ * ``ArrowWidget`` state round-trip, adder defaults, dispatcher entry, JS-sync
+
+Batch 2 — figure-level chrome + annotation layer
+ * ``edit_chrome`` / ``selected_panel`` trait round-trips
+ * ``set_figure_markers`` validation + id assignment + ``figure_markers``
+ * ``_dispatch_event`` for ``figure_background`` (figure callbacks, not panel)
+ * ``_dispatch_event`` for ``figure_marker`` pointer_up (updates list + fires)
+
+The JS drag / hover / background-emit behaviour is covered by the Playwright
+suite in ``test_edit_chrome_playwright.py``.
+"""
+
+from __future__ import annotations
+
+import json
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+from anyplotlib.widgets import ArrowWidget
+
+
+def _simulate_js_event(fig, plot, event_type, *, widget_id=None, **fields):
+ payload = {"source": "js", "panel_id": plot._id, "event_type": event_type}
+ if widget_id is not None:
+ payload["widget_id"] = widget_id if isinstance(widget_id, str) else widget_id._id
+ payload.update(fields)
+ fig._dispatch_event(json.dumps(payload))
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Batch 1.1 — show_handles
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestShowHandles:
+ def test_default_true_all_kinds(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ widgets = [
+ v.add_circle_widget(),
+ v.add_rectangle_widget(),
+ v.add_annular_widget(r_outer=10, r_inner=5),
+ v.add_crosshair_widget(),
+ v.add_polygon_widget(),
+ v.add_label_widget(),
+ v.add_arrow_widget(),
+ ]
+ for w in widgets:
+ assert w.show_handles is True, w._type
+ assert w.to_dict()["show_handles"] is True
+
+ def test_false_carried_into_state(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ w = v.add_rectangle_widget(show_handles=False)
+ assert w.show_handles is False
+ entry = next(e for e in v.to_state_dict()["overlay_widgets"]
+ if e["id"] == w.id)
+ assert entry["show_handles"] is False
+
+ def test_show_handles_via_add_widget_dispatcher(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ w = v.add_widget("circle", show_handles=False)
+ assert w.show_handles is False
+
+ def test_serialization_present_for_every_kind(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ v.add_circle_widget(); v.add_rectangle_widget()
+ v.add_annular_widget(r_outer=10, r_inner=5)
+ v.add_crosshair_widget(); v.add_polygon_widget()
+ v.add_label_widget(); v.add_arrow_widget()
+ entries = v.to_state_dict()["overlay_widgets"]
+ assert all("show_handles" in e for e in entries)
+ assert len(entries) == 7
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Batch 1.2 — ArrowWidget
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestArrowWidget:
+ def test_arrow_attributes(self):
+ w = ArrowWidget(lambda: None, x=10, y=20, u=30, v=40)
+ assert w.x == 10.0 and w.y == 20.0 and w.u == 30.0 and w.v == 40.0
+ assert w._type == "arrow"
+ assert w.linewidth == 2.0 and w.show_handles is True
+
+ def test_to_dict_round_trip(self):
+ w = ArrowWidget(lambda: None, x=1, y=2, u=3, v=4, color="#abc")
+ d = w.to_dict()
+ assert d["type"] == "arrow" and d["x"] == 1.0 and d["u"] == 3.0
+ assert d["color"] == "#abc" and "id" in d
+
+ def test_add_arrow_widget_defaults(self):
+ # tail at 25%,25% of the image; u=v = 15% of the image (mirrors label).
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((40, 40)))
+ w = v.add_arrow_widget()
+ assert w.x == pytest.approx(40 * 0.25)
+ assert w.y == pytest.approx(40 * 0.25)
+ assert w.u == pytest.approx(40 * 0.15)
+ assert w.v == pytest.approx(40 * 0.15)
+
+ def test_add_arrow_widget_explicit(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ w = v.add_arrow_widget(x=5, y=6, u=7, v=8, color="#f00", linewidth=3)
+ assert (w.x, w.y, w.u, w.v) == (5.0, 6.0, 7.0, 8.0)
+ assert w.color == "#f00" and w.linewidth == 3.0
+
+ def test_dispatcher_entry(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ w = v.add_widget("arrow", x=1, y=2, u=3, v=4)
+ assert isinstance(w, ArrowWidget)
+ assert (w.x, w.u) == (1.0, 3.0)
+
+ def test_arrow_in_state_dict(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ w = v.add_arrow_widget(x=1, y=2, u=3, v=4)
+ entry = next(e for e in v.to_state_dict()["overlay_widgets"]
+ if e["id"] == w.id)
+ assert entry["type"] == "arrow"
+ assert entry["x"] == 1.0 and entry["v"] == 4.0
+
+ def test_js_drag_syncs_all_fields(self):
+ """A simulated JS drag emits x/y/u/v; _update_from_js syncs them back."""
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ w = v.add_arrow_widget(x=0, y=0, u=10, v=10)
+ moves = []
+ w.add_event_handler(lambda e: moves.append((w.x, w.y, w.u, w.v)),
+ "pointer_move")
+ _simulate_js_event(fig, v, "pointer_move", widget_id=w,
+ x=5.0, y=6.0, u=15.0, v=16.0)
+ assert moves == [(5.0, 6.0, 15.0, 16.0)]
+ assert (w.x, w.y, w.u, w.v) == (5.0, 6.0, 15.0, 16.0)
+
+ def test_js_head_resize_syncs_uv(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ w = v.add_arrow_widget(x=4, y=4, u=8, v=8)
+ _simulate_js_event(fig, v, "pointer_up", widget_id=w, u=20.0, v=2.0)
+ assert w.u == 20.0 and w.v == 2.0
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Batch 2.1 — Figure traits (edit_chrome / selected_panel / figure_markers_json)
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestFigureTraits:
+ def test_edit_chrome_default_false(self):
+ fig, ax = apl.subplots(1, 1)
+ assert fig.edit_chrome is False
+
+ def test_edit_chrome_round_trip(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.edit_chrome = True
+ assert fig.edit_chrome is True
+ assert fig.traits(sync=True)["edit_chrome"] is not None
+
+ def test_selected_panel_default_empty(self):
+ fig, ax = apl.subplots(1, 1)
+ assert fig.selected_panel == ""
+
+ def test_selected_panel_round_trip(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((8, 8)))
+ fig.selected_panel = v._id
+ assert fig.selected_panel == v._id
+
+ def test_figure_markers_json_default_empty_list(self):
+ fig, ax = apl.subplots(1, 1)
+ assert json.loads(fig.figure_markers_json) == []
+
+ def test_all_three_are_synced_traits(self):
+ fig, ax = apl.subplots(1, 1)
+ synced = fig.traits(sync=True)
+ assert "edit_chrome" in synced
+ assert "selected_panel" in synced
+ assert "figure_markers_json" in synced
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Batch 2.2 — set_figure_markers / figure_markers property
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestSetFigureMarkers:
+ def test_set_and_read_back(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.set_figure_markers([
+ {"kind": "text", "x": 0.5, "y": 0.5, "text": "hi"},
+ {"kind": "circle", "x": 0.2, "y": 0.3, "r": 0.1},
+ {"kind": "rect", "x": 0.5, "y": 0.5, "w": 0.2, "h": 0.1},
+ {"kind": "arrow", "x": 0.1, "y": 0.1, "u": 0.2, "v": 0.2},
+ ])
+ markers = fig.figure_markers
+ assert [m["kind"] for m in markers] == ["text", "circle", "rect", "arrow"]
+
+ def test_ids_assigned_when_missing(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.set_figure_markers([{"kind": "text", "x": 0, "y": 0, "text": "a"}])
+ assert fig.figure_markers[0]["id"]
+
+ def test_existing_ids_preserved(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.set_figure_markers([{"kind": "text", "x": 0, "y": 0, "text": "a",
+ "id": "keepme"}])
+ assert fig.figure_markers[0]["id"] == "keepme"
+
+ def test_json_trait_syncs(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.set_figure_markers([{"kind": "circle", "x": 0.5, "y": 0.5, "r": 0.1}])
+ parsed = json.loads(fig.figure_markers_json)
+ assert parsed[0]["kind"] == "circle" and parsed[0]["r"] == 0.1
+
+ def test_bad_kind_raises(self):
+ fig, ax = apl.subplots(1, 1)
+ with pytest.raises(ValueError, match="kind must be one of"):
+ fig.set_figure_markers([{"kind": "polygon", "x": 0, "y": 0}])
+
+ def test_figure_markers_returns_copy(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.set_figure_markers([{"kind": "text", "x": 0, "y": 0, "text": "a"}])
+ got = fig.figure_markers
+ got[0]["x"] = 999
+ # Mutating the returned list must not desync the stored state.
+ assert fig.figure_markers[0]["x"] == 0
+
+ def test_empty_list_clears(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.set_figure_markers([{"kind": "text", "x": 0, "y": 0, "text": "a"}])
+ fig.set_figure_markers([])
+ assert fig.figure_markers == []
+ assert json.loads(fig.figure_markers_json) == []
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Batch 2.3 — _dispatch_event figure-level routing
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestFigureBackgroundDispatch:
+ def test_fires_figure_callback_not_panel(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((16, 16)))
+ fig_events, panel_events = [], []
+ fig.add_event_handler(lambda e: fig_events.append(e), "pointer_down")
+ v.add_event_handler(lambda e: panel_events.append(e), "pointer_down")
+
+ fig._dispatch_event(json.dumps({
+ "source": "js", "panel_id": "", "event_type": "pointer_down",
+ "figure_background": True,
+ }))
+ assert len(fig_events) == 1
+ assert panel_events == []
+
+ def test_figure_event_source_is_figure(self):
+ fig, ax = apl.subplots(1, 1)
+ received = []
+ fig.add_event_handler(lambda e: received.append(e.source), "pointer_down")
+ fig._dispatch_event(json.dumps({
+ "source": "js", "panel_id": "", "event_type": "pointer_down",
+ "figure_background": True,
+ }))
+ assert received[0] is fig
+
+ def test_panel_click_does_not_fire_figure(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((16, 16)))
+ fig_events = []
+ fig.add_event_handler(lambda e: fig_events.append(e), "pointer_down")
+ fig._dispatch_event(json.dumps({
+ "source": "js", "panel_id": v._id, "event_type": "pointer_down",
+ }))
+ assert fig_events == []
+
+
+class TestFigureMarkerDispatch:
+ def test_pointer_up_updates_list_and_fires(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.set_figure_markers([
+ {"kind": "arrow", "x": 0.1, "y": 0.1, "u": 0.2, "v": 0.2, "id": "m1"}])
+ fired = []
+ fig.add_event_handler(lambda e: fired.append(e.last_widget_id), "pointer_up")
+
+ fig._dispatch_event(json.dumps({
+ "source": "js", "panel_id": "", "event_type": "pointer_up",
+ "figure_marker": True, "marker_id": "m1",
+ "x": 0.5, "y": 0.6, "u": 0.3, "v": 0.3,
+ }))
+ m = fig.figure_markers[0]
+ assert (m["x"], m["y"], m["u"], m["v"]) == (0.5, 0.6, 0.3, 0.3)
+ assert fired == ["m1"]
+
+ def test_marker_json_converges(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.set_figure_markers([
+ {"kind": "text", "x": 0.1, "y": 0.1, "text": "hi", "id": "t1"}])
+ fig._dispatch_event(json.dumps({
+ "source": "js", "panel_id": "", "event_type": "pointer_up",
+ "figure_marker": True, "marker_id": "t1", "x": 0.7, "y": 0.8,
+ }))
+ parsed = json.loads(fig.figure_markers_json)
+ assert parsed[0]["x"] == 0.7 and parsed[0]["y"] == 0.8
+
+ def test_unknown_marker_id_no_crash(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.set_figure_markers([
+ {"kind": "text", "x": 0.1, "y": 0.1, "text": "hi", "id": "t1"}])
+ # Should not raise even if the id doesn't match.
+ fig._dispatch_event(json.dumps({
+ "source": "js", "panel_id": "", "event_type": "pointer_up",
+ "figure_marker": True, "marker_id": "nope", "x": 0.7,
+ }))
+ assert fig.figure_markers[0]["x"] == 0.1
+
+ def test_rect_marker_size_fields_update(self):
+ fig, ax = apl.subplots(1, 1)
+ fig.set_figure_markers([
+ {"kind": "rect", "x": 0.5, "y": 0.5, "w": 0.2, "h": 0.1, "id": "r1"}])
+ fig._dispatch_event(json.dumps({
+ "source": "js", "panel_id": "", "event_type": "pointer_up",
+ "figure_marker": True, "marker_id": "r1", "x": 0.3, "y": 0.4,
+ }))
+ m = fig.figure_markers[0]
+ assert m["x"] == 0.3 and m["y"] == 0.4
+ # w/h unchanged (not in the drag payload)
+ assert m["w"] == 0.2 and m["h"] == 0.1
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Batch 3.1 — panel drag-swap dispatch
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestPanelSwapDispatch:
+ def test_panel_swap_fires_figure_event_with_ids(self):
+ fig = apl.Figure(1, 2)
+ a0 = fig.add_subplot((0, 0)); v0 = a0.imshow(np.zeros((8, 8)))
+ a1 = fig.add_subplot((0, 1)); v1 = a1.imshow(np.zeros((8, 8)))
+ got = []
+ fig.add_event_handler(lambda e: got.append(e), "pointer_up")
+ fig._dispatch_event(json.dumps({
+ "source": "js", "panel_id": "", "event_type": "pointer_up",
+ "panel_swap": True,
+ "source_panel_id": v0._id, "target_panel_id": v1._id,
+ }))
+ assert len(got) == 1
+ e = got[0]
+ assert e.source is fig
+ assert e.source_panel_id == v0._id
+ assert e.target_panel_id == v1._id
+
+ def test_panel_swap_does_not_touch_panels_or_layout(self):
+ # anyplotlib performs NO layout change itself on a swap.
+ fig = apl.Figure(1, 2)
+ a0 = fig.add_subplot((0, 0)); v0 = a0.imshow(np.zeros((8, 8)))
+ a1 = fig.add_subplot((0, 1)); v1 = a1.imshow(np.zeros((8, 8)))
+ panel_got = []
+ v0.add_event_handler(lambda e: panel_got.append(e), "pointer_up")
+ v1.add_event_handler(lambda e: panel_got.append(e), "pointer_up")
+ before = fig.layout_json
+ fig._dispatch_event(json.dumps({
+ "source": "js", "panel_id": "", "event_type": "pointer_up",
+ "panel_swap": True,
+ "source_panel_id": v0._id, "target_panel_id": v1._id,
+ }))
+ # No per-panel callback fired, and layout is untouched.
+ assert panel_got == []
+ assert fig.layout_json == before
+
+ def test_panel_swap_ids_none_on_other_events(self):
+ # A non-swap figure event leaves the swap fields None.
+ fig, ax = apl.subplots(1, 1)
+ got = []
+ fig.add_event_handler(lambda e: got.append(e), "pointer_down")
+ fig._dispatch_event(json.dumps({
+ "source": "js", "panel_id": "", "event_type": "pointer_down",
+ "figure_background": True,
+ }))
+ assert got[0].source_panel_id is None
+ assert got[0].target_panel_id is None
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Batch 3.2 — arrow tail reshape / resize round-trip via _update_from_js
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestArrowTailReshapeSync:
+ def test_tail_reshape_updates_x_y_u_v(self):
+ # Simulate the JS resize_tail drag: tail moved by (+4, +3); the JS keeps
+ # the head anchored by also shifting u,v by the negative delta. Python
+ # just applies whatever x/y/u/v the JS emits.
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ w = v.add_arrow_widget(x=6, y=6, u=20, v=20)
+ # original head = (26, 26); tail → (10, 9); head must stay (26, 26)
+ _simulate_js_event(fig, v, "pointer_up", widget_id=w,
+ x=10.0, y=9.0, u=16.0, v=17.0)
+ d = w.to_dict()
+ assert (d["x"], d["y"]) == (10.0, 9.0)
+ # head = x+u, y+v stays anchored at the original (26, 26)
+ assert d["x"] + d["u"] == pytest.approx(26.0)
+ assert d["y"] + d["v"] == pytest.approx(26.0)
+
+
+class TestCircleRectResizeSync:
+ def test_circle_radius_resize_updates_r(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ w = v.add_circle_widget(cx=16, cy=16, r=5)
+ _simulate_js_event(fig, v, "pointer_up", widget_id=w,
+ cx=16.0, cy=16.0, r=9.0)
+ d = w.to_dict()
+ assert d["r"] == 9.0 and d["cx"] == 16.0 and d["cy"] == 16.0
+
+ def test_rect_corner_resize_updates_wh_and_xy(self):
+ fig, ax = apl.subplots(1, 1)
+ v = ax.imshow(np.zeros((32, 32)))
+ w = v.add_rectangle_widget(x=4, y=4, w=10, h=8)
+ # top-left corner drag anchors the SE corner: x,y and w,h both change.
+ _simulate_js_event(fig, v, "pointer_up", widget_id=w,
+ x=6.0, y=5.0, w=8.0, h=7.0)
+ d = w.to_dict()
+ assert (d["x"], d["y"], d["w"], d["h"]) == (6.0, 5.0, 8.0, 7.0)
diff --git a/anyplotlib/tests/test_interactive/test_edit_chrome_playwright.py b/anyplotlib/tests/test_interactive/test_edit_chrome_playwright.py
new file mode 100644
index 00000000..d8ffbc91
--- /dev/null
+++ b/anyplotlib/tests/test_interactive/test_edit_chrome_playwright.py
@@ -0,0 +1,842 @@
+"""
+tests/test_interactive/test_edit_chrome_playwright.py
+=====================================================
+
+Playwright integration tests for the Report-Builder edit-mode features:
+
+ * ArrowWidget synthetic drag → pointer_up carries final x/y/u/v
+ * edit_chrome hover outline appears on a panel cell (DOM style assert)
+ * figure-background click emits ``figure_background`` in edit mode (and NOT
+ when edit mode is off)
+ * figure-marker drag round-trips fractions (figure_markers_json + pointer_up)
+
+Each test opens the standalone HTML (no live kernel) and drives real browser
+mouse events, reading back ``event_json`` writes and DOM styles via
+``page.evaluate``.
+
+Coordinate system
+-----------------
+GRID_PAD = 8 (gridDiv padding). The figMarkerCanvas + panel grid both sit at
+(8, 8) inside outerDiv, so a figure fraction (fx, fy) maps to page coords
+(GRID_PAD + fx*fig_w, GRID_PAD + fy*fig_h). Panel image px map through the
+per-panel PAD offsets (see _event_test_utils).
+"""
+from __future__ import annotations
+
+import json
+
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+from anyplotlib.tests.test_interactive._event_test_utils import (
+ _collect_events, _get_events, GRID_PAD, PAD_L, PAD_T,
+)
+
+FIG_W, FIG_H = 400, 300
+
+
+def _open(interact_page, fig):
+ page = interact_page(fig)
+ _collect_events(page)
+ return page
+
+
+# JS: the first panel cell's inline outline (the grid is found by computed
+# display, since inline-style attribute matching is brittle across browsers).
+_CELL_OUTLINE_JS = """() => {
+ let grid = null;
+ for (const d of document.querySelectorAll('div')) {
+ if (getComputedStyle(d).display === 'grid') { grid = d; break; }
+ }
+ const cell = grid ? grid.firstElementChild : null;
+ return cell ? cell.style.outline : null;
+}"""
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 1. ArrowWidget synthetic drag
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestArrowWidgetDrag:
+ # Map image-px (ix,iy) → page coords using the overlay canvas rect + the
+ # 'contain' fit (no zoom/pan in these tests), matching figure_esm.js. The
+ # overlay canvas is identified as the single canvas with pointer-events:all.
+ _OVERLAY_RECT_JS = """() => {
+ for (const cv of document.querySelectorAll('canvas')) {
+ if (getComputedStyle(cv).pointerEvents === 'all') {
+ const r = cv.getBoundingClientRect();
+ return {left:r.left, top:r.top, w:r.width, h:r.height};
+ }
+ }
+ return null;
+ }"""
+
+ def _img_to_page(self, page, ix, iy, iw=32, ih=32):
+ r = page.evaluate(self._OVERLAY_RECT_JS)
+ assert r is not None, "overlay canvas not found"
+ s = min(r["w"] / iw, r["h"] / ih)
+ ox = (r["w"] - iw * s) / 2.0
+ oy = (r["h"] - ih * s) / 2.0
+ return r["left"] + ox + ix * s, r["top"] + oy + iy * s
+
+ def test_arrow_shaft_drag_moves_whole_arrow(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ # 32×32 image; arrow spans the panel so the shaft crosses the centre.
+ v = ax.imshow(np.zeros((32, 32), dtype=np.float32))
+ v.add_arrow_widget(x=6, y=6, u=20, v=20, color="#ff0000")
+ page = _open(interact_page, fig)
+
+ # Grab the SHAFT midpoint (16,16) — away from both endpoint nodes — and
+ # drag the whole arrow. Both endpoints translate; u,v (vector) invariant.
+ mx, my = self._img_to_page(page, 16, 16)
+ page.mouse.move(mx, my)
+ page.mouse.down()
+ page.mouse.move(mx + 30, my + 20, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ ups = _get_events(page, "pointer_up")
+ assert ups, "arrow shaft drag should emit a pointer_up"
+ last = ups[-1]
+ assert "x" in last and "y" in last and "u" in last and "v" in last
+ # Body move → x,y increased; u,v (vector) unchanged.
+ assert last["x"] > 6.0 and last["y"] > 6.0
+ assert last["u"] == pytest.approx(20.0, abs=1e-6)
+ assert last["v"] == pytest.approx(20.0, abs=1e-6)
+
+ def test_arrow_tail_drag_reshapes_head_fixed(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ v = ax.imshow(np.zeros((32, 32), dtype=np.float32))
+ v.add_arrow_widget(x=6, y=6, u=20, v=20, color="#ff0000")
+ page = _open(interact_page, fig)
+
+ # Grab the TAIL node (6,6) and drag it. New semantics: the tail moves
+ # but the HEAD (tail + (u,v) = (26,26)) stays anchored.
+ tx, ty = self._img_to_page(page, 6, 6)
+ page.mouse.move(tx, ty)
+ page.mouse.down()
+ page.mouse.move(tx + 24, ty + 16, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ ups = _get_events(page, "pointer_up")
+ assert ups, "arrow tail drag should emit a pointer_up"
+ last = ups[-1]
+ # Tail moved down-right → x,y increased.
+ assert last["x"] > 6.0 and last["y"] > 6.0
+ # Head = x+u, y+v stays anchored at the original (26, 26).
+ assert last["x"] + last["u"] == pytest.approx(26.0, abs=0.6)
+ assert last["y"] + last["v"] == pytest.approx(26.0, abs=0.6)
+
+ def test_arrow_head_drag_changes_uv(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ v = ax.imshow(np.zeros((32, 32), dtype=np.float32))
+ v.add_arrow_widget(x=6, y=6, u=20, v=20, color="#ff0000")
+ page = _open(interact_page, fig)
+
+ hx, hy = self._img_to_page(page, 26, 26) # head = tail + (u,v)
+ page.mouse.move(hx, hy)
+ page.mouse.down()
+ page.mouse.move(hx + 20, hy - 25, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ ups = _get_events(page, "pointer_up")
+ assert ups
+ last = ups[-1]
+ # Tail stays put; head moved right+up → u increases, v decreases.
+ assert last["x"] == pytest.approx(6.0, abs=1e-6)
+ assert last["u"] > 20.0
+ assert last["v"] < 20.0
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 2. edit_chrome hover outline (DOM style assert)
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestEditChromeHover:
+ def test_hover_outline_appears_in_edit_mode(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ ax.imshow(np.zeros((16, 16), dtype=np.float32))
+ fig.edit_chrome = True
+ page = interact_page(fig)
+
+ # Enter from outside, then move over the panel cell centre.
+ page.mouse.move(1, 1)
+ page.mouse.move(GRID_PAD + FIG_W / 2, GRID_PAD + FIG_H / 2, steps=5)
+ page.wait_for_timeout(60)
+
+ outline = page.evaluate(_CELL_OUTLINE_JS)
+ assert outline and "dashed" in outline
+
+ def test_no_hover_outline_when_edit_off(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ ax.imshow(np.zeros((16, 16), dtype=np.float32))
+ # edit_chrome stays False (default)
+ page = interact_page(fig)
+
+ page.mouse.move(1, 1)
+ page.mouse.move(GRID_PAD + FIG_W / 2, GRID_PAD + FIG_H / 2, steps=5)
+ page.wait_for_timeout(60)
+
+ outline = page.evaluate(_CELL_OUTLINE_JS)
+ assert not outline # '' or null
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 3. Figure-background click
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestFigureBackgroundClick:
+ def test_background_click_emits_in_edit_mode(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ ax.imshow(np.zeros((16, 16), dtype=np.float32))
+ fig.edit_chrome = True
+ page = _open(interact_page, fig)
+
+ # Click in the gridDiv padding band (top-left 8px margin) — always
+ # background, never inside a panel cell.
+ page.mouse.move(2, 2)
+ page.mouse.down()
+ page.mouse.up()
+ page.wait_for_timeout(60)
+
+ bg = [e for e in _get_events(page)
+ if e.get("figure_background")]
+ assert bg, "figure_background should be emitted on a background click"
+
+ def test_background_click_silent_when_edit_off(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ ax.imshow(np.zeros((16, 16), dtype=np.float32))
+ page = _open(interact_page, fig)
+
+ page.mouse.move(2, 2)
+ page.mouse.down()
+ page.mouse.up()
+ page.wait_for_timeout(60)
+
+ bg = [e for e in _get_events(page) if e.get("figure_background")]
+ assert bg == []
+
+ def test_panel_click_not_background(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ ax.imshow(np.zeros((16, 16), dtype=np.float32))
+ fig.edit_chrome = True
+ page = _open(interact_page, fig)
+
+ page.mouse.move(GRID_PAD + FIG_W / 2, GRID_PAD + FIG_H / 2)
+ page.mouse.down()
+ page.mouse.up()
+ page.wait_for_timeout(60)
+
+ bg = [e for e in _get_events(page) if e.get("figure_background")]
+ assert bg == [], "a click inside a panel must not fire figure_background"
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 4. Figure-marker drag round-trips fractions
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestFigureMarkerDrag:
+ def test_marker_drag_updates_fractions(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ ax.imshow(np.zeros((16, 16), dtype=np.float32))
+ fig.set_figure_markers([
+ {"kind": "circle", "x": 0.5, "y": 0.5, "r": 0.08, "id": "c1"}])
+ fig.edit_chrome = True
+ page = _open(interact_page, fig)
+
+ # Marker centre at fraction (0.5, 0.5) → page coords.
+ start_x = GRID_PAD + 0.5 * FIG_W
+ start_y = GRID_PAD + 0.5 * FIG_H
+ # Drag it by +0.2 fig-width, -0.1 fig-height.
+ end_x = GRID_PAD + 0.7 * FIG_W
+ end_y = GRID_PAD + 0.4 * FIG_H
+
+ page.mouse.move(start_x, start_y)
+ page.mouse.down()
+ page.mouse.move(end_x, end_y, steps=10)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ # (a) pointer_up carries the updated fractions + figure_marker flag.
+ ups = [e for e in _get_events(page, "pointer_up")
+ if e.get("figure_marker")]
+ assert ups, "figure-marker drag should emit a figure_marker pointer_up"
+ last = ups[-1]
+ assert last["marker_id"] == "c1"
+ assert last["x"] == pytest.approx(0.7, abs=0.03)
+ assert last["y"] == pytest.approx(0.4, abs=0.03)
+
+ # (b) figure_markers_json written back to the model with the new pos.
+ stored = page.evaluate(
+ "() => JSON.parse(window._aplModel.get('figure_markers_json'))")
+ assert stored[0]["x"] == pytest.approx(0.7, abs=0.03)
+ assert stored[0]["y"] == pytest.approx(0.4, abs=0.03)
+
+ def test_marker_not_draggable_when_edit_off(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ ax.imshow(np.zeros((16, 16), dtype=np.float32))
+ fig.set_figure_markers([
+ {"kind": "circle", "x": 0.5, "y": 0.5, "r": 0.08, "id": "c1"}])
+ # edit_chrome stays False → figMarkerCanvas pointer-events:none.
+ page = _open(interact_page, fig)
+
+ start_x = GRID_PAD + 0.5 * FIG_W
+ start_y = GRID_PAD + 0.5 * FIG_H
+ page.mouse.move(start_x, start_y)
+ page.mouse.down()
+ page.mouse.move(start_x + 0.2 * FIG_W, start_y, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(60)
+
+ ups = [e for e in _get_events(page, "pointer_up")
+ if e.get("figure_marker")]
+ assert ups == [], "markers must be inert when edit_chrome is off"
+ stored = page.evaluate(
+ "() => JSON.parse(window._aplModel.get('figure_markers_json'))")
+ assert stored[0]["x"] == pytest.approx(0.5, abs=1e-6)
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 5. Widget resize nodes — circle radius + rectangle corner round-trip
+# ═══════════════════════════════════════════════════════════════════════════
+
+# Module-level image-px → page-coord mapper (overlay canvas rect + 'contain'
+# fit, no zoom/pan), shared by the resize tests.
+_OVERLAY_RECT_JS = """() => {
+ for (const cv of document.querySelectorAll('canvas')) {
+ if (getComputedStyle(cv).pointerEvents === 'all') {
+ const r = cv.getBoundingClientRect();
+ return {left:r.left, top:r.top, w:r.width, h:r.height};
+ }
+ }
+ return null;
+}"""
+
+
+def _img_to_page(page, ix, iy, iw=32, ih=32):
+ r = page.evaluate(_OVERLAY_RECT_JS)
+ assert r is not None, "overlay canvas not found"
+ s = min(r["w"] / iw, r["h"] / ih)
+ ox = (r["w"] - iw * s) / 2.0
+ oy = (r["h"] - ih * s) / 2.0
+ return r["left"] + ox + ix * s, r["top"] + oy + iy * s
+
+
+class TestWidgetResizeNodes:
+ def test_circle_radius_node_resizes(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ v = ax.imshow(np.zeros((32, 32), dtype=np.float32))
+ v.add_circle_widget(cx=16, cy=16, r=6, color="#ff0000")
+ page = _open(interact_page, fig)
+
+ # Radius handle sits at the east point (cx+r, cy) = (22, 16). Drag it
+ # OUT to ~(26, 16) → radius grows; centre unchanged.
+ hx, hy = _img_to_page(page, 22, 16)
+ page.mouse.move(hx, hy)
+ page.mouse.down()
+ ex, ey = _img_to_page(page, 26, 16)
+ page.mouse.move(ex, ey, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ ups = _get_events(page, "pointer_up")
+ assert ups, "circle radius drag should emit a pointer_up"
+ last = ups[-1]
+ assert last["r"] > 6.0
+ assert last["cx"] == pytest.approx(16.0, abs=0.5)
+ assert last["cy"] == pytest.approx(16.0, abs=0.5)
+
+ def test_circle_centre_node_moves(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ v = ax.imshow(np.zeros((32, 32), dtype=np.float32))
+ v.add_circle_widget(cx=16, cy=16, r=6, color="#ff0000")
+ page = _open(interact_page, fig)
+
+ cx, cy = _img_to_page(page, 16, 16)
+ page.mouse.move(cx, cy)
+ page.mouse.down()
+ ex, ey = _img_to_page(page, 20, 22)
+ page.mouse.move(ex, ey, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ last = _get_events(page, "pointer_up")[-1]
+ # Centre moved; radius unchanged.
+ assert last["cx"] > 16.0 and last["cy"] > 16.0
+ assert last["r"] == pytest.approx(6.0, abs=0.5)
+
+ def test_rect_corner_node_resizes(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ v = ax.imshow(np.zeros((32, 32), dtype=np.float32))
+ # top-left (x,y)=(6,6), size 12×10 → SE corner at (18,16).
+ v.add_rectangle_widget(x=6, y=6, w=12, h=10, color="#ff0000")
+ page = _open(interact_page, fig)
+
+ # Drag the SE corner further out → w,h grow; x,y (top-left) fixed.
+ sx, sy = _img_to_page(page, 18, 16)
+ page.mouse.move(sx, sy)
+ page.mouse.down()
+ ex, ey = _img_to_page(page, 24, 22)
+ page.mouse.move(ex, ey, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ last = _get_events(page, "pointer_up")[-1]
+ assert last["w"] > 12.0 and last["h"] > 10.0
+ assert last["x"] == pytest.approx(6.0, abs=0.5)
+ assert last["y"] == pytest.approx(6.0, abs=0.5)
+
+ def test_rect_tl_corner_anchors_opposite(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ v = ax.imshow(np.zeros((32, 32), dtype=np.float32))
+ v.add_rectangle_widget(x=6, y=6, w=12, h=10, color="#ff0000")
+ page = _open(interact_page, fig)
+
+ # Drag the TL corner IN → x,y move, w,h shrink; SE corner (18,16) fixed.
+ tx, ty = _img_to_page(page, 6, 6)
+ page.mouse.move(tx, ty)
+ page.mouse.down()
+ ex, ey = _img_to_page(page, 10, 9)
+ page.mouse.move(ex, ey, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ last = _get_events(page, "pointer_up")[-1]
+ # SE corner = x+w, y+h stays anchored at (18, 16).
+ assert last["x"] + last["w"] == pytest.approx(18.0, abs=0.7)
+ assert last["y"] + last["h"] == pytest.approx(16.0, abs=0.7)
+ assert last["x"] > 6.0 and last["y"] > 6.0
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 6. Panel drag-swap grip
+# ═══════════════════════════════════════════════════════════════════════════
+
+# Locate a panel cell's move-grip and the cell rect by grid order.
+_GRIP_RECT_JS = """(idx) => {
+ let grid = null;
+ for (const d of document.querySelectorAll('div')) {
+ if (getComputedStyle(d).display === 'grid') { grid = d; break; }
+ }
+ if (!grid) return null;
+ const cell = grid.children[idx];
+ if (!cell) return null;
+ const grip = cell.querySelector('.apl-panel-grip');
+ const gr = grip ? grip.getBoundingClientRect() : null;
+ const cr = cell.getBoundingClientRect();
+ return {
+ grip: gr ? {left:gr.left, top:gr.top, w:gr.width, h:gr.height,
+ display: getComputedStyle(grip).display} : null,
+ cell: {left:cr.left, top:cr.top, w:cr.width, h:cr.height},
+ };
+}"""
+
+
+class TestPanelSwapGrip:
+ def _two_panel_fig(self):
+ fig = apl.Figure(1, 2, figsize=(FIG_W, FIG_H))
+ a0 = fig.add_subplot((0, 0)); a0.imshow(np.zeros((16, 16), dtype=np.float32))
+ a1 = fig.add_subplot((0, 1)); a1.imshow(np.zeros((16, 16), dtype=np.float32))
+ return fig
+
+ def test_grip_hidden_when_edit_off(self, interact_page):
+ fig = self._two_panel_fig()
+ page = _open(interact_page, fig)
+ page.wait_for_timeout(60)
+ info = page.evaluate(_GRIP_RECT_JS, 0)
+ # Grip exists (lazily created) but is display:none off-edit.
+ assert info["grip"] is None or info["grip"]["display"] == "none"
+
+ def test_grip_visible_in_edit_mode(self, interact_page):
+ fig = self._two_panel_fig()
+ fig.edit_chrome = True
+ page = _open(interact_page, fig)
+ page.wait_for_timeout(60)
+ info = page.evaluate(_GRIP_RECT_JS, 0)
+ assert info["grip"] is not None
+ assert info["grip"]["display"] == "flex"
+
+ def test_swap_emits_with_ids(self, interact_page):
+ fig = self._two_panel_fig()
+ fig.edit_chrome = True
+ page = _open(interact_page, fig)
+ page.wait_for_timeout(60)
+
+ g0 = page.evaluate(_GRIP_RECT_JS, 0)
+ c1 = page.evaluate(_GRIP_RECT_JS, 1)["cell"]
+ gx = g0["grip"]["left"] + g0["grip"]["w"] / 2
+ gy = g0["grip"]["top"] + g0["grip"]["h"] / 2
+ # Drop over the CENTRE of the second panel.
+ tx = c1["left"] + c1["w"] / 2
+ ty = c1["top"] + c1["h"] / 2
+
+ page.mouse.move(gx, gy)
+ page.mouse.down()
+ page.mouse.move(tx, ty, steps=10)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ swaps = [e for e in _get_events(page, "pointer_up") if e.get("panel_swap")]
+ assert swaps, "dropping the grip over a different panel should emit panel_swap"
+ last = swaps[-1]
+ assert last["source_panel_id"] and last["target_panel_id"]
+ assert last["source_panel_id"] != last["target_panel_id"]
+
+ def test_swap_silent_on_source_panel(self, interact_page):
+ fig = self._two_panel_fig()
+ fig.edit_chrome = True
+ page = _open(interact_page, fig)
+ page.wait_for_timeout(60)
+
+ g0 = page.evaluate(_GRIP_RECT_JS, 0)
+ c0 = g0["cell"]
+ gx = g0["grip"]["left"] + g0["grip"]["w"] / 2
+ gy = g0["grip"]["top"] + g0["grip"]["h"] / 2
+ # Drop back inside the SAME (source) panel → no swap.
+ tx = c0["left"] + c0["w"] * 0.6
+ ty = c0["top"] + c0["h"] * 0.6
+
+ page.mouse.move(gx, gy)
+ page.mouse.down()
+ page.mouse.move(tx, ty, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ swaps = [e for e in _get_events(page, "pointer_up") if e.get("panel_swap")]
+ assert swaps == [], "dropping on the source panel must not emit panel_swap"
+
+ def test_swap_silent_on_empty_space(self, interact_page):
+ fig = self._two_panel_fig()
+ fig.edit_chrome = True
+ page = _open(interact_page, fig)
+ page.wait_for_timeout(60)
+
+ g0 = page.evaluate(_GRIP_RECT_JS, 0)
+ gx = g0["grip"]["left"] + g0["grip"]["w"] / 2
+ gy = g0["grip"]["top"] + g0["grip"]["h"] / 2
+ # Drop far outside any panel (top-left figure padding band).
+ page.mouse.move(gx, gy)
+ page.mouse.down()
+ page.mouse.move(1, 1, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ swaps = [e for e in _get_events(page, "pointer_up") if e.get("panel_swap")]
+ assert swaps == [], "dropping on empty space must not emit panel_swap"
+
+ def test_grip_inert_when_edit_off(self, interact_page):
+ fig = self._two_panel_fig()
+ page = _open(interact_page, fig) # edit_chrome stays False
+ page.wait_for_timeout(60)
+
+ # Even if we click where the grip would be, no swap fires.
+ c0 = page.evaluate(_GRIP_RECT_JS, 0)["cell"]
+ c1 = page.evaluate(_GRIP_RECT_JS, 1)["cell"]
+ page.mouse.move(c0["left"] + 8, c0["top"] + 8)
+ page.mouse.down()
+ page.mouse.move(c1["left"] + c1["w"] / 2, c1["top"] + c1["h"] / 2, steps=8)
+ page.mouse.up()
+ page.wait_for_timeout(80)
+
+ swaps = [e for e in _get_events(page, "pointer_up") if e.get("panel_swap")]
+ assert swaps == [], "grip must be inert when edit_chrome is off"
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 7. Selection / hover outline is fully INSET (not clipped at figure edges)
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TestOutlineInset:
+ def test_selection_outline_offset_is_negative_full_width(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ v = ax.imshow(np.zeros((16, 16), dtype=np.float32))
+ fig.edit_chrome = True
+ fig.selected_panel = v._id
+ page = _open(interact_page, fig)
+ page.wait_for_timeout(60)
+
+ style = page.evaluate("""() => {
+ let grid = null;
+ for (const d of document.querySelectorAll('div')) {
+ if (getComputedStyle(d).display === 'grid') { grid = d; break; }
+ }
+ const cell = grid ? grid.firstElementChild : null;
+ return cell ? {outline: cell.style.outline,
+ offset: cell.style.outlineOffset} : null;
+ }""")
+ assert style and "solid" in style["outline"]
+ # Fully inset: outline-offset == -2px (= -width) so nothing spills out.
+ assert style["offset"] == "-2px"
+
+ def test_hover_outline_offset_is_negative_full_width(self, interact_page):
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ ax.imshow(np.zeros((16, 16), dtype=np.float32))
+ fig.edit_chrome = True
+ page = interact_page(fig)
+
+ page.mouse.move(1, 1)
+ page.mouse.move(GRID_PAD + FIG_W / 2, GRID_PAD + FIG_H / 2, steps=5)
+ page.wait_for_timeout(60)
+
+ style = page.evaluate("""() => {
+ let grid = null;
+ for (const d of document.querySelectorAll('div')) {
+ if (getComputedStyle(d).display === 'grid') { grid = d; break; }
+ }
+ const cell = grid ? grid.firstElementChild : null;
+ return cell ? {outline: cell.style.outline,
+ offset: cell.style.outlineOffset} : null;
+ }""")
+ assert style and "dashed" in style["outline"]
+ assert style["offset"] == "-2px"
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 8. Container-resize → onResize hook fires (mount() embedding path)
+# ═══════════════════════════════════════════════════════════════════════════
+
+# Bare mount() page (Blob-URL ESM import — the proven embedding pattern; a
+# file:// ES-module import is blocked, see test_embed_mount.py). The host DIV
+# is sized so we can resize it and observe onResize.
+_RESIZE_PAGE = """
+
+
+
+
+"""
+
+
+class TestContainerResizeHook:
+ def test_onresize_fires_on_container_resize(self, _pw_browser):
+ import pathlib
+ import tempfile
+ from anyplotlib.embed import esm_path, figure_state
+
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ ax.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ html = (_RESIZE_PAGE
+ .replace("__STATE__", json.dumps(figure_state(fig)))
+ .replace("__ESM__", json.dumps(esm_path().read_text(encoding="utf-8"))))
+ with tempfile.NamedTemporaryFile(
+ suffix=".html", mode="w", encoding="utf-8", delete=False) as fh:
+ fh.write(html)
+ tmp = pathlib.Path(fh.name)
+
+ page = _pw_browser.new_page()
+ try:
+ page.goto(tmp.as_uri())
+ page.wait_for_function("() => window._aplReady === true", timeout=15_000)
+ page.evaluate("() => new Promise(r => requestAnimationFrame("
+ "() => requestAnimationFrame(r)))")
+ # Clear any initial-observe calls, then resize the host container.
+ page.evaluate("() => { window._resizeCalls = []; }")
+ page.evaluate("""() => {
+ const h = document.getElementById('host');
+ h.style.width = '520px'; h.style.height = '360px';
+ }""")
+ page.wait_for_function(
+ "() => window._resizeCalls.length > 0", timeout=5_000)
+ calls = page.evaluate("() => window._resizeCalls")
+ assert calls, "onResize should fire when the root container resizes"
+ last = calls[-1]
+ assert last["width"] == pytest.approx(520, abs=4)
+ assert last["height"] == pytest.approx(360, abs=4)
+ finally:
+ page.close()
+ tmp.unlink(missing_ok=True)
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 9. Python-pushed event_json widget update through the Electron/embed
+# "state bridge" (the standalone-HTML `_repr_utils.build_standalone_html`
+# host, which is what SpyDE's report figures actually load via file://,
+# not a bare mount() page).
+#
+# Regression for: a single inbound `awi_state` push for one key (event_json)
+# was re-firing EVERY registered change:* listener in makeModel().
+# save_changes(), including every panel's `change:panel__json` listener —
+# which re-parses that panel's OWN (untouched, stale) trait and overwrites
+# `p.state` wholesale, silently reverting the just-applied targeted widget
+# edit in the same synchronous cascade. `interact_page` (see conftest.py)
+# renders through this exact template but only exposes `window._aplModel`
+# (not the render() return API), so these tests build the same
+# build_standalone_html() page directly and also expose `window._aplApi`
+# (render()'s return value — panels / _drawFigureMarkers / figMarkerCanvas)
+# so we can read the live in-memory panel state, not just the (never
+# rewritten by this path) `panel__json` trait. A real
+# `postMessage({type:'awi_state', ...})` — precisely how SpyDEContext's
+# state_update handler talks to the report-figure iframe — exercises the
+# real bridge, not a mock.
+# ═══════════════════════════════════════════════════════════════════════════
+
+def _open_repr_page(_pw_browser, fig):
+ """Open *fig* via build_standalone_html() (the SpyDE report-figure /
+ mount-bridge host), exposing window._aplReady + window._aplApi (render()'s
+ return value: panels, figMarkerCanvas, _drawFigureMarkers, ...)."""
+ import pathlib
+ import tempfile
+ from anyplotlib._repr_utils import build_standalone_html
+
+ html = build_standalone_html(fig, resizable=False, fig_id="test")
+ html = html.replace(
+ "renderFn({ model, el });",
+ "renderFn({ model, el }); window._aplReady = true;",
+ )
+ # _aplRenderApi is assigned right after renderFn(...) inside the same
+ # import(...).then(mod => { ... }) callback — expose it too.
+ html = html.replace(
+ "_aplRenderApi = renderFn({ model, el }); window._aplReady = true;",
+ "_aplRenderApi = renderFn({ model, el }); window._aplApi = _aplRenderApi; "
+ "window._aplReady = true;",
+ )
+ with tempfile.NamedTemporaryFile(
+ suffix=".html", mode="w", encoding="utf-8", delete=False) as fh:
+ fh.write(html)
+ tmp = pathlib.Path(fh.name)
+ page = _pw_browser.new_page()
+ page.goto(tmp.as_uri())
+ page.wait_for_function("() => window._aplReady === true", timeout=15_000)
+ page.evaluate(
+ "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
+ return page, tmp
+
+
+def _push_widget_field(page, panel_id, widget_id, **fields):
+ """Post an `awi_state` message carrying a Python-sourced event_json patch,
+ exactly the shape `Figure._push_widget` (figure/_figure.py) produces and
+ exactly the channel SpyDEContext's `state_update` handler relays into a
+ report-figure iframe (`postMessage({type:'awi_state', key:'event_json',
+ value})`)."""
+ payload = {"source": "python", "panel_id": panel_id, "widget_id": widget_id}
+ payload.update(fields)
+ page.evaluate(
+ "(v) => window.postMessage("
+ "{type:'awi_state', key:'event_json', value: JSON.stringify(v)}, '*')",
+ payload,
+ )
+
+
+def _overlay_pixel_rgb(page, ix, iy, iw=32, ih=32):
+ """Sample the overlay canvas at image pixel (ix, iy) → [r, g, b, a]."""
+ return page.evaluate("""(args) => {
+ const [ix, iy, iw, ih] = args;
+ let overlay = null;
+ for (const cv of document.querySelectorAll('canvas')) {
+ if (getComputedStyle(cv).pointerEvents === 'all') { overlay = cv; break; }
+ }
+ if (!overlay) return null;
+ const r = overlay.getBoundingClientRect();
+ const s = Math.min(r.width / iw, r.height / ih);
+ const ox = (r.width - iw * s) / 2.0, oy = (r.height - ih * s) / 2.0;
+ // Canvas backing store may be DPR-scaled relative to its CSS rect.
+ const scaleX = overlay.width / r.width, scaleY = overlay.height / r.height;
+ const px = Math.round((ox + ix * s) * scaleX);
+ const py = Math.round((oy + iy * s) * scaleY);
+ const ctx = overlay.getContext('2d');
+ const d = ctx.getImageData(px, py, 1, 1).data;
+ return [d[0], d[1], d[2], d[3]];
+ }""", [ix, iy, iw, ih])
+
+
+class TestPythonPushedWidgetUpdate:
+ def test_event_json_push_updates_stored_color(self, _pw_browser):
+ """Widget.set(color=...) → Figure._push_widget → event_json, relayed
+ through the SAME postMessage bridge SpyDE uses, must land in the
+ JS-side overlay_widgets state (not just get silently reverted by a
+ stale panel__json re-sync riding the same save_changes() call)."""
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ v = ax.imshow(np.zeros((32, 32), dtype=np.float32))
+ w = v.add_circle_widget(cx=16, cy=16, r=6, color="#00e5ff")
+ page, tmp = _open_repr_page(_pw_browser, fig)
+ try:
+ _push_widget_field(page, v._id, w.id, color="#ff00ff")
+ page.wait_for_timeout(150)
+
+ widgets = page.evaluate(
+ "(pid) => window._aplApi.panels.get(pid).state.overlay_widgets", v._id)
+ assert widgets and widgets[0]["id"] == w.id
+ assert widgets[0]["color"] == "#ff00ff", (
+ "Python-pushed event_json color update did not survive — a stale "
+ "panel__json re-sync riding the same save_changes() cascade "
+ "reverted it")
+ finally:
+ page.close()
+ tmp.unlink(missing_ok=True)
+
+ def test_event_json_push_repaints_the_pixel_color(self, _pw_browser):
+ """Same push, but assert the actual redrawn pixels changed color —
+ the stored-state check alone wouldn't catch a case where the field
+ applies but _redrawPanel silently no-ops."""
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ v = ax.imshow(np.zeros((32, 32), dtype=np.float32))
+ # A big, thick-stroked circle so the sample point is squarely on ink.
+ w = v.add_circle_widget(cx=16, cy=16, r=10, color="#ff0000")
+ page, tmp = _open_repr_page(_pw_browser, fig)
+ try:
+ before = _overlay_pixel_rgb(page, 26, 16) # east point of the circle
+ assert before is not None and before[3] > 0, "no ink at the sample point before the push"
+ assert before[0] > 150 and before[1] < 80, f"expected red stroke, got {before}"
+
+ _push_widget_field(page, v._id, w.id, color="#00ff00")
+ page.wait_for_timeout(150)
+
+ after = _overlay_pixel_rgb(page, 26, 16)
+ assert after is not None and after[3] > 0, "ink disappeared after the push"
+ assert after[1] > 150 and after[0] < 80, (
+ f"pixel did not repaint green after the Python-pushed color update: {after}")
+ finally:
+ page.close()
+ tmp.unlink(missing_ok=True)
+
+ def test_event_json_push_does_not_disturb_other_traits(self, _pw_browser):
+ """A figure_markers_json push arriving right after an event_json push
+ (same bridge, same makeModel) must still apply — guards against an
+ overzealous fix that under-fires instead of over-firing."""
+ fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H))
+ v = ax.imshow(np.zeros((32, 32), dtype=np.float32))
+ w = v.add_circle_widget(cx=16, cy=16, r=6, color="#00e5ff")
+ page, tmp = _open_repr_page(_pw_browser, fig)
+ try:
+ _push_widget_field(page, v._id, w.id, color="#ff00ff")
+ page.wait_for_timeout(60)
+
+ markers = [{"id": "m1", "kind": "circle", "x": 0.5, "y": 0.5,
+ "r": 0.2, "color": "#ff9800"}]
+ page.evaluate(
+ "(v) => window.postMessage("
+ "{type:'awi_state', key:'figure_markers_json', value: JSON.stringify(v)}, '*')",
+ markers,
+ )
+ page.wait_for_timeout(150)
+
+ widgets = page.evaluate(
+ "(pid) => window._aplApi.panels.get(pid).state.overlay_widgets", v._id)
+ assert widgets[0]["color"] == "#ff00ff", "earlier event_json push was lost"
+
+ ink = page.evaluate("""() => {
+ const c = window._aplApi.figMarkerCanvas;
+ if (!c) return -1;
+ const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
+ let n = 0;
+ for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n++;
+ return n;
+ }""")
+ assert ink > 0, "figure_markers_json push did not draw the marker"
+ finally:
+ page.close()
+ tmp.unlink(missing_ok=True)
diff --git a/anyplotlib/tests/test_layouts/test_inset_callout.py b/anyplotlib/tests/test_layouts/test_inset_callout.py
new file mode 100644
index 00000000..f2b382f5
--- /dev/null
+++ b/anyplotlib/tests/test_layouts/test_inset_callout.py
@@ -0,0 +1,636 @@
+"""
+Tests for arbitrary inset placement (``anchor=``) and mark_inset-style region
+indications / callouts (``InsetAxes.indicate_region`` / ``clear_indication``).
+
+Unit tests (no browser)
+-----------------------
+ * ``add_inset(..., anchor=(x, y))`` state: anchor in inset_specs, corner None.
+ * corner-only path still defaults (anchor None); anchor supersedes corner.
+ * ``indicate_region`` serialises into ``layout.indications`` and round-trips
+ through ``figure_state`` (save_html path).
+ * replace + clear semantics.
+
+Playwright tests (headless Chromium, via the public ``mount()`` handle)
+-----------------------------------------------------------------------
+ * Anchored inset's DOM top-left sits at ``anchor × figure``.
+ * The callout canvas paints the dashed rect at the parent region AND leader
+ lines between rect and inset (colour-pixel assertions).
+ * The rect tracks parent zoom/pan (change the view → the rect bbox moves).
+ * Minimizing the inset hides the leaders (rect stays).
+ * ``exportPNG`` composites the indication (colour present only with it).
+"""
+from __future__ import annotations
+
+import base64
+import json
+import pathlib
+import tempfile
+
+import numpy as np
+import pytest
+
+import anyplotlib as apl
+from anyplotlib.axes import InsetAxes
+from anyplotlib.embed import esm_path, figure_state
+from anyplotlib.tests._png_utils import decode_png
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Unit tests
+# ═══════════════════════════════════════════════════════════════════════════
+
+def _make_fig():
+ fig, ax = apl.subplots(1, 1, figsize=(640, 480))
+ ax.imshow(np.zeros((64, 64), dtype=np.float32))
+ return fig, ax
+
+
+def _inset_spec(fig, plot_id):
+ layout = json.loads(fig.layout_json)
+ return next(s for s in layout["inset_specs"] if s["id"] == plot_id)
+
+
+# ── anchored placement ────────────────────────────────────────────────────
+
+def test_add_inset_anchor_state():
+ fig, _ = _make_fig()
+ inset = fig.add_inset(0.3, 0.25, anchor=(0.55, 0.10), title="Callout")
+ plot = inset.imshow(np.zeros((32, 32), dtype=np.float32))
+
+ assert isinstance(inset, InsetAxes)
+ assert inset.anchor == (0.55, 0.10)
+ assert inset.corner is None
+
+ spec = _inset_spec(fig, plot._id)
+ assert spec["anchor"] == [0.55, 0.10]
+ assert spec["corner"] is None
+
+
+def test_add_inset_corner_default_has_no_anchor():
+ fig, _ = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, corner="top-left")
+ plot = inset.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ assert inset.anchor is None
+ assert inset.corner == "top-left"
+ spec = _inset_spec(fig, plot._id)
+ assert spec["anchor"] is None
+ assert spec["corner"] == "top-left"
+
+
+def test_anchor_supersedes_corner():
+ """Passing both anchor and a corner keeps the anchor and drops the corner."""
+ fig, _ = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, corner="bottom-right", anchor=(0.2, 0.3))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+ assert inset.anchor == (0.2, 0.3)
+ assert inset.corner is None
+
+
+def test_anchor_inset_state_transitions_work():
+ """minimize / maximize / restore still function for an anchored inset."""
+ fig, _ = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.5))
+ plot = inset.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ inset.minimize()
+ assert _inset_spec(fig, plot._id)["inset_state"] == "minimized"
+ inset.maximize()
+ assert _inset_spec(fig, plot._id)["inset_state"] == "maximized"
+ inset.restore()
+ assert _inset_spec(fig, plot._id)["inset_state"] == "normal"
+
+
+def test_anchor_repr():
+ fig, _ = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, anchor=(0.4, 0.2))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+ r = repr(inset)
+ assert "anchor=(0.4, 0.2)" in r
+ assert "corner" not in r
+
+
+# ── indicate_region / clear_indication ────────────────────────────────────
+
+def test_indicate_region_state():
+ fig, ax = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, anchor=(0.55, 0.1))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ inset.indicate_region(ax._plot, (10, 12, 20, 24),
+ color="#00ff00", linestyle="dashed", linewidth=2.0)
+
+ layout = json.loads(fig.layout_json)
+ assert "indications" in layout
+ assert len(layout["indications"]) == 1
+ ind = layout["indications"][0]
+ assert ind["inset_id"] == inset._plot._id
+ assert ind["parent_id"] == ax._plot._id
+ assert ind["region"] == [10.0, 12.0, 20.0, 24.0]
+ assert ind["color"] == "#00ff00"
+ assert ind["linestyle"] == "dashed"
+ assert ind["linewidth"] == 2.0
+ # property mirror
+ assert inset.indication["region"] == [10.0, 12.0, 20.0, 24.0]
+
+
+def test_indicate_region_defaults():
+ fig, ax = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, corner="top-right")
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+ inset.indicate_region(ax._plot, (5, 5, 10, 10))
+ ind = json.loads(fig.layout_json)["indications"][0]
+ assert ind["color"] == "#ff9800"
+ assert ind["linestyle"] == "dashed"
+ assert ind["linewidth"] == 1.5
+
+
+def test_indicate_region_replaces_previous():
+ """A second indicate_region REPLACES the first for the same inset."""
+ fig, ax = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, anchor=(0.55, 0.1))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ inset.indicate_region(ax._plot, (10, 10, 20, 20))
+ inset.indicate_region(ax._plot, (30, 30, 8, 8), color="#123456")
+
+ inds = json.loads(fig.layout_json)["indications"]
+ assert len(inds) == 1
+ assert inds[0]["region"] == [30.0, 30.0, 8.0, 8.0]
+ assert inds[0]["color"] == "#123456"
+
+
+def test_clear_indication():
+ fig, ax = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, anchor=(0.55, 0.1))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+ inset.indicate_region(ax._plot, (10, 10, 20, 20))
+ assert len(json.loads(fig.layout_json)["indications"]) == 1
+
+ inset.clear_indication()
+ assert json.loads(fig.layout_json)["indications"] == []
+ assert inset.indication is None
+ # idempotent
+ inset.clear_indication()
+ assert json.loads(fig.layout_json)["indications"] == []
+
+
+def test_indicate_region_bad_parent_raises():
+ fig, _ = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ class _NoId:
+ pass
+
+ with pytest.raises(ValueError, match="panel id"):
+ inset.indicate_region(_NoId(), (0, 0, 1, 1))
+
+
+def test_indicate_region_foreign_figure_parent_raises():
+ """A parent_plot registered on a DIFFERENT Figure must be rejected — it
+ has a real panel id (so it passes the earlier no-id check) but isn't one
+ of this inset's own figure's panels."""
+ fig, ax = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ other_fig, other_ax = _make_fig() # a second, unrelated figure
+
+ with pytest.raises(ValueError, match="not registered"):
+ inset.indicate_region(other_ax._plot, (0, 0, 1, 1))
+ # And the inset's own indication is untouched by the rejected call.
+ assert inset.indication is None
+
+
+def test_indicate_region_foreign_inset_parent_raises():
+ """Same check, but the foreign parent is itself an inset (on the other
+ figure) rather than a grid panel — _plots_map covers both."""
+ fig, ax = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ other_fig, _ = _make_fig()
+ other_inset = other_fig.add_inset(0.2, 0.2, anchor=(0.1, 0.1))
+ other_plot = other_inset.imshow(np.zeros((8, 8), dtype=np.float32))
+
+ with pytest.raises(ValueError, match="not registered"):
+ inset.indicate_region(other_plot, (0, 0, 1, 1))
+
+
+@pytest.mark.parametrize("region", [
+ (0, 0, 0, 10), # w == 0
+ (0, 0, 10, 0), # h == 0
+ (0, 0, -5, 10), # w < 0
+ (0, 0, 10, -5), # h < 0
+ (float("nan"), 0, 10, 10),
+ (0, float("nan"), 10, 10),
+ (0, 0, float("nan"), 10),
+ (0, 0, 10, float("nan")),
+ (0, 0, float("inf"), 10),
+ (0, 0, 10, 10, 99), # wrong length (too many)
+ (0, 0, 10), # wrong length (too few)
+])
+def test_indicate_region_degenerate_region_raises(region):
+ fig, ax = _make_fig()
+ inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ with pytest.raises(ValueError):
+ inset.indicate_region(ax._plot, region)
+
+
+def test_indicate_region_out_of_bounds_is_allowed():
+ """A region that extends outside the parent's data bounds is allowed BY
+ DESIGN (clipping is a visual concern, not a validation error) — only
+ degenerate/non-finite values are rejected."""
+ fig, ax = _make_fig() # parent image is 64x64
+ inset = fig.add_inset(0.3, 0.3, anchor=(0.5, 0.1))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ # Region far outside the 64x64 parent image bounds — must NOT raise.
+ inset.indicate_region(ax._plot, (1000, 1000, 50, 50))
+ assert inset.indication is not None
+ assert inset.indication["region"] == [1000.0, 1000.0, 50.0, 50.0]
+
+
+def test_two_insets_two_indications():
+ fig, ax = _make_fig()
+ i1 = fig.add_inset(0.25, 0.25, anchor=(0.6, 0.05))
+ i1.imshow(np.zeros((16, 16), dtype=np.float32))
+ i2 = fig.add_inset(0.25, 0.25, anchor=(0.6, 0.6))
+ i2.imshow(np.zeros((16, 16), dtype=np.float32))
+ i1.indicate_region(ax._plot, (5, 5, 10, 10))
+ i2.indicate_region(ax._plot, (40, 40, 10, 10))
+
+ inds = json.loads(fig.layout_json)["indications"]
+ assert len(inds) == 2
+ ids = {ind["inset_id"] for ind in inds}
+ assert ids == {i1._plot._id, i2._plot._id}
+
+
+# ── figure_state / save_html round-trip ───────────────────────────────────
+
+def test_indication_survives_figure_state_roundtrip():
+ """The indication lives in layout_json (a synced trait) so figure_state()
+ — the exact state mount()/save_html feed the renderer — carries it."""
+ fig, ax = _make_fig()
+ inset = fig.add_inset(0.3, 0.25, anchor=(0.55, 0.1))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+ inset.indicate_region(ax._plot, (10, 10, 20, 20), color="#abcdef")
+
+ state = figure_state(fig)
+ layout = json.loads(state["layout_json"])
+ assert layout["indications"][0]["color"] == "#abcdef"
+ # anchor placement also survives
+ assert layout["inset_specs"][0]["anchor"] == [0.55, 0.1]
+
+
+def test_save_html_contains_indication():
+ fig, ax = _make_fig()
+ inset = fig.add_inset(0.3, 0.25, anchor=(0.55, 0.1))
+ inset.imshow(np.zeros((16, 16), dtype=np.float32))
+ inset.indicate_region(ax._plot, (10, 10, 20, 20), color="#abcdef")
+
+ html = fig.to_html()
+ assert "indications" in html
+ assert "#abcdef" in html
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Playwright tests — public mount() handle
+# ═══════════════════════════════════════════════════════════════════════════
+
+_MOUNT_PAGE = """
+
+
+
+
+"""
+
+
+@pytest.fixture
+def mount_page(_pw_browser):
+ """Open a figure via the public mount() API; return the live Page."""
+ pages, paths = [], []
+
+ def _open(fig):
+ html = (_MOUNT_PAGE
+ .replace("__STATE__", json.dumps(figure_state(fig)))
+ .replace("__ESM__", json.dumps(esm_path().read_text(encoding="utf-8"))))
+ with tempfile.NamedTemporaryFile(
+ suffix=".html", mode="w", encoding="utf-8", delete=False
+ ) as fh:
+ fh.write(html)
+ tmp = pathlib.Path(fh.name)
+ paths.append(tmp)
+ page = _pw_browser.new_page()
+ pages.append(page)
+ page.goto(tmp.as_uri())
+ page.wait_for_function("() => window._aplReady === true", timeout=15_000)
+ # Three rAFs: layout settle + the deferred first callout draw.
+ page.evaluate(
+ "() => new Promise(r => requestAnimationFrame(() => "
+ "requestAnimationFrame(() => requestAnimationFrame(r))))"
+ )
+ return page
+
+ yield _open
+ for p in pages:
+ try:
+ p.close()
+ except Exception:
+ pass
+ for f in paths:
+ f.unlink(missing_ok=True)
+
+
+# JS helpers evaluated in-page ------------------------------------------------
+
+# Bounding box (in callout-canvas CSS px) of pixels whose colour matches `rgb`
+# within a per-channel tolerance. Returns null when no such pixel exists.
+_COLOR_BBOX_JS = """
+([rgb, tol]) => {
+ const cv = window._handle.api.calloutCanvas;
+ const dpr = window.devicePixelRatio || 1;
+ const ctx = cv.getContext('2d');
+ const img = ctx.getImageData(0, 0, cv.width, cv.height);
+ const d = img.data, W = cv.width, H = cv.height;
+ let minX=1e9,minY=1e9,maxX=-1,maxY=-1,count=0;
+ for (let y=0;ymaxX)maxX=x;
+ if(ymaxY)maxY=y;
+ }
+ }
+ if(count===0) return null;
+ // Return in CSS px (divide by dpr) for stable, resolution-independent asserts.
+ return {minX:minX/dpr,minY:minY/dpr,maxX:maxX/dpr,maxY:maxY/dpr,count};
+}
+"""
+
+
+def _color_bbox(page, rgb, tol=40):
+ return page.evaluate(_COLOR_BBOX_JS, [list(rgb), tol])
+
+
+def _inset_dom_rect(page, plot_id):
+ return page.evaluate(
+ """(pid) => {
+ const p = window._handle.api.panels.get(pid);
+ const r = p.insetDiv.getBoundingClientRect();
+ const base = window._handle.api.calloutCanvas.getBoundingClientRect();
+ return {left: r.left - base.left, top: r.top - base.top,
+ width: r.width, height: r.height};
+ }""",
+ plot_id,
+ )
+
+
+def _set_parent_view(page, parent_id, zoom, cx, cy):
+ """Set the parent panel's zoom/center in the model and force a redraw."""
+ page.evaluate(
+ """([pid, zoom, cx, cy]) => {
+ const key = 'panel_' + pid + '_json';
+ const st = JSON.parse(window._handle.get(key));
+ st.zoom = zoom; st.center_x = cx; st.center_y = cy;
+ // Mark as a Python-driven view so _preserveView doesn't restore the
+ // old (identity) view over the one we're setting.
+ st._view_from_python = true;
+ window._handle.set(key, JSON.stringify(st));
+ }""",
+ [parent_id, zoom, cx, cy],
+ )
+ page.evaluate("() => new Promise(r => requestAnimationFrame("
+ "() => requestAnimationFrame(r)))")
+
+
+# ── the figure the Playwright tests share ──────────────────────────────────
+
+def _callout_fig(color="#00ff00", region=(8, 8, 24, 24), anchor=(0.55, 0.08)):
+ """640×480 image + anchored inset with a bright-colour indication.
+
+ A pure primary colour (#00ff00 lime) is used so the callout ink is trivially
+ distinguishable from the grayscale parent image and any theme chrome.
+ """
+ fig, ax = apl.subplots(1, 1, figsize=(640, 480))
+ parent = ax.imshow(np.zeros((64, 64), dtype=np.float32),
+ cmap="gray", vmin=0.0, vmax=1.0)
+ inset = fig.add_inset(0.30, 0.30, anchor=anchor, title="Callout")
+ inset.imshow(np.zeros((32, 32), dtype=np.float32))
+ inset.indicate_region(parent, region, color=color, linewidth=2.5)
+ return fig, ax, parent, inset
+
+
+class TestAnchoredPlacement:
+ def test_anchored_inset_dom_position(self, mount_page):
+ """The anchored inset's DOM top-left sits at anchor × figure size."""
+ fig, ax = apl.subplots(1, 1, figsize=(640, 480))
+ ax.imshow(np.zeros((64, 64), dtype=np.float32))
+ inset = fig.add_inset(0.30, 0.25, anchor=(0.55, 0.10))
+ plot = inset.imshow(np.zeros((16, 16), dtype=np.float32))
+
+ page = mount_page(fig)
+ rect = _inset_dom_rect(page, plot._id)
+ # anchor (0.55, 0.10) of 640×480 → (352, 48), a few px tolerance.
+ assert abs(rect["left"] - 0.55 * 640) <= 3, rect
+ assert abs(rect["top"] - 0.10 * 480) <= 3, rect
+ # width ≈ 0.30 * 640 = 192
+ assert abs(rect["width"] - 0.30 * 640) <= 4, rect
+
+
+class TestCalloutRendering:
+ def test_dashed_rect_and_leaders_present(self, mount_page):
+ """The callout canvas paints lime ink spanning FROM the parent region
+ (top-left of figure) TO the inset (right side) — i.e. rect + leaders."""
+ fig, ax, parent, inset = _callout_fig(color="#00ff00",
+ region=(8, 8, 24, 24),
+ anchor=(0.55, 0.08))
+ page = mount_page(fig)
+
+ bbox = _color_bbox(page, (0, 255, 0))
+ assert bbox is not None, "no lime callout pixels found"
+ assert bbox["count"] > 30, f"too little callout ink: {bbox}"
+
+ # Rect lives in the parent image (left ~half); the inset is anchored at
+ # x=0.55 → the leaders extend the lime ink rightward to the inset's near
+ # (left) edge at ≈0.55×640=352. So the ink reaches close to that edge.
+ assert bbox["minX"] < 0.5 * 640, f"rect not on the left: {bbox}"
+ inset_left = 0.55 * 640
+ assert bbox["maxX"] >= inset_left - 6, (
+ f"leaders don't reach the inset (left≈{inset_left}): {bbox}"
+ )
+ # And the horizontal span is wide (rect + leaders), not just a small rect.
+ assert (bbox["maxX"] - bbox["minX"]) > 120, f"span too narrow: {bbox}"
+
+ def test_rect_at_expected_parent_location(self, mount_page):
+ """A small region near the parent's top-left maps to lime ink near the
+ parent image's top-left (no zoom: image fills the panel)."""
+ # region (4,4,12,12) on a 64px image, panel ~ full width.
+ fig, ax, parent, inset = _callout_fig(color="#00ff00",
+ region=(4, 4, 12, 12),
+ anchor=(0.6, 0.5))
+ page = mount_page(fig)
+ bbox = _color_bbox(page, (0, 255, 0))
+ assert bbox is not None
+ # The rect's own top-left corner should be well within the top-left
+ # quadrant of the figure (region 4/64 ≈ 6% into a ~full-panel image).
+ assert bbox["minX"] < 0.25 * 640, bbox
+ assert bbox["minY"] < 0.30 * 480, bbox
+
+ def test_minimize_hides_leaders(self, mount_page):
+ """Minimizing the inset drops the leader ink but keeps the rect."""
+ fig, ax, parent, inset = _callout_fig(color="#00ff00",
+ region=(8, 8, 24, 24),
+ anchor=(0.55, 0.08))
+ page = mount_page(fig)
+
+ full = _color_bbox(page, (0, 255, 0))
+ assert full is not None
+
+ # Minimize the inset by updating layout_json's inset_state (this is what
+ # the Python side does on a title-bar click → _push_layout → applyLayout).
+ page.evaluate(
+ """(pid) => {
+ const layout = JSON.parse(window._handle.get('layout_json'));
+ for (const s of layout.inset_specs)
+ if (s.id === pid) s.inset_state = 'minimized';
+ window._handle.set('layout_json', JSON.stringify(layout));
+ }""",
+ inset._plot._id,
+ )
+ page.evaluate("() => new Promise(r => requestAnimationFrame("
+ "() => requestAnimationFrame(() => requestAnimationFrame(r))))")
+
+ mini = _color_bbox(page, (0, 255, 0))
+ assert mini is not None, "rect vanished when minimized (should stay)"
+ # Leaders reached toward the inset (x≈352); minimized ink stops at the
+ # rect's own right edge (well left of the inset).
+ assert mini["maxX"] < full["maxX"] - 15, (
+ f"leaders still present when minimized: full={full} mini={mini}"
+ )
+
+ def test_rect_tracks_parent_zoom(self, mount_page):
+ """Zooming the parent moves the dashed rect (it is mapped through the
+ parent's data→screen transform every draw)."""
+ # Small region near the parent's TOP-LEFT so zooming to the CENTRE of
+ # the image pushes the rect up-and-left (or off) — a clear position
+ # shift that doesn't depend on clip behaviour.
+ fig, ax, parent, inset = _callout_fig(color="#00ff00",
+ region=(4, 4, 10, 10),
+ anchor=(0.55, 0.55))
+ page = mount_page(fig)
+
+ before = _color_bbox(page, (0, 255, 0))
+ assert before is not None
+ # Record where the rect's top-left corner sits before zoom.
+ before_min = (before["minX"], before["minY"])
+
+ # Zoom 2× centred on the IMAGE centre (0.5, 0.5): the top-left region
+ # moves further toward the top-left corner (its data coord is far from
+ # the new view centre), so the rect's min corner shifts measurably.
+ _set_parent_view(page, parent._id, zoom=2.0, cx=0.5, cy=0.5)
+ after = _color_bbox(page, (0, 255, 0))
+ assert after is not None, "callout gone after zoom"
+ after_min = (after["minX"], after["minY"])
+
+ shift = ((after_min[0] - before_min[0]) ** 2
+ + (after_min[1] - before_min[1]) ** 2) ** 0.5
+ assert shift > 15, (
+ f"rect did not move on zoom (shift={shift:.1f}px): "
+ f"before={before} after={after}"
+ )
+
+ def test_empty_overlays_do_not_shadow_panel_content(self, mount_page):
+ """A figure with NO indication (and no figure markers) must keep both
+ figure-level overlay canvases (callout + figMarker) collapsed to a 0×0
+ backing store.
+
+ Regression: those overlays used to size themselves to the FULL figure
+ even when empty, making them the largest transparent canvases in the
+ DOM — which shadowed the panel content for any consumer/test that
+ samples "the largest canvas" (this is exactly what broke the tiled-image
+ render tests). The largest canvas must be a panel content canvas, and
+ it must have real (non-transparent) pixels.
+ """
+ fig, ax = apl.subplots(1, 1, figsize=(300, 300))
+ ax.imshow(np.tile(np.linspace(0, 1, 64, dtype=np.float32), (64, 1)),
+ cmap="gray", vmin=0.0, vmax=1.0)
+ page = mount_page(fig)
+
+ info = page.evaluate(
+ """() => {
+ const co = window._handle.api.calloutCanvas;
+ const fm = window._handle.api.figMarkerCanvas;
+ const cs = Array.from(document.querySelectorAll('canvas'))
+ .sort((a, b) => b.width * b.height - a.width * a.height);
+ const largest = cs[0];
+ const px = largest.getContext('2d').getImageData(
+ (largest.width * 0.5) | 0, (largest.height * 0.5) | 0, 1, 1).data[0];
+ return {
+ callout: [co.width, co.height],
+ figMarker: [fm.width, fm.height],
+ largest: [largest.width, largest.height],
+ isOverlay: largest === co || largest === fm,
+ largestPx: px,
+ };
+ }"""
+ )
+ assert info["callout"] == [0, 0], (
+ f"empty callout canvas not collapsed: {info}")
+ assert info["figMarker"] == [0, 0], (
+ f"empty figMarker canvas not collapsed: {info}")
+ assert not info["isOverlay"], (
+ f"an empty overlay is the largest canvas (shadows panels): {info}")
+ assert info["largestPx"] > 0, (
+ f"largest canvas has no rendered content: {info}")
+
+
+class TestCalloutExport:
+ def _decode(self, data_url):
+ raw = base64.b64decode(data_url.split(",", 1)[1])
+ return decode_png(raw)
+
+ def _closest(self, arr, rgb, tol=40):
+ d = np.abs(arr[..., :3].astype(np.int32) - np.asarray(rgb, np.int32))
+ return int(((d <= tol).all(axis=-1)).sum())
+
+ def test_export_png_includes_indication(self, mount_page):
+ """exportPNG composites the callout: lime ink present WITH an indication
+ and absent from an otherwise-identical figure WITHOUT one."""
+ fig, ax, parent, inset = _callout_fig(color="#00ff00",
+ region=(8, 8, 24, 24),
+ anchor=(0.55, 0.08))
+ page = mount_page(fig)
+ res = page.evaluate(
+ "() => window._handle.exportPNG({}).then(r => ({dataUrl:r.dataUrl}))")
+ arr = self._decode(res["dataUrl"])
+ n_with = self._closest(arr, (0, 255, 0))
+ assert n_with > 50, f"indication missing from export ({n_with} lime px)"
+
+ # Control: same figure minus the indication → little/no lime.
+ fig2, ax2 = apl.subplots(1, 1, figsize=(640, 480))
+ ax2.imshow(np.zeros((64, 64), dtype=np.float32),
+ cmap="gray", vmin=0.0, vmax=1.0)
+ i2 = fig2.add_inset(0.30, 0.30, anchor=(0.55, 0.08))
+ i2.imshow(np.zeros((32, 32), dtype=np.float32))
+ page2 = mount_page(fig2)
+ res2 = page2.evaluate(
+ "() => window._handle.exportPNG({}).then(r => ({dataUrl:r.dataUrl}))")
+ arr2 = self._decode(res2["dataUrl"])
+ n_without = self._closest(arr2, (0, 255, 0))
+ assert n_without < n_with // 4, (
+ f"control has too much lime: with={n_with} without={n_without}"
+ )
diff --git a/anyplotlib/tests/test_plot2d/test_layers.py b/anyplotlib/tests/test_plot2d/test_layers.py
index ef426d1e..42a9d616 100644
--- a/anyplotlib/tests/test_plot2d/test_layers.py
+++ b/anyplotlib/tests/test_plot2d/test_layers.py
@@ -127,6 +127,54 @@ def test_set_returns_self_for_chaining(self):
lyr = p.add_layer(np.ones((32, 32), np.float32))
assert lyr.set(alpha=0.3).set(cmap="viridis") is lyr
+ def test_set_clim_none_is_a_noop(self):
+ """clim=None means 'leave unchanged' — NOT reset to auto. Regression
+ guard for the (fixed) misleading docstring/behaviour gap."""
+ _fig, p = _imshow()
+ data = np.full((32, 32), 5.0, np.float32)
+ lyr = p.add_layer(data, clim=(0, 10))
+ tok0 = p._state["layers"][0]["image_b64"]
+ lyr.set(clim=None) # explicit no-op call
+ assert lyr.clim == (0.0, 10.0)
+ assert p._state["layers"][0]["image_b64"] == tok0
+
+ def test_set_clim_auto_resets_to_data_range(self):
+ """clim='auto' is the new sentinel: recompute the display range from
+ the layer's CURRENT data min/max, discarding the previous explicit
+ clim — the only way back to auto after an explicit clim was set."""
+ _fig, p = _imshow()
+ data = np.linspace(2.0, 6.0, 32 * 32, dtype=np.float32).reshape(32, 32)
+ lyr = p.add_layer(data, clim=(0, 100)) # explicit clim, far from data range
+ assert lyr.clim == (0.0, 100.0)
+ tok_explicit = p._state["layers"][0]["image_b64"]
+
+ lyr.set(clim="auto")
+ assert lyr.clim == (pytest.approx(2.0), pytest.approx(6.0))
+ # And the pixels were re-quantised (not just the metadata) — different
+ # window means a different code for the same data.
+ assert p._state["layers"][0]["image_b64"] != tok_explicit
+
+ def test_set_clim_auto_matches_add_layer_auto(self):
+ """clim='auto' after set(...) must reproduce exactly what add_layer's
+ own clim=None auto-ranging would have produced for the same data."""
+ _fig, p1 = _imshow()
+ data = np.linspace(-3.0, 9.0, 32 * 32, dtype=np.float32).reshape(32, 32)
+ lyr1 = p1.add_layer(data, clim=(0, 1))
+ lyr1.set(clim="auto")
+
+ _fig2, p2 = _imshow()
+ lyr2 = p2.add_layer(data, clim=None) # auto from creation
+
+ assert lyr1.clim == lyr2.clim
+ assert (p1._state["layers"][0]["image_b64"]
+ == p2._state["layers"][0]["image_b64"])
+
+ def test_set_clim_invalid_string_raises(self):
+ _fig, p = _imshow()
+ lyr = p.add_layer(np.ones((32, 32), np.float32))
+ with pytest.raises(ValueError):
+ lyr.set(clim="not-a-valid-sentinel")
+
class TestSetData:
def test_set_data_swaps_pixels_one_push(self):
@@ -227,18 +275,68 @@ def test_enable_tile_on_layered_plot_raises(self):
p.enable_tile(np.zeros((2048, 2048), np.float32))
def test_set_data_tile_true_on_layered_raises(self):
- _fig, p = _imshow(n=64)
- p.add_layer(np.ones((64, 64), np.float32))
+ # Same-shape frame (2048x2048 base + layer, 2048x2048 new frame) so
+ # this exercises ONLY the tile-mode guard, not the (separate) shape
+ # guard from TestTileGuards/TestShapeChangeNoLayers below. tile=False
+ # on construction keeps the base itself plain (not auto-tiled) so
+ # add_layer is allowed.
+ fig, ax = apl.subplots(1, 1, figsize=(300, 300))
+ p = ax.imshow(np.zeros((2048, 2048), np.float32), cmap="gray",
+ vmin=0, vmax=1, gpu=False, tile=False)
+ p.add_layer(np.ones((2048, 2048), np.float32))
with pytest.raises(RuntimeError, match="image layers"):
p.set_data(np.zeros((2048, 2048), np.float32), tile=True)
def test_large_set_data_on_layered_stays_plain(self):
- _fig, p = _imshow(n=64)
- p.add_layer(np.ones((64, 64), np.float32))
+ # Same-shape (base already 2048x2048, above TILE_THRESHOLD) so the new
+ # frame's shape matches — isolates the tile-auto-enable guard from the
+ # shape guard. tile=False on construction keeps the base plain so
+ # add_layer is allowed; the guard under test is set_data's OWN
+ # auto-tile decision on a later call, not the constructor's.
+ fig, ax = apl.subplots(1, 1, figsize=(300, 300))
+ p = ax.imshow(np.zeros((2048, 2048), np.float32), cmap="gray",
+ vmin=0, vmax=1, gpu=False, tile=False)
+ p.add_layer(np.ones((2048, 2048), np.float32))
# A large frame that would normally auto-enable tiling must stay plain.
- p.set_data(np.zeros((2048, 2048), np.float32))
+ p.set_data(np.full((2048, 2048), 0.5, np.float32))
assert p._tile_on is False
+ def test_shape_changing_set_data_on_layered_plot_raises(self):
+ """A layer keeps the (H, W) it had at add_layer time; JS fits every
+ layer's bitmap into the base image's CURRENT fit-rect, so a
+ shape-changing base set_data would silently stretch a stale-sized
+ layer over the new image. Must raise instead of corrupting."""
+ _fig, p = _imshow(n=32)
+ p.add_layer(np.ones((32, 32), np.float32))
+ with pytest.raises(ValueError, match="image layers"):
+ p.set_data(np.zeros((16, 16), np.float32))
+ # And the plot's own image is untouched by the rejected call.
+ assert p._state["image_width"] == 32
+ assert p._state["image_height"] == 32
+
+ def test_same_shape_set_data_on_layered_plot_is_fine(self):
+ """The guard only fires on an actual shape CHANGE — same-size updates
+ (the common live-update case) must keep working."""
+ _fig, p = _imshow(n=32)
+ p.add_layer(np.ones((32, 32), np.float32))
+ p.set_data(np.full((32, 32), 0.5, np.float32)) # must not raise
+ assert p._state["image_width"] == 32
+ assert p._state["image_height"] == 32
+
+
+class TestShapeChangeNoLayers:
+ """A plain (layer-free) plot must always accept a shape-changing
+ set_data and refresh image_width/image_height to the new frame size —
+ the ValueError guard in TestTileGuards is layers-only."""
+
+ def test_shape_changing_set_data_updates_image_dims(self):
+ _fig, p = _imshow(n=32)
+ assert p._state["image_width"] == 32
+ assert p._state["image_height"] == 32
+ p.set_data(np.zeros((16, 24), np.float32))
+ assert p._state["image_height"] == 16
+ assert p._state["image_width"] == 24
+
class TestBinaryTransportRoute:
"""The layer pixel bytes must travel over BOTH transports: base64-in-JSON and
diff --git a/anyplotlib/widgets/__init__.py b/anyplotlib/widgets/__init__.py
index b8b4ff1b..e61a0a82 100644
--- a/anyplotlib/widgets/__init__.py
+++ b/anyplotlib/widgets/__init__.py
@@ -2,7 +2,7 @@
from anyplotlib.widgets._base import Widget
from anyplotlib.widgets._widgets2d import (
RectangleWidget, CircleWidget, AnnularWidget,
- CrosshairWidget, PolygonWidget, LabelWidget,
+ CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget,
)
from anyplotlib.widgets._widgets1d import (
VLineWidget, HLineWidget, RangeWidget, PointWidget,
@@ -12,7 +12,7 @@
__all__ = [
"Widget",
"RectangleWidget", "CircleWidget", "AnnularWidget",
- "CrosshairWidget", "PolygonWidget", "LabelWidget",
+ "CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget",
"VLineWidget", "HLineWidget", "RangeWidget", "PointWidget",
"PlaneWidget",
]
diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py
index 5a2456dc..9f5090d9 100644
--- a/anyplotlib/widgets/_widgets2d.py
+++ b/anyplotlib/widgets/_widgets2d.py
@@ -2,6 +2,12 @@
widgets/_widgets2d.py
=====================
Interactive overlay widgets for 2-D image panels (Plot2D / InsetAxes).
+
+Every 2-D widget accepts ``show_handles`` (default ``True``). When ``False``
+the widget body still draws and stays fully hit-testable / draggable, but the
+small square grab-handle dots are omitted — a cleaner look for a finished
+annotation. It rides along in ``_data`` so it serialises into the panel state's
+``overlay_widgets`` list and reaches the JS renderer unchanged.
"""
from __future__ import annotations
@@ -21,11 +27,15 @@ class RectangleWidget(Widget):
Width and height in pixel/data coordinates.
color : str, optional
CSS colour for the rectangle outline. Default ``"#00e5ff"``.
+ show_handles : bool, optional
+ Draw the corner grab handles. Default ``True``.
"""
- def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff"):
+ def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff",
+ show_handles=True):
super().__init__("rectangle", push_fn,
x=float(x), y=float(y),
- w=float(w), h=float(h), color=color)
+ w=float(w), h=float(h), color=color,
+ show_handles=bool(show_handles))
class CircleWidget(Widget):
@@ -41,10 +51,14 @@ class CircleWidget(Widget):
Radius in pixel/data coordinates.
color : str, optional
CSS colour for the circle outline. Default ``"#00e5ff"``.
+ show_handles : bool, optional
+ Draw the radius grab handle. Default ``True``.
"""
- def __init__(self, push_fn, *, cx, cy, r, color="#00e5ff"):
+ def __init__(self, push_fn, *, cx, cy, r, color="#00e5ff",
+ show_handles=True):
super().__init__("circle", push_fn,
- cx=float(cx), cy=float(cy), r=float(r), color=color)
+ cx=float(cx), cy=float(cy), r=float(r), color=color,
+ show_handles=bool(show_handles))
class AnnularWidget(Widget):
@@ -61,19 +75,22 @@ class AnnularWidget(Widget):
Inner radius must be less than outer radius.
color : str, optional
CSS colour for the ring outline. Default ``"#00e5ff"``.
+ show_handles : bool, optional
+ Draw the inner/outer radius grab handles. Default ``True``.
Raises
------
ValueError
If r_inner >= r_outer.
"""
- def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff"):
+ def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff",
+ show_handles=True):
if r_inner >= r_outer:
raise ValueError("r_inner must be < r_outer")
super().__init__("annular", push_fn,
cx=float(cx), cy=float(cy),
r_outer=float(r_outer), r_inner=float(r_inner),
- color=color)
+ color=color, show_handles=bool(show_handles))
class CrosshairWidget(Widget):
@@ -87,10 +104,13 @@ class CrosshairWidget(Widget):
Center position in pixel/data coordinates.
color : str, optional
CSS colour for the crosshair. Default ``"#00e5ff"``.
+ show_handles : bool, optional
+ Draw the centre dot handle. Default ``True``.
"""
- def __init__(self, push_fn, *, cx, cy, color="#00e5ff"):
+ def __init__(self, push_fn, *, cx, cy, color="#00e5ff", show_handles=True):
super().__init__("crosshair", push_fn,
- cx=float(cx), cy=float(cy), color=color)
+ cx=float(cx), cy=float(cy), color=color,
+ show_handles=bool(show_handles))
class PolygonWidget(Widget):
@@ -105,17 +125,20 @@ class PolygonWidget(Widget):
Must have at least 3 vertices.
color : str, optional
CSS colour for the polygon outline. Default ``"#00e5ff"``.
+ show_handles : bool, optional
+ Draw the per-vertex grab handles. Default ``True``.
Raises
------
ValueError
If fewer than 3 vertices provided.
"""
- def __init__(self, push_fn, *, vertices, color="#00e5ff"):
+ def __init__(self, push_fn, *, vertices, color="#00e5ff", show_handles=True):
verts = [[float(x), float(y)] for x, y in vertices]
if len(verts) < 3:
raise ValueError("polygon needs >= 3 vertices")
- super().__init__("polygon", push_fn, vertices=verts, color=color)
+ super().__init__("polygon", push_fn, vertices=verts, color=color,
+ show_handles=bool(show_handles))
class LabelWidget(Widget):
@@ -133,9 +156,43 @@ class LabelWidget(Widget):
Font size in points. Default 14.
color : str, optional
CSS colour for the text. Default ``"#00e5ff"``.
+ show_handles : bool, optional
+ Draw the anchor grab handle. Default ``True``.
"""
def __init__(self, push_fn, *, x, y, text="Label", fontsize=14,
- color="#00e5ff"):
+ color="#00e5ff", show_handles=True):
super().__init__("label", push_fn,
x=float(x), y=float(y),
- text=str(text), fontsize=int(fontsize), color=color)
+ text=str(text), fontsize=int(fontsize), color=color,
+ show_handles=bool(show_handles))
+
+
+class ArrowWidget(Widget):
+ """Draggable arrow overlay widget for 2-D plots.
+
+ The arrow tail sits at ``(x, y)`` and the head at ``(x + u, y + v)``, all in
+ image-pixel coordinates. Dragging the body moves the whole arrow; dragging
+ the head handle re-aims it (updates ``u``/``v``).
+
+ Parameters
+ ----------
+ push_fn : Callable
+ Update callback.
+ x, y : float
+ Tail position in pixel/data coordinates.
+ u, v : float
+ Arrow vector (head = tail + (u, v)) in pixel/data coordinates.
+ color : str, optional
+ CSS colour for the arrow. Default ``"#00e5ff"``.
+ linewidth : float, optional
+ Shaft line width in px. Default 2.
+ show_handles : bool, optional
+ Draw the tail/head grab handles. Default ``True``.
+ """
+ def __init__(self, push_fn, *, x, y, u, v, color="#00e5ff",
+ linewidth=2, show_handles=True):
+ super().__init__("arrow", push_fn,
+ x=float(x), y=float(y),
+ u=float(u), v=float(v), color=color,
+ linewidth=float(linewidth),
+ show_handles=bool(show_handles))
diff --git a/upcoming_changes/+arrow-widget-show-handles.new_feature.rst b/upcoming_changes/+arrow-widget-show-handles.new_feature.rst
new file mode 100644
index 00000000..fa44243c
--- /dev/null
+++ b/upcoming_changes/+arrow-widget-show-handles.new_feature.rst
@@ -0,0 +1,4 @@
+Added :class:`~anyplotlib.widgets.ArrowWidget` (draggable arrow overlay, tail
+at ``(x, y)`` and head at ``(x + u, y + v)``) via ``Plot2D.add_arrow_widget`` /
+``add_widget("arrow")``, and a ``show_handles`` option (default ``True``) on
+every 2-D overlay widget to hide the grab-handle dots without affecting drag.
diff --git a/upcoming_changes/+edit-mode-resize-swap.new_feature.rst b/upcoming_changes/+edit-mode-resize-swap.new_feature.rst
new file mode 100644
index 00000000..7603080e
--- /dev/null
+++ b/upcoming_changes/+edit-mode-resize-swap.new_feature.rst
@@ -0,0 +1,22 @@
+Extended :class:`~anyplotlib.Figure` edit-mode interaction:
+
+* Circle and rectangle overlay widgets are now **resizable via visible nodes** —
+ a circle draws a centre (move) node and an east-point radius node; a rectangle
+ draws all four corner nodes (opposite corner anchored on drag). Drawn only
+ when ``show_handles`` is ``True``, with matching resize cursors.
+* :class:`~anyplotlib.widgets.ArrowWidget` **tail is now a reshape node**:
+ dragging the tail moves it while the head stays anchored (dragging the shaft
+ still moves the whole arrow; the head node still re-aims it).
+* The selected-panel and hover **outlines are fully inset** (``outline-offset:
+ -2px``) so an edge/corner panel's ring is no longer clipped at the figure's
+ right/bottom edge.
+* **Panel drag-swap** under ``edit_chrome``: each grid panel shows a move grip
+ in its top-left corner; dragging it over a *different* panel emits a
+ figure-level ``pointer_up`` event with ``panel_swap: true`` and
+ ``source_panel_id`` / ``target_panel_id`` (new :class:`~anyplotlib.Event`
+ fields). anyplotlib performs no layout change itself — the host swaps and
+ rebuilds. Releasing on the source panel or empty space cancels cleanly; the
+ grip is inert when ``edit_chrome`` is off.
+* The JS ``mount()`` embedding entry point accepts an ``onResize({width,
+ height})`` callback, fired (debounced) when the **root container resizes**, so
+ an embedding host can relayout the figure to its new box.
diff --git a/upcoming_changes/+figure-edit-chrome.new_feature.rst b/upcoming_changes/+figure-edit-chrome.new_feature.rst
new file mode 100644
index 00000000..f746d953
--- /dev/null
+++ b/upcoming_changes/+figure-edit-chrome.new_feature.rst
@@ -0,0 +1,6 @@
+Added figure-level edit-mode chrome to :class:`~anyplotlib.Figure`: the
+``edit_chrome`` and ``selected_panel`` traits (per-panel hover / selection
+outlines), figure-background click events, and a figure-level annotation layer
+(``set_figure_markers`` / ``figure_markers``, positioned in figure fractions and
+always included in ``exportPNG``) with figure-level callbacks via
+``add_event_handler``.