From c766c4705a420065514aced1bdb8484add187a5a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 12 Jul 2026 11:01:15 -0500 Subject: [PATCH 1/6] feat(plot2d): multi-image layers with per-layer cmap, clim, and alpha plot2d.add_layer(data, cmap=, alpha=, clim=, visible=) returns a Layer handle (set/set_data/remove); layers draw bottom-up above the base image with the base's exact fit-rect + zoom/pan transform, under axes, markers, and widgets, and composite in Canvas2D even over a WebGPU base. Per-layer LUT bitmaps are cached by (pixels, cmap, clim) so unchanged layers cost nothing on repaint; exportPNG captures layers for free. Heavy pixels ride dynamic geom keys (layer__b64): _GEOM_KEYS is now a property that unions the fixed base set with live layer keys, the Electron binary route ships each layer as its own PLOTBIN frame, and _spliceBinaryBytes scans by geom prefix instead of a hardcoded key list. resolve_pixel_tokens materialises layer base64 so save_html snapshots stay self-contained. Layers and tile mode exclude each other (enforced both directions). Layer data must match the base (H, W). 30 unit + 5 Playwright tests (incl. alpha-blend pixel assertions and the WebGPU-base + Canvas2D-layer readback case). --- anyplotlib/FIGURE_ESM.md | 78 +++++ anyplotlib/_electron.py | 17 +- anyplotlib/figure_esm.js | 179 ++++++++-- anyplotlib/plot2d/__init__.py | 5 +- anyplotlib/plot2d/_layer.py | 108 ++++++ anyplotlib/plot2d/_plot2d.py | 304 +++++++++++++++- anyplotlib/tests/test_plot2d/test_layers.py | 329 ++++++++++++++++++ .../test_plot2d/test_layers_playwright.py | 313 +++++++++++++++++ 8 files changed, 1300 insertions(+), 33 deletions(-) create mode 100644 anyplotlib/plot2d/_layer.py create mode 100644 anyplotlib/tests/test_plot2d/test_layers.py create mode 100644 anyplotlib/tests/test_plot2d/test_layers_playwright.py diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index 99c25bb8..ee99b848 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -63,6 +63,8 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | `drawScaleBar2d` / `drawColorbar2d` | 1360 / 1436 | | `_drawAxes2d` (ticks, labels, title) | 1491 | | `drawOverlay2d` / `drawMarkers2d` | 1629 / 1685 | +| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 1553 / 1577 / 1629 | +| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 675 / 706 | | **3D drawing**: `draw3d` | 1833 | | Event emission `_emitEvent` | 2031 | | 3D event handlers `_attachEvents3d` | 2059 | @@ -196,6 +198,82 @@ inverses of the blit geometry. --- +## Image layers (multi-image overlay) + +`Plot2D.add_layer(data, cmap=, alpha=, clim=, visible=)` composites a second +(third, …) scalar image OVER the base image in the same panel, each with its own +colormap / clim / alpha. Distinct from `set_overlay_mask` (single-colour boolean +mask). `Layer.set(...)`, `Layer.set_data(frame)`, `Layer.remove()`, +`Plot2D.layers`, `Plot2D.remove_layer(layer)`. **Layers and tile mode are mutually +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). + +### State + transport (dynamic per-layer pixel keys) + +The layer *metadata* lives in `st.layers` (a list of small dicts on the light view +trait): + +``` +st.layers = [{ id, cmap, clim_min, clim_max, alpha, visible, + width, height, colormap_data, image_b64 }, …] # z-order +``` + +`image_b64` in each entry is the layer's pixels: a base64 string (Jupyter / +standalone / `save_html`) OR a `"\x00bin:"` change-token (Electron binary +transport). The JS reads pixels from this entry field on the base64 path. + +The *heavy pixel bytes* additionally ride a **DYNAMIC geometry key** +`layer__b64` — one per layer — mirroring how the base image `image_b64` rides +the geom channel. The dynamic-key mechanism: + +- **`Plot2D._GEOM_KEYS` is a PROPERTY** (not a plain frozenset): it returns the + fixed base set (`image_b64`, `colormap_data`, `overlay_mask_b64`, `detail_b64`) + UNION the current `layer__b64` keys. So `Figure._push` splits every layer's + pixels off the light view trait onto `panel__geom` and dedup-caches them + exactly like the base image; a removed layer's key drops out automatically. +- **`_electron._route_change`** ships each layer key as its own PLOTBIN frame: + `_is_binary_pixel_key(k)` matches `k in _BINARY_KEYS` OR `layer_*_b64`. The + binary frame header carries `{"geom": "panel__geom"}` and `key=layer__b64`, + so the receiver builds slot `panel__geom::layer__b64` (the same + `awi_state_binary` handler as the base image — already generic on `hdr.geom` + + `e.data.key`, no change needed there). +- **`resolve_pixel_tokens`** (cold path: `save_html` / standalone) materialises + real base64 for every `layer__b64` key AND the entry `image_b64` mirror, so a + snapshot is self-contained. +- **JS `_spliceBinaryBytes`** scans `__apl_pixbytes` by the `panel__geom::` + PREFIX (not the old hardcoded 3-key list) so it splices any `layer__b64_bytes` + into `p2._geomCache`. The per-slot binary listeners are registered only for the + fixed keys (`_registerBinaryPixelListeners`); dynamic layer bytes are consumed by + the **geom-JSON change handler**, which now also calls `_spliceBinaryBytes` — the + geom trait always re-pushes when layers change, so a layer's bytes converge into + the cache regardless of trait arrival order. + +### JS compositing (`_drawLayers2d`, called from `draw2d`) + +After the base image (Canvas2D blit OR WebGPU) and the overlay mask, and BEFORE +`_drawAxes2d` / markers / widgets, `_drawLayers2d(p, st, imgW, imgH, ctx, iw, ih)` +draws each **visible** layer bottom-up on `plotCanvas`: + +- `_layerBytes(st, layer)` prefers `layer__b64_bytes` (binary) over the entry + `image_b64` base64; +- `_layerBitmap(p, st, layer)` builds a LUT-colormapped RGBA `OffscreenCanvas`, + **cached per layer id** by `(pixel key, cmap, clim)` — rebuilt only when the + layer's data or appearance changes (a live scrub that only swaps one layer's + data rebuilds just that layer); +- it blits with the SAME fit-rect + zoom/pan transform as the base blit + (`_imgFitRect` + the `zoom>=1` window math) at `ctx.globalAlpha = layer.alpha`, + so zoom/pan track the base exactly. + +Because layers draw on `plotCanvas`, they sit UNDER `markersCanvas` / +`overlayCanvas` (z-order) and are captured by `exportPNG` for free (plotCanvas is +z1 in the composite). Over a WebGPU base the layers still composite in Canvas2D on +`plotCanvas` (which sits above the transparent `gpuCanvas`) — verified by +`test_layers_playwright.py::TestGpuBaseWithLayer`. Per-move perf: only the changed +layer's LUT bitmap is rebuilt (the box-loop is ~one pass over H×W uint8 → uint32, +comparable to the base image's `_buildLut32` blit). + +--- + ## 3D drawing (line ~1840) Orthographic projection; geometry b64-decoded and cached. `draw3d` sorts triangles, draws axes with per-axis `_drawTex` labels (`x/y/z_label_size`). diff --git a/anyplotlib/_electron.py b/anyplotlib/_electron.py index 9f7edb5e..451d4921 100644 --- a/anyplotlib/_electron.py +++ b/anyplotlib/_electron.py @@ -32,9 +32,20 @@ # transport on its _encode_pixels "\x00bin:" token stays in the geom JSON # and the real bytes are never shipped, so the renderer can't decode it and the crisp # zoom tile never displays (you only ever see the downsampled overview base). +# +# Image LAYERS add DYNAMIC pixel keys ``layer__b64`` (one per layer) that also +# ride the geom channel and must ship as binary — they can't be enumerated in a +# fixed frozenset, so ``_is_binary_pixel_key`` matches them by name pattern. _BINARY_KEYS = frozenset({"image_b64", "overlay_mask_b64", "detail_b64"}) +def _is_binary_pixel_key(k: str) -> bool: + """True for a geom key whose value is raw image pixels worth shipping as a + PLOTBIN binary frame — the fixed base-image/mask/detail keys OR a dynamic + per-layer key ``layer__b64``.""" + return k in _BINARY_KEYS or (k.startswith("layer_") and k.endswith("_b64")) + + def _route_change(fig_id: str, name: str, value) -> None: """Forward ONE trait change to the host — as a raw PLOTBIN binary frame for a large image pixel trait (when binary transport is enabled), else as a @@ -61,13 +72,15 @@ def _route_change(fig_id: str, name: str, value) -> None: geom = json.loads(value) except Exception: geom = None - if isinstance(geom, dict) and any(k in geom for k in _BINARY_KEYS): + if isinstance(geom, dict) and any( + _is_binary_pixel_key(k) for k in geom): panel_id = name[len("panel_"):-len("_geom")] fig = _figures.get(fig_id) raw_tbl = getattr(fig, "_raw_pixels", None) sent_binary = False for k in list(geom.keys()): - if k not in _BINARY_KEYS or not isinstance(geom[k], str) or not geom[k]: + if (not _is_binary_pixel_key(k) + or not isinstance(geom[k], str) or not geom[k]): continue raw = None if geom[k].startswith("\x00bin:") and raw_tbl is not None: diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index dfebac16..03b13a62 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -676,8 +676,15 @@ function render({ model, el }) { const tbl = globalThis.__apl_pixbytes; if (!tbl) return false; let spliced = false; - for (const pixelKey of ['image_b64', 'overlay_mask_b64', 'detail_b64']) { - const b = tbl[`${geomTrait}::${pixelKey}`]; + // Fixed base-image / mask / detail keys PLUS every DYNAMIC layer pixel key + // (`layer__b64`) currently present in the side-table for this panel's + // geom trait — layer keys aren't enumerable ahead of time, so scan by the + // `::` prefix. Splices each into the cache as `_bytes`. + const prefix = `${geomTrait}::`; + for (const slot in tbl) { + if (!slot.startsWith(prefix)) continue; + const pixelKey = slot.slice(prefix.length); + const b = tbl[slot]; if (!b) continue; if (b.__aplSeq === undefined) { try { b.__aplSeq = ++_pixArrivalSeq; } catch (_) {} @@ -689,6 +696,25 @@ function render({ model, el }) { return spliced; } + // Register the per-pixel-key binary-bytes change listeners for a panel's geom + // trait. The FIXED base-image / mask / detail keys each get a dedicated + // listener (their slot fires `change:` when new bytes arrive). DYNAMIC + // layer keys (layer__b64) are NOT enumerable here — their bytes are picked + // up by the geom-JSON re-splice (the geom trait always re-pushes when layers + // change) and by _spliceBinaryBytes's prefix scan, so they need no per-slot + // listener. Shared by _createPanelDOM and _createInsetDOM. + function _registerBinaryPixelListeners(id, geomTrait) { + for (const pixelKey of ['image_b64', 'overlay_mask_b64', 'detail_b64']) { + const slot = `${geomTrait}::${pixelKey}`; + model.on(`change:${slot}`, () => { + const p2 = panels.get(id); + if (!p2) return; + _spliceBinaryBytes(p2, geomTrait); + if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } + }); + } + } + // ── layout application ─────────────────────────────────────────────────── function applyLayout() { if (_suppressLayoutUpdate) return; @@ -963,6 +989,11 @@ function render({ model, el }) { const rev = (p2.state && p2.state._geom_rev !== undefined) ? p2.state._geom_rev : ((p2._geomRev || 0) + 1); _loadGeom(p2, model.get(_geomTrait), rev); + // Re-splice any binary pixel bytes present for this panel — including + // DYNAMIC layer keys (layer__b64) that have no dedicated per-slot + // listener. The geom JSON always re-pushes when layers change, so this + // guarantees a layer's bytes are consumed regardless of arrival order. + _spliceBinaryBytes(p2, _geomTrait); if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } }); // Binary transport: raw pixel bytes for this panel arrive on a companion @@ -970,15 +1001,7 @@ function render({ model, el }) { // geomCache under `_bytes` so _imageBytes uses them (no atob), and // redraw. Fires INDEPENDENTLY of the geom JSON trait (which now carries only // the small LUT/flags), so pixels + LUT converge in the cache. - for (const pixelKey of ['image_b64', 'overlay_mask_b64', 'detail_b64']) { - const slot = `${_geomTrait}::${pixelKey}`; - model.on(`change:${slot}`, () => { - const p2 = panels.get(id); - if (!p2) return; - _spliceBinaryBytes(p2, _geomTrait); - if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } - }); - } + _registerBinaryPixelListeners(id, _geomTrait); } model.on(`change:panel_${id}_json`, () => { @@ -1119,6 +1142,8 @@ function render({ model, el }) { const rev = (p2.state && p2.state._geom_rev !== undefined) ? p2.state._geom_rev : ((p2._geomRev || 0) + 1); _loadGeom(p2, model.get(_geomTrait), rev); + // Re-splice binary bytes incl. dynamic layer keys — see _createPanelDOM. + _spliceBinaryBytes(p2, _geomTrait); if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } }); // Binary transport: raw pixel bytes for this panel arrive on a companion @@ -1126,15 +1151,7 @@ function render({ model, el }) { // geomCache under `_bytes` so _imageBytes uses them (no atob), and // redraw. Fires INDEPENDENTLY of the geom JSON trait (which now carries only // the small LUT/flags), so pixels + LUT converge in the cache. - for (const pixelKey of ['image_b64', 'overlay_mask_b64', 'detail_b64']) { - const slot = `${_geomTrait}::${pixelKey}`; - model.on(`change:${slot}`, () => { - const p2 = panels.get(id); - if (!p2) return; - _spliceBinaryBytes(p2, _geomTrait); - if (p2.state) { _applyGeom(p2, p2.state); _redrawPanel(p2); } - }); - } + _registerBinaryPixelListeners(id, _geomTrait); } model.on(`change:panel_${id}_json`, () => { @@ -1528,6 +1545,120 @@ function render({ model, el }) { return Math.max(0, Math.min(1, (now - b.t0) / _DETAIL_BLEND_MS)); } + // ── Image layers (multi-image overlay) ───────────────────────────────────── + // Raw single-channel pixel bytes for ONE layer. Prefers the BINARY bytes + // (`layer__b64_bytes`, spliced into the geomCache under `_bytes`) over + // the base64 string in the layer entry's `image_b64`. Returns {bytes, key} where + // `key` is a cheap identity for the "unchanged → skip rebuild" check. + function _layerBytes(st, layer) { + const pk = `layer_${layer.id}_b64`; + const raw = st[pk + '_bytes']; + if (raw && (raw instanceof Uint8Array || raw.byteLength !== undefined)) { + const u8 = raw instanceof Uint8Array ? raw : new Uint8Array(raw); + let key = layer.image_b64; // the "\x00bin:" content token + if (!key && u8.__aplSeq !== undefined) key = `binseq:${u8.__aplSeq}`; + if (!key) key = `layerbin:${u8.length}`; + return { bytes: u8, key }; + } + const b64 = layer.image_b64 || ''; + if (!b64 || b64.charCodeAt(0) === 0) return { bytes: null, key: '' }; // token, no bytes yet + try { + const bin = atob(b64); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return { bytes: u8, key: b64 }; + } catch (_) { return { bytes: null, key: '' }; } + } + + // Build (and cache on the panel) the LUT-colormapped RGBA OffscreenCanvas for + // one layer. Cached per layer id by (pixel key, cmap, clim) so a live scrub only + // rebuilds when the layer's data/appearance actually changes. Returns null when + // the bytes aren't available or the size is wrong. + function _layerBitmap(p, st, layer) { + const d = _layerBytes(st, layer); + const lw = layer.width || st.image_width; + const lh = layer.height || st.image_height; + if (!d.bytes || !lw || !lh || d.bytes.length < lw * lh) return null; + const cmap = layer.cmap || 'gray'; + const dMin = layer.clim_min, dMax = layer.clim_max; + const lutKey = [cmap, dMin, dMax].join('|'); + const cache = (p._layerBlit ||= {}); + const c = cache[layer.id]; + if (c && c.key === d.key && c.lutKey === lutKey && c.w === lw && c.h === lh) + return c.bitmap; + // Build a 256-entry uint32 LUT from the layer's own colormap + clim. The layer + // bytes are quantised over [clim_min, clim_max] (Python side), so code i maps + // linearly through that window; the LUT bakes clim→colour like the base path. + const cmapData = layer.colormap_data || st.colormap_data || []; + let cmapFlat = null; + if (cmapData.length === 256) { + cmapFlat = new Uint8Array(256 * 4); + for (let i = 0; i < 256; i++) { + cmapFlat[i*4] = cmapData[i][0]; cmapFlat[i*4+1] = cmapData[i][1]; + cmapFlat[i*4+2] = cmapData[i][2]; cmapFlat[i*4+3] = 255; + } + } + const lut = new Uint32Array(256); + const buf = new ArrayBuffer(4); const dv = new DataView(buf); + const u32 = new Uint32Array(buf); + for (let raw = 0; raw < 256; raw++) { + // Bytes are already the normalised index over the layer's clim (Python + // _normalize_image), so idx == raw — identity into the colormap. + const idx = raw; + if (cmapFlat) { + dv.setUint8(0, cmapFlat[idx*4]); dv.setUint8(1, cmapFlat[idx*4+1]); + dv.setUint8(2, cmapFlat[idx*4+2]); dv.setUint8(3, 255); + } else { dv.setUint8(0, idx); dv.setUint8(1, idx); dv.setUint8(2, idx); dv.setUint8(3, 255); } + lut[raw] = u32[0]; + } + const imgData = new ImageData(lw, lh); + const out32 = new Uint32Array(imgData.data.buffer); + for (let i = 0; i < lw * lh; i++) out32[i] = lut[d.bytes[i]]; + const oc = new OffscreenCanvas(lw, lh); + oc.getContext('2d').putImageData(imgData, 0, 0); + cache[layer.id] = { bitmap: oc, key: d.key, lutKey, w: lw, h: lh }; + return oc; + } + + // Composite every VISIBLE layer over the base image on plotCanvas, bottom-up, + // using the SAME zoom/pan/fit-rect transform as the base blit (so zoom/pan track + // exactly) at each layer's own globalAlpha. Layers draw AFTER the base + overlay + // mask and BEFORE axes/markers/widgets, so they sit under annotations + widgets. + // Works identically over a WebGPU base (which lives on the gpuCanvas beneath the + // transparent plotCanvas): the layers just paint onto plotCanvas above it. + function _drawLayers2d(p, st, imgW, imgH, ctx, iw, ih) { + const layers = st.layers; + if (!layers || !layers.length) { if (p._layerBlit) p._layerBlit = {}; return; } + // Drop cache entries for layers that were removed. + if (p._layerBlit) { + const live = new Set(layers.map(l => l.id)); + for (const k in p._layerBlit) if (!live.has(k)) delete p._layerBlit[k]; + } + const { x, y, w, h } = _imgFitRect(iw, ih, imgW, imgH); + const z = st.zoom, cx = st.center_x, cy = st.center_y; + for (const layer of layers) { + if (layer.visible === false) continue; + const bmp = _layerBitmap(p, st, layer); + if (!bmp) continue; + const alpha = layer.alpha != null ? layer.alpha : 1.0; + const bw = bmp.width, bh = bmp.height; + const kx = bw / iw, ky = bh / ih; // logical→layer-texel scale (==1 here) + ctx.save(); + ctx.globalAlpha = alpha; + ctx.imageSmoothingEnabled = false; + if (z >= 1.0) { + const visW = iw / z, visH = ih / z; + const sx = Math.max(0, Math.min(iw - visW, cx * iw - visW / 2)); + const sy = Math.max(0, Math.min(ih - visH, cy * ih - visH / 2)); + ctx.drawImage(bmp, sx * kx, sy * ky, visW * kx, visH * ky, x, y, w, h); + } else { + const dw = w * z, dh = h * z; + ctx.drawImage(bmp, 0, 0, bw, bh, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh); + } + ctx.restore(); + } + } + function _blit2d(p, bitmap, st, pw, ph, ctx, detailBitmap) { const { x, y, w, h } = _imgFitRect(st.image_width, st.image_height, pw, ph); const zoom = st.zoom, cx = st.center_x, cy = st.center_y; @@ -1769,6 +1900,14 @@ function render({ model, el }) { ctx.restore(); } } + // ── Image layers ───────────────────────────────────────────────────────── + // Composited over the base image (+ overlay mask) at each layer's own + // colormap/clim/alpha, using the same zoom/pan transform, UNDER axes/markers/ + // widgets. For a WebGPU base the layers paint on the transparent plotCanvas + // that sits above the gpuCanvas. iw/ih == the logical image size here (tiling + // is forbidden when layers exist, so base_width is 0 and iw == image_width). + _drawLayers2d(p, st, imgW, imgH, ctx, iw, ih); + // Axes / scalebar / colorbar _drawAxes2d(p); drawScaleBar2d(p); diff --git a/anyplotlib/plot2d/__init__.py b/anyplotlib/plot2d/__init__.py index 27f2ee00..f4183b06 100644 --- a/anyplotlib/plot2d/__init__.py +++ b/anyplotlib/plot2d/__init__.py @@ -1,5 +1,6 @@ -"""anyplotlib.plot2d — 2-D image plot panel classes (Plot2D, PlotMesh).""" +"""anyplotlib.plot2d — 2-D image plot panel classes (Plot2D, PlotMesh, Layer).""" from anyplotlib.plot2d._plot2d import Plot2D from anyplotlib.plot2d._plotmesh import PlotMesh +from anyplotlib.plot2d._layer import Layer -__all__ = ["Plot2D", "PlotMesh"] +__all__ = ["Plot2D", "PlotMesh", "Layer"] diff --git a/anyplotlib/plot2d/_layer.py b/anyplotlib/plot2d/_layer.py new file mode 100644 index 00000000..eb248287 --- /dev/null +++ b/anyplotlib/plot2d/_layer.py @@ -0,0 +1,108 @@ +""" +plot2d/_layer.py +================ +Multi-image LAYER handle for :class:`~anyplotlib.plot2d.Plot2D`. + +A ``Layer`` is a second (third, …) scalar image drawn OVER the base image in the +same panel, each with its OWN colormap, clim (display range), and alpha. It is +composited client-side (JS ``draw2d``) with the exact same image→screen +transform as the base image, so zoom/pan track perfectly. This is distinct from +:meth:`Plot2D.set_overlay_mask`, which is a single-colour boolean mask. + +The pixel bytes travel exactly like the base image ``image_b64``: base64 inline +for Jupyter / standalone / ``save_html``, and a raw PLOTBIN frame under the +Electron binary transport (``APL_BINARY_TRANSPORT=1``). Each layer's pixels ride +a DYNAMIC geometry key ``layer__b64`` (see ``Plot2D._GEOM_KEYS`` / +``_electron._route_change`` / ``resolve_pixel_tokens``). + +A ``Layer`` is a thin handle: it owns no state itself. All state lives in the +parent plot's ``_state["layers"]`` list (metadata) plus the top-level +``layer__b64`` pixel key; the handle just forwards mutations to the plot. +""" +from __future__ import annotations + +import itertools + +_layer_counter = itertools.count(1) + + +def _next_layer_id() -> str: + return f"L{next(_layer_counter)}" + + +class Layer: + """Handle for one image layer on a :class:`Plot2D`. + + Do not construct directly — use :meth:`Plot2D.add_layer`. The handle stays + valid until :meth:`remove` (or :meth:`Plot2D.remove_layer`) is called. + + Attributes are read through the parent plot's state so they always reflect + the latest ``set``/``set_data``. + """ + + def __init__(self, plot, layer_id: str) -> None: + self._plot = plot + self._id = layer_id + self._removed = False + + # ── identity ────────────────────────────────────────────────────────────── + @property + def id(self) -> str: + return self._id + + def _entry(self) -> dict: + """Return this layer's metadata dict in the plot state (or raise).""" + if self._removed: + raise ValueError(f"layer {self._id!r} has been removed") + for lyr in self._plot._state.get("layers", []): + if lyr.get("id") == self._id: + return lyr + raise ValueError(f"layer {self._id!r} is no longer attached to its plot") + + # ── read-only views of the current state ────────────────────────────────── + @property + def cmap(self) -> str: + return self._entry().get("cmap", "gray") + + @property + def alpha(self) -> float: + return float(self._entry().get("alpha", 1.0)) + + @property + def visible(self) -> bool: + return bool(self._entry().get("visible", True)) + + @property + def clim(self): + e = self._entry() + return (e.get("clim_min"), e.get("clim_max")) + + # ── mutations (forwarded to the plot) ───────────────────────────────────── + 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. + """ + self._plot._layer_set(self._id, cmap=cmap, alpha=alpha, clim=clim, + visible=visible) + return self + + def set_data(self, frame) -> "Layer": + """Replace this layer's image data (the live path — one push, no full + state rebuild). ``frame`` must match the base image ``(H, W)``.""" + self._plot._layer_set_data(self._id, frame) + return self + + def remove(self) -> None: + """Remove this layer from its plot.""" + if self._removed: + return + self._plot.remove_layer(self) + + def __repr__(self) -> str: + state = "removed" if self._removed else "active" + return f"Layer(id={self._id!r}, {state})" diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index b0f7e399..e4c5d109 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -60,8 +60,34 @@ class Plot2D(_BasePlot, _PanelMixin, _MarkerMixin): #: channel keys are eligible for the PLOTBIN binary route in _route_change — if it #: rode the light view trait instead, its "\x00bin:" token would never be resolved #: to bytes and the crisp zoom tile would never render (only the overview shows). - _GEOM_KEYS = frozenset({"image_b64", "colormap_data", "overlay_mask_b64", - "detail_b64"}) + #: + #: NB ``_GEOM_KEYS`` is a *property* (not a plain frozenset) because image LAYERS + #: add DYNAMIC pixel keys ``layer__b64`` — one per active layer — that must + #: also ride the geom channel (dedup-cached + PLOTBIN-eligible), exactly like the + #: base image. The property returns the fixed base set UNION the current layer + #: pixel keys, so ``Figure._push`` splits every layer's pixels off the light view + #: trait and the binary route recognises them. The consumers only ever iterate the + #: returned set, so a set-typed property is a drop-in for the old frozenset. + _BASE_GEOM_KEYS = frozenset({"image_b64", "colormap_data", "overlay_mask_b64", + "detail_b64"}) + + @property + def _GEOM_KEYS(self) -> frozenset: + layer_keys = self._layer_pixel_keys() + if not layer_keys: + return self._BASE_GEOM_KEYS + return self._BASE_GEOM_KEYS | layer_keys + + @staticmethod + def _layer_pixel_key(layer_id: str) -> str: + """The top-level state / geom key holding one layer's raw pixel bytes.""" + return f"layer_{layer_id}_b64" + + def _layer_pixel_keys(self) -> frozenset: + """The pixel geom keys for every currently-attached layer.""" + return frozenset( + self._layer_pixel_key(lyr["id"]) + for lyr in self._state.get("layers", [])) # Logical image bigger than this (either side) uses the tile backend under # tile="auto": it sends a downsampled OVERVIEW as the base + streams a hi-res @@ -267,6 +293,12 @@ def __init__(self, data, # a re-sampled tile even at the same size/region "overlay_widgets": [], "markers": [], + # Image LAYERS (see add_layer): each entry is a small metadata dict + # {id, cmap, clim_min, clim_max, alpha, visible, width, height, image_b64} + # drawn bottom-up OVER the base image. The heavy pixel bytes for a layer + # ride the DYNAMIC geom key layer__b64 (see _GEOM_KEYS); image_b64 in + # the entry is the base64 / "\x00bin:" TOKEN mirror the JS reads. + "layers": [], "pointer_settled_ms": 0, "pointer_settled_delta": 4, # Transparent mask overlay (set via set_overlay_mask) @@ -303,6 +335,13 @@ def __init__(self, data, # actual render path, not just the requested gpu_mode. self._gpu_active: bool = False + # Image layers (see add_layer). ``_layers`` holds the Layer handles in + # z-order; ``_layer_raw`` caches each layer's display-oriented raw frame + # (keyed by layer id) so a clim change can re-quantise without the caller + # re-supplying data — the layer analogue of ``self._data`` + ``set_clim``. + self._layers: list = [] + self._layer_raw: dict = {} + @property def gpu_active(self) -> bool: """True when this image is being rendered by the WebGPU path (reported by @@ -398,6 +437,12 @@ def enable_tile(self, backend=None, integration_method: str = "mean", (``"mean"|"subsample"|"max"``). ``None`` keeps the current setting (default ``"mean"`` from construction). Use ``"subsample"`` to restore the old fast nearest-neighbour path when a full-frame area-mean is too expensive.""" + if self._state.get("layers"): + raise RuntimeError( + "enable_tile is not supported on a plot with image layers — tile " + "mode streams detail tiles of a single image and cannot composite " + "independent layers. Remove all layers first (remove_layer), or " + "keep the plot untiled.") from anyplotlib.plot2d._tile_backend import as_tile_backend if backend is not None: self._tile_backend = as_tile_backend(backend, origin=self._origin) @@ -688,16 +733,45 @@ def resolve_pixel_tokens(self, d: dict) -> dict: base64 (materialised from the Figure's ``_raw_pixels`` side-table), in place, and return *d*. Used by the COLD paths (save_html / standalone) that have no PLOTBIN channel and need the pixels inline. A no-op for a - normal base64 string. See :meth:`to_state_dict`.""" + normal base64 string. See :meth:`to_state_dict`. + + Also materialises every LAYER's pixels: each layer has a top-level geom + key ``layer__b64`` AND a mirror ``image_b64`` field inside its + ``layers`` entry — both are resolved so a ``save_html`` snapshot of a + layered plot is self-contained.""" import base64 fig = getattr(self, "_fig", None) raw_tbl = getattr(fig, "_raw_pixels", None) - for key in ("image_b64", "overlay_mask_b64"): - val = d.get(key) + + def _resolve(store_key: str, val): if not (isinstance(val, str) and val.startswith("\x00bin:")): - continue - raw = raw_tbl.get((self._id, key)) if raw_tbl is not None else None - d[key] = base64.b64encode(raw).decode("ascii") if raw else "" + return val + raw = raw_tbl.get((self._id, store_key)) if raw_tbl is not None else None + return base64.b64encode(raw).decode("ascii") if raw else "" + + for key in ("image_b64", "overlay_mask_b64"): + if d.get(key) is not None: + d[key] = _resolve(key, d.get(key)) + # Layer pixels: top-level layer__b64 key + the mirror in the entry. + # ``d`` is a SHALLOW copy of ``_state`` (to_state_dict), so ``d["layers"]`` + # shares the entry dicts with the live state. Rebuild the list with COPIED + # entries before rewriting ``image_b64`` so materialising base64 for a cold + # snapshot never mutates a live "\x00bin:" token back into base64 (which + # would corrupt the binary-transport dedup on the next push). + layers = d.get("layers") + if layers: + new_layers = [] + for lyr in layers: + lid = lyr.get("id") + if lid is None: + new_layers.append(lyr) + continue + pk = self._layer_pixel_key(lid) + resolved = _resolve(pk, d.get(pk, lyr.get("image_b64"))) + if pk in d: + d[pk] = resolved + new_layers.append({**lyr, "image_b64": resolved}) + d["layers"] = new_layers return d # ------------------------------------------------------------------ @@ -766,7 +840,15 @@ def set_data(self, data: np.ndarray, # RGB frames don't tile — fall through to the plain path. # Per-call `tile` override wins; else the construction preference decides. eff_pref = self._tile_pref if tile is None else tile - want_tile = (not is_rgb) and ( + # Layers + tiling are mutually exclusive (a layer can't be tiled against + # the same streamed view). A plot with layers ALWAYS takes the plain path: + # honour an explicit tile=True by refusing, and never auto-enable. + _has_layers = bool(self._state.get("layers")) + if _has_layers and tile is True: + raise RuntimeError( + "set_data(tile=True) is not supported on a plot with image layers " + "— remove all layers (remove_layer) before enabling tile mode.") + want_tile = (not is_rgb) and (not _has_layers) and ( eff_pref is True or (eff_pref == "auto" and max(h, w) > self.TILE_THRESHOLD)) # tile=False FORCES the plain path even on an already-tiled plot (the caller is @@ -1010,6 +1092,210 @@ def set_overlay_mask(self, mask: "np.ndarray | None", self._state["overlay_mask_alpha"] = alpha self._push() + # ------------------------------------------------------------------ + # Image layers (multi-image overlay) + # ------------------------------------------------------------------ + @property + def layers(self) -> list: + """The list of :class:`~anyplotlib.plot2d._layer.Layer` handles, in + z-order (index 0 drawn first, above the base image).""" + return list(self._layers) + + def _norm_clim(self, clim): + """Parse a ``(vmin, vmax)`` clim → ``(lo, hi)`` floats, or ``(None, None)`` + for auto. A degenerate (non-increasing) range falls back to auto.""" + if clim is None: + return (None, None) + try: + lo, hi = float(clim[0]), float(clim[1]) + except (TypeError, ValueError, IndexError): + return (None, None) + if not (hi > lo): + return (None, None) + return (lo, hi) + + def _encode_layer_pixels(self, layer_id: str, frame: np.ndarray, clim): + """Quantise a 2-D layer frame → uint8, store the bytes under the layer's + geom key (base64 or PLOTBIN token via _encode_pixels), and return + ``(token, height, width, vmin, vmax)``. + + Requires ``frame.shape == (image_height, image_width)`` — raises + ``ValueError`` otherwise (a layer must line up pixel-for-pixel with the + base image, since it shares the base's image→screen transform).""" + arr = np.asarray(frame) + ih = self._state["image_height"] + iw = self._state["image_width"] + if arr.ndim != 2: + raise ValueError( + f"layer data must be 2-D (H x W), got shape {arr.shape}") + if arr.shape != (ih, iw): + raise ValueError( + f"layer data shape {arr.shape} does not match the base image " + f"({ih} x {iw}); a layer must be the same size as the base image") + # origin='lower' flips the base data for display; flip layers to match so + # they line up with the base pixels. + if self._origin == "lower": + arr = np.flipud(arr) + lo, hi = self._norm_clim(clim) + parsed = (lo, hi) if lo is not None else None + img_u8, vmin, vmax = _normalize_image(arr, clim=parsed) + pk = self._layer_pixel_key(layer_id) + token = self._encode_pixels(pk, np.ascontiguousarray(img_u8)) + # Cache the (display-oriented) raw frame so a later clim change can + # re-quantise without the caller re-supplying data (mirrors self._data + # for the base image + set_clim). + self._layer_raw[layer_id] = arr + return token, int(arr.shape[0]), int(arr.shape[1]), vmin, vmax + + def add_layer(self, data, *, cmap: str = "magma", alpha: float = 0.5, + clim=None, visible: bool = True): + """Add an image LAYER drawn over the base image. + + Each layer has its OWN colormap, display range (``clim``), and opacity + (``alpha``), and is composited on top of the base image (and previously + added layers) with the SAME zoom/pan transform, so it tracks the base + exactly. This is the multi-image overlay primitive; for a single-colour + boolean mask use :meth:`set_overlay_mask` instead. + + Parameters + ---------- + data : ndarray, shape (H, W) + 2-D scalar array, the same size as the base image. Normalised to + uint8 via ``clim`` → LUT exactly like the base image. A shape + mismatch raises ``ValueError``. + cmap : str, optional + Colormap name (default ``"magma"``). + alpha : float, optional + Opacity in [0, 1] (default ``0.5``). + clim : (vmin, vmax) or None, optional + Display range; ``None`` (default) auto-scales to the data min/max. + visible : bool, optional + Whether the layer is drawn (default ``True``). + + Returns + ------- + Layer + A handle with ``.set(...)`` / ``.set_data(frame)`` / ``.remove()``. + + Raises + ------ + RuntimeError + If the plot is in TILE mode — layers are incompatible with tiling + (a layer would have to be tiled independently against the same view). + Load the plot with ``tile=False`` to use layers. + ValueError + If ``data`` is not 2-D or does not match the base image size. + """ + if self._tile_on: + raise RuntimeError( + "add_layer is not supported in tile mode — a tiled plot streams " + "detail tiles of a single image and cannot composite independent " + "layers. Create the plot with tile=False to use layers.") + from anyplotlib.plot2d._layer import Layer, _next_layer_id + alpha = float(alpha) + if not (0.0 <= alpha <= 1.0): + raise ValueError(f"alpha must be in [0, 1], got {alpha!r}") + layer_id = _next_layer_id() + token, h, w, vmin, vmax = self._encode_layer_pixels(layer_id, data, clim) + entry = { + "id": layer_id, + "cmap": cmap, + "clim_min": vmin, + "clim_max": vmax, + "alpha": alpha, + "visible": bool(visible), + "width": w, + "height": h, + # The layer's colormap LUT (256 [r,g,b]); the JS composites via this. + "colormap_data": _build_colormap_lut(cmap), + # Mirror of the pixel token so the JS reads bytes for this layer even + # when it only sees the light `layers` list (the heavy bytes ride the + # geom key layer__b64, spliced into the panel state by the binary / + # geom machinery under the same key). + "image_b64": token, + } + # Also stash the pixels as a TOP-LEVEL geom key so Figure._push splits it + # onto the geom channel (dedup-cached) and the binary route ships it. + self._state[self._layer_pixel_key(layer_id)] = token + self._state.setdefault("layers", []).append(entry) + layer = Layer(self, layer_id) + self._layers.append(layer) + self._push() + return layer + + def _layer_entry(self, layer_id: str) -> dict: + for lyr in self._state.get("layers", []): + if lyr.get("id") == layer_id: + return lyr + raise ValueError(f"no layer {layer_id!r} on this plot") + + def _layer_set(self, layer_id: str, *, cmap=None, alpha=None, clim=None, + visible=None) -> None: + entry = self._layer_entry(layer_id) + if cmap is not None: + entry["cmap"] = cmap + entry["colormap_data"] = _build_colormap_lut(cmap) + if alpha is not None: + a = float(alpha) + if not (0.0 <= a <= 1.0): + raise ValueError(f"alpha must be in [0, 1], got {alpha!r}") + entry["alpha"] = a + if visible is not None: + entry["visible"] = bool(visible) + 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: + parsed = (lo, hi) if lo is not None else 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)) + entry["clim_min"], entry["clim_max"] = vmin, vmax + 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 + self._push() + + def _layer_set_data(self, layer_id: str, frame) -> None: + entry = self._layer_entry(layer_id) + # Re-quantise over the layer's CURRENT clim so a live frame keeps its + # contrast window (None endpoints → auto per-frame, matching the base). + cur_clim = (entry.get("clim_min"), entry.get("clim_max")) + clim = cur_clim if cur_clim[0] is not None else None + token, h, w, vmin, vmax = self._encode_layer_pixels(layer_id, frame, clim) + entry["width"], entry["height"] = w, h + entry["clim_min"], entry["clim_max"] = vmin, vmax + entry["image_b64"] = token + self._state[self._layer_pixel_key(layer_id)] = token + self._push() + + def remove_layer(self, layer) -> None: + """Remove *layer* (a :class:`Layer` handle or its id string).""" + from anyplotlib.plot2d._layer import Layer + layer_id = layer.id if isinstance(layer, Layer) else str(layer) + self._state["layers"] = [ + l for l in self._state.get("layers", []) if l.get("id") != layer_id] + pk = self._layer_pixel_key(layer_id) + self._state.pop(pk, None) + self._layer_raw.pop(layer_id, None) + # Drop the raw-pixel side-table entry so a removed layer's bytes aren't + # retained (and can't be re-shipped). + fig = getattr(self, "_fig", None) + raw_tbl = getattr(fig, "_raw_pixels", None) + if raw_tbl is not None: + raw_tbl.pop((self._id, pk), None) + for h in list(self._layers): + if h.id == layer_id: + h._removed = True + self._layers.remove(h) + self._push() + # ------------------------------------------------------------------ # Display settings # ------------------------------------------------------------------ diff --git a/anyplotlib/tests/test_plot2d/test_layers.py b/anyplotlib/tests/test_plot2d/test_layers.py new file mode 100644 index 00000000..ef426d1e --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_layers.py @@ -0,0 +1,329 @@ +"""Multi-image LAYERS for Plot2D — Python (no browser) contract. + +Covers the state schema after add/set/set_data/remove, z-order, shape +validation, tile-mode guards (both directions), the binary-transport wire route ++ ``resolve_pixel_tokens`` (cold path materialisation), and ``figure_state`` +inclusion. The browser-side compositing (blended pixels, exportPNG capture, +visibility toggle, live set_data) is covered by ``test_layers_playwright.py``. +""" +from __future__ import annotations + +import base64 +import json + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.plot2d import Layer + + +def _imshow(n=32, val=0.0): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((n, n), val, np.float32), cmap="gray", vmin=0, vmax=1, + gpu=False) + return fig, p + + +class TestAddLayerState: + def test_add_layer_returns_handle_and_records_state(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32), cmap="magma", + alpha=0.5, clim=(0, 1)) + assert isinstance(lyr, Layer) + assert p.layers == [lyr] + entries = p._state["layers"] + assert len(entries) == 1 + e = entries[0] + assert e["id"] == lyr.id + assert e["cmap"] == "magma" + assert e["alpha"] == 0.5 + assert e["visible"] is True + assert e["width"] == 32 and e["height"] == 32 + assert (e["clim_min"], e["clim_max"]) == (0.0, 1.0) + # Pixel bytes present: base64 in the entry (non-binary host). + assert e["image_b64"] and not e["image_b64"].startswith("\x00bin:") + # Colormap LUT baked in for the JS compositor. + assert len(e["colormap_data"]) == 256 + + def test_pixel_key_is_a_geom_key(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + pk = p._layer_pixel_key(lyr.id) + # The heavy layer pixels ride a dynamic geom key so Figure._push splits + # them off the light view trait and the binary route ships them. + assert pk in p._GEOM_KEYS + assert pk in p.to_state_dict() + + def test_default_cmap_and_alpha(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + assert lyr.cmap == "magma" + assert lyr.alpha == 0.5 + assert lyr.visible is True + + def test_auto_clim_when_none(self): + _fig, p = _imshow() + data = np.linspace(0, 4, 32 * 32, dtype=np.float32).reshape(32, 32) + lyr = p.add_layer(data, clim=None) + assert lyr.clim == (0.0, 4.0) + + def test_add_layer_alpha_out_of_range_raises(self): + _fig, p = _imshow() + with pytest.raises(ValueError): + p.add_layer(np.ones((32, 32), np.float32), alpha=1.5) + + +class TestZOrder: + def test_layers_kept_in_add_order(self): + _fig, p = _imshow() + a = p.add_layer(np.ones((32, 32), np.float32), cmap="magma") + b = p.add_layer(np.ones((32, 32), np.float32), cmap="viridis") + c = p.add_layer(np.ones((32, 32), np.float32), cmap="plasma") + assert [l.id for l in p.layers] == [a.id, b.id, c.id] + assert [e["id"] for e in p._state["layers"]] == [a.id, b.id, c.id] + + def test_ids_unique(self): + _fig, p = _imshow() + ids = {p.add_layer(np.ones((32, 32), np.float32)).id for _ in range(5)} + assert len(ids) == 5 + + +class TestSet: + def test_partial_update_each_field(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32), cmap="magma", + alpha=0.5, clim=(0, 1)) + lyr.set(cmap="viridis") + assert lyr.cmap == "viridis" + assert p._state["layers"][0]["colormap_data"][0] is not None + lyr.set(alpha=0.9) + assert lyr.alpha == 0.9 + lyr.set(visible=False) + assert lyr.visible is False + # unaffected fields unchanged + assert lyr.cmap == "viridis" and lyr.alpha == 0.9 + + def test_set_clim_requantises(self): + _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=(0, 5)) + assert lyr.clim == (0.0, 5.0) + tok1 = p._state["layers"][0]["image_b64"] + # A value of 5 over [0,10] is code ~127; over [0,5] it's ~255 → the bytes + # (and thus the base64 / token) must change. + assert tok0 != tok1 + + def test_set_alpha_out_of_range_raises(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + with pytest.raises(ValueError): + lyr.set(alpha=-0.1) + + def test_set_returns_self_for_chaining(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + assert lyr.set(alpha=0.3).set(cmap="viridis") is lyr + + +class TestSetData: + def test_set_data_swaps_pixels_one_push(self): + _fig, p = _imshow() + lyr = p.add_layer(np.zeros((32, 32), np.float32), clim=(0, 1)) + tok0 = p._state["layers"][0]["image_b64"] + lyr.set_data(np.ones((32, 32), np.float32)) + tok1 = p._state["layers"][0]["image_b64"] + assert tok0 != tok1 + assert p._state["layers"][0]["width"] == 32 + + def test_set_data_keeps_clim_window(self): + _fig, p = _imshow() + lyr = p.add_layer(np.full((32, 32), 1.0, np.float32), clim=(0, 10)) + assert lyr.clim == (0.0, 10.0) + lyr.set_data(np.full((32, 32), 2.0, np.float32)) + # The layer keeps its clim window across a live frame swap. + assert lyr.clim == (0.0, 10.0) + + def test_set_data_auto_clim_tracks_frame(self): + _fig, p = _imshow() + lyr = p.add_layer(np.zeros((32, 32), np.float32), clim=None) + lyr.set_data(np.linspace(0, 8, 32 * 32, np.float32).reshape(32, 32)) + assert lyr.clim == (0.0, 8.0) + + def test_set_data_shape_mismatch_raises(self): + _fig, p = _imshow() + lyr = p.add_layer(np.zeros((32, 32), np.float32)) + with pytest.raises(ValueError): + lyr.set_data(np.zeros((16, 16), np.float32)) + + +class TestShapeValidation: + def test_wrong_shape_raises(self): + _fig, p = _imshow(n=32) + with pytest.raises(ValueError, match="does not match the base image"): + p.add_layer(np.ones((16, 16), np.float32)) + + def test_non_2d_raises(self): + _fig, p = _imshow(n=32) + with pytest.raises(ValueError): + p.add_layer(np.ones((32, 32, 3), np.float32)) + + +class TestRemove: + def test_remove_via_handle(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + pk = p._layer_pixel_key(lyr.id) + lyr.remove() + assert p.layers == [] + assert p._state["layers"] == [] + assert pk not in p.to_state_dict() + # A removed layer's key drops out of _GEOM_KEYS. + assert pk not in p._GEOM_KEYS + + def test_remove_via_plot(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + p.remove_layer(lyr) + assert p.layers == [] + + def test_remove_only_targeted_layer(self): + _fig, p = _imshow() + a = p.add_layer(np.ones((32, 32), np.float32), cmap="magma") + b = p.add_layer(np.ones((32, 32), np.float32), cmap="viridis") + p.remove_layer(a) + assert [l.id for l in p.layers] == [b.id] + assert p._layer_pixel_key(a.id) not in p.to_state_dict() + assert p._layer_pixel_key(b.id) in p.to_state_dict() + + def test_removed_handle_raises_on_use(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + lyr.remove() + with pytest.raises(ValueError): + lyr.set(alpha=0.5) + + def test_double_remove_is_noop(self): + _fig, p = _imshow() + lyr = p.add_layer(np.ones((32, 32), np.float32)) + lyr.remove() + lyr.remove() # no crash + + +class TestTileGuards: + def test_add_layer_on_tiled_plot_raises(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((2048, 2048), np.float32), vmin=0, vmax=1, gpu=False) + assert p._tile_on + with pytest.raises(RuntimeError, match="tile mode"): + p.add_layer(np.ones((2048, 2048), np.float32)) + + def test_enable_tile_on_layered_plot_raises(self): + _fig, p = _imshow(n=64) + p.add_layer(np.ones((64, 64), np.float32)) + with pytest.raises(RuntimeError, match="image layers"): + 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)) + 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)) + # A large frame that would normally auto-enable tiling must stay plain. + p.set_data(np.zeros((2048, 2048), np.float32)) + assert p._tile_on is False + + +class TestBinaryTransportRoute: + """The layer pixel bytes must travel over BOTH transports: base64-in-JSON and + the Electron PLOTBIN binary path (dynamic layer__b64 keys).""" + + def test_binary_route_ships_layer_frame(self, monkeypatch): + monkeypatch.setenv("APL_BINARY_TRANSPORT", "1") + from anyplotlib import _electron + + fig, ax = apl.subplots(1, 1, figsize=(200, 200)) + p = ax.imshow(np.zeros((16, 16), np.float32), cmap="gray", + vmin=0, vmax=1, gpu=False) + fid = _electron.register(fig) + + captured = {"bin": [], "txt": []} + monkeypatch.setattr(_electron, "emit", + lambda o: captured["txt"].append(o)) + monkeypatch.setattr(_electron, "emit_binary", + lambda f, k, h, pl: captured["bin"].append((k, h, len(pl)))) + + lyr = p.add_layer(np.full((16, 16), 0.7, np.float32), + cmap="magma", clim=(0, 1)) + pk = p._layer_pixel_key(lyr.id) + # In binary mode the layer entry / top-level key hold a bin TOKEN, not b64. + sd = p.to_state_dict() + assert sd[pk].startswith("\x00bin:") + assert (p._id, pk) in fig._raw_pixels + + # Drive the geom trait change the way Figure._push would, carrying the + # slimmed geom with the layer token. + gname = f"panel_{p._id}_geom" + _electron._route_change(fid, gname, json.dumps({pk: sd[pk]})) + keys = [k for (k, _h, _n) in captured["bin"]] + assert pk in keys, f"layer pixel frame not shipped as binary ({keys})" + # The binary frame header names the geom trait so the renderer routes it. + hdr = next(h for (k, h, _n) in captured["bin"] if k == pk) + assert hdr.get("geom") == gname + + def test_resolve_pixel_tokens_materialises_layers(self, monkeypatch): + monkeypatch.setenv("APL_BINARY_TRANSPORT", "1") + from anyplotlib import _electron + + fig, ax = apl.subplots(1, 1, figsize=(200, 200)) + p = ax.imshow(np.zeros((16, 16), np.float32), cmap="gray", + vmin=0, vmax=1, gpu=False) + _electron.register(fig) + lyr = p.add_layer(np.full((16, 16), 0.7, np.float32), clim=(0, 1)) + pk = p._layer_pixel_key(lyr.id) + + d = p.to_state_dict() + assert d[pk].startswith("\x00bin:") # token before resolve + p.resolve_pixel_tokens(d) + # After resolve the top-level key AND the entry mirror hold real base64. + assert not d[pk].startswith("\x00bin:") and len(d[pk]) > 0 + entry = d["layers"][0] + assert not entry["image_b64"].startswith("\x00bin:") + assert entry["image_b64"] == d[pk] + # And it decodes to 16*16 uint8 bytes. + assert len(base64.b64decode(d[pk])) == 16 * 16 + + +class TestFigureStateRoundTrip: + def test_figure_state_includes_layers(self): + from anyplotlib.embed import figure_state + fig, ax = apl.subplots(1, 1, figsize=(200, 200)) + p = ax.imshow(np.zeros((16, 16), np.float32), cmap="gray", + vmin=0, vmax=1, gpu=False) + lyr = p.add_layer(np.full((16, 16), 0.7, np.float32), + cmap="magma", alpha=0.5, clim=(0, 1)) + st = figure_state(fig) + pj = json.loads(st[f"panel_{p._id}_json"]) + assert "layers" in pj and len(pj["layers"]) == 1 + assert pj["layers"][0]["cmap"] == "magma" + # The layer's pixels are inline (base64) somewhere the JS can reach: either + # the geom trait's top-level key or the entry mirror. + gj = json.loads(st[f"panel_{p._id}_geom"]) + pk = p._layer_pixel_key(lyr.id) + assert pk in gj or len(pj["layers"][0]["image_b64"]) > 0 + + def test_save_html_self_contained(self, tmp_path): + fig, ax = apl.subplots(1, 1, figsize=(200, 200)) + p = ax.imshow(np.zeros((16, 16), np.float32), cmap="gray", + vmin=0, vmax=1, gpu=False) + p.add_layer(np.full((16, 16), 0.7, np.float32), clim=(0, 1)) + out = fig.save_html(tmp_path / "layered.html") + html = out.read_text(encoding="utf-8") + # No unresolved binary tokens leak into the standalone HTML. + assert "\x00bin:" not in html diff --git a/anyplotlib/tests/test_plot2d/test_layers_playwright.py b/anyplotlib/tests/test_plot2d/test_layers_playwright.py new file mode 100644 index 00000000..a86310a1 --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_layers_playwright.py @@ -0,0 +1,313 @@ +"""Browser (Playwright) tests for multi-image LAYERS. + +Exercised through the public ``mount()`` entry point exactly as an Electron / +SpyDE app would. We assert on real composited pixels via ``exportPNG`` (which +captures the plotCanvas where layers draw): + +* two constant-value images with distinct colormaps + ``alpha=0.5`` → the blended + colour ``base*(1-a) + layer*a`` (Canvas2D source-over), computed from the LUTs; +* ``visible=False`` removes the layer's contribution; +* ``layer.set_data`` swaps the layer pixels; +* a large image on the WebGPU base path with a Canvas2D layer over it (skips + cleanly without a WebGPU adapter); +* ``exportPNG`` includes the layer contribution. + +Bundled Chromium (the default ``_pw_browser``) has NO WebGPU, so the base image +renders on Canvas2D there; the GPU-base case uses ``_pw_gpu_browser``. +""" +from __future__ import annotations + +import base64 +import json +import pathlib +import tempfile + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.embed import esm_path, figure_state +from anyplotlib.tests._png_utils import decode_png + + +_MOUNT_PAGE = """ + + +
+ +""" + + +def _make_page(browser, 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) + page = 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 + + +@pytest.fixture +def mount_page(_pw_browser): + pages, paths = [], [] + + def _open(fig): + page, tmp = _make_page(_pw_browser, fig) + pages.append(page) + paths.append(tmp) + return page + + yield _open + for p in pages: + try: + p.close() + except Exception: + pass + for f in paths: + f.unlink(missing_ok=True) + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _export(page, opts=None): + return page.evaluate( + """(opts) => window._handle.exportPNG(opts || {}) + .then(r => ({dataUrl: r.dataUrl, width: r.width, height: r.height})) + .catch(e => ({error: String(e && e.message || e)}))""", + opts or {}, + ) + + +def _decode(data_url): + assert data_url.startswith("data:image/png;base64,"), data_url[:40] + return decode_png(base64.b64decode(data_url.split(",", 1)[1])) + + +def _flush(page): + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + + +def _center_rgb(arr): + cy, cx = arr.shape[0] // 2, arr.shape[1] // 2 + return arr[cy, cx, :3].astype(np.int32) + + +def _count_near(arr, rgb, tol=14): + d = np.abs(arr[..., :3].astype(np.int32) - np.asarray(rgb, np.int32)) + return int(((d <= tol).all(axis=-1)).sum()) + + +def _lut_endpoint(cmap, idx=255): + """The [r,g,b] a constant image at the clim top/bottom maps to for *cmap*.""" + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((4, 4), np.float32), cmap=cmap, vmin=0, vmax=1, gpu=False) + return list(p.to_state_dict()["colormap_data"][idx]) + + +# ── tests ──────────────────────────────────────────────────────────────────── + +class TestLayerBlend: + def test_two_layer_alpha_blend_pixels(self, mount_page): + """A constant base at vmax (gray→white) with a constant magma layer at + vmax and alpha=0.5 must composite to base*(1-a) + layer*a at the centre.""" + base_cmap, layer_cmap, alpha = "gray", "magma", 0.5 + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((32, 32), 1.0, np.float32), + cmap=base_cmap, vmin=0.0, vmax=1.0, gpu=False) + p.add_layer(np.full((32, 32), 1.0, np.float32), + cmap=layer_cmap, alpha=alpha, clim=(0, 1)) + + base_hi = np.array(_lut_endpoint(base_cmap, 255), np.float64) # white + layer_hi = np.array(_lut_endpoint(layer_cmap, 255), np.float64) # pale yellow + expected = np.round(base_hi * (1 - alpha) + layer_hi * alpha) + + page = mount_page(fig) + res = _export(page) + assert "error" not in res, res.get("error") + arr = _decode(res["dataUrl"]) + + centre = _center_rgb(arr) + d = int(np.max(np.abs(centre - expected))) + assert d <= 16, ( + f"centre {centre.tolist()} != blend {expected.tolist()} (dmax={d}); " + f"base_hi={base_hi.tolist()} layer_hi={layer_hi.tolist()}" + ) + # The blend colour must NOT equal the bare base (proves the layer drew). + assert int(np.max(np.abs(centre - base_hi))) > 16, ( + "centre equals the bare base — layer contribution missing" + ) + + def test_layer_visible_false_removes_contribution(self, mount_page): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((32, 32), 1.0, np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + lyr = p.add_layer(np.full((32, 32), 1.0, np.float32), + cmap="magma", alpha=0.5, clim=(0, 1)) + page = mount_page(fig) + + with_layer = _center_rgb(_decode(_export(page)["dataUrl"])) + + # Hide the layer → drive the state update the way a live app does → repaint. + lyr.set(visible=False) + _set_panel(page, p) + _flush(page) + without_layer = _center_rgb(_decode(_export(page)["dataUrl"])) + + base_hi = np.array(_lut_endpoint("gray", 255), np.int32) + assert int(np.max(np.abs(without_layer - base_hi))) <= 16, ( + f"hiding the layer did not revert to the base ({without_layer.tolist()})" + ) + assert int(np.max(np.abs(with_layer - without_layer))) > 16, ( + "visible toggle produced no pixel change" + ) + + def test_layer_set_data_swaps_pixels(self, mount_page): + """Swapping the layer's data (low→high value) changes the composited + colour (the magma layer goes from dark to pale).""" + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.zeros((32, 32), np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + lyr = p.add_layer(np.zeros((32, 32), np.float32), + cmap="magma", alpha=0.6, clim=(0, 1)) + page = mount_page(fig) + before = _center_rgb(_decode(_export(page)["dataUrl"])) + + lyr.set_data(np.full((32, 32), 1.0, np.float32)) + _set_panel(page, p) + _flush(page) + after = _center_rgb(_decode(_export(page)["dataUrl"])) + + assert int(np.max(np.abs(after - before))) > 16, ( + f"set_data did not change the composite ({before.tolist()} → {after.tolist()})" + ) + + def test_exportpng_includes_layer(self, mount_page): + """exportPNG must capture the layer (it draws into plotCanvas, so it is + free) — a large fraction of the frame carries the blended colour.""" + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + p = ax.imshow(np.full((32, 32), 1.0, np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + p.add_layer(np.full((32, 32), 1.0, np.float32), + cmap="magma", alpha=0.5, clim=(0, 1)) + base_hi = np.array(_lut_endpoint("gray", 255), np.float64) + layer_hi = np.array(_lut_endpoint("magma", 255), np.float64) + blend = np.round(base_hi * 0.5 + layer_hi * 0.5) + + page = mount_page(fig) + arr = _decode(_export(page)["dataUrl"]) + assert _count_near(arr, blend) > 0.15 * arr.shape[0] * arr.shape[1], ( + "blended colour does not fill the image region in the export" + ) + + +def _set_panel(page, plot): + """Push the plot's CURRENT state into the mounted figure, splitting the geom + channel exactly like ``Figure._push`` does (base64-resolved pixels, since the + mount path has no PLOTBIN channel). Drives a live update as the app would.""" + pid = plot._id + state = plot.to_state_dict() + if hasattr(plot, "resolve_pixel_tokens"): + plot.resolve_pixel_tokens(state) + geom_keys = set(plot._GEOM_KEYS) + geom = {k: state.pop(k) for k in list(geom_keys) if k in state} + state["_geom_rev"] = (state.get("_geom_rev") or 0) + 1 + page.evaluate( + """(args) => { + const [pid, geom, view] = args; + window._handle.set('panel_' + pid + '_geom', JSON.stringify(geom)); + window._handle.setPanelState(pid, view); + }""", + [pid, geom, state], + ) + + +# ── GPU base + Canvas2D layer ──────────────────────────────────────────────── + +GPU_IMG_N = 1200 # > GPU_IMAGE_THRESHOLD (1 Mpx) → WebGPU base in auto mode + + +@pytest.fixture +def gpu_mount_page(_pw_gpu_browser): + pages, paths = [], [] + + def _open(fig, expect_gpu=False): + page, tmp = _make_page(_pw_gpu_browser, fig) + pages.append(page) + paths.append(tmp) + if expect_gpu: + page.wait_for_function( + """() => { + const d = globalThis.__apl_gpu2d || {}; + const v = Object.values(d); + return v.length > 0 && v.every(x => x.active); + }""", + timeout=20_000, + ) + return page + + yield _open + for p in pages: + try: + p.close() + except Exception: + pass + for f in paths: + f.unlink(missing_ok=True) + + +class TestGpuBaseWithLayer: + def test_layer_over_gpu_base(self, gpu_mount_page): + """A large image on the WebGPU base path with a Canvas2D layer over it: + the layer (which composites on plotCanvas above gpuCanvas) must appear in + the export. Skips cleanly without a WebGPU adapter (via _pw_gpu_browser).""" + n = GPU_IMG_N + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + # Base engages the GPU path (large, gpu=True) but tile=False so the WHOLE + # frame goes to the GPU as one texture (layers + tiling are mutually + # exclusive). A constant magma layer composites on Canvas2D over it. + p = ax.imshow(np.full((n, n), 1.0, np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=True, tile=False) + p.add_layer(np.full((n, n), 1.0, np.float32), + cmap="magma", alpha=0.5, clim=(0, 1)) + + base_hi = np.array(_lut_endpoint("gray", 255), np.float64) + layer_hi = np.array(_lut_endpoint("magma", 255), np.float64) + blend = np.round(base_hi * 0.5 + layer_hi * 0.5) + + page = gpu_mount_page(fig, expect_gpu=True) + # Confirm the GPU path is the one painting the base. + diag = page.evaluate("() => globalThis.__apl_gpu2d || {}") + assert diag and all(d.get("active") for d in diag.values()), ( + f"GPU base path not active: {diag}" + ) + + res = _export(page) + assert "error" not in res, res.get("error") + arr = _decode(res["dataUrl"]) + # The blended colour (base⊕layer) must be present — proves the Canvas2D + # layer composited over the WebGPU base and both are captured. + assert _count_near(arr, blend) > 500, ( + f"blend colour {blend.tolist()} missing over GPU base — layer not composited" + ) + # And it must differ from the bare GPU base colour. + assert int(np.max(np.abs(blend - base_hi))) > 16 From 404ad1ef6cfd8d44a4f217d67793b1f8f3a457ae Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 12 Jul 2026 11:41:33 -0500 Subject: [PATCH 2/6] feat(inset): callout connectors + arbitrary inset placement add_inset gains anchor=(x_frac, y_frac) as an alternative to the four corners (inset top-left in figure fraction, clamped inside the figure; minimize/maximize/restore still work). inset.indicate_region(parent_plot, (x, y, w, h)) draws the mark_inset look: a dashed rectangle around the region in the parent's DATA coords plus two leader lines from the rect's facing corners to the inset's nearest corners (deterministic loc1/loc2 pairing). clear_indication() removes it; one indication per inset. Indications live in layout_json (round-trip through figure_state / save_html) and render on a figure-level calloutCanvas above panels and insets: the rect re-maps through the parent transform every draw (tracks zoom/pan, clips to the image area), leaders follow the inset's live DOM rect and hide while minimized, and exportPNG composites them last after a forced fresh draw. The parent may be any 2-D panel in the figure, so cross-panel navigator-to-DP callouts work. 19 tests (13 unit + 6 Playwright visual/tracking). --- anyplotlib/FIGURE_ESM.md | 35 +- anyplotlib/axes/_inset_axes.py | 110 +++- anyplotlib/figure/_figure.py | 35 +- anyplotlib/figure_esm.js | 236 +++++++- .../tests/test_layouts/test_inset_callout.py | 525 ++++++++++++++++++ 5 files changed, 921 insertions(+), 20 deletions(-) create mode 100644 anyplotlib/tests/test_layouts/test_inset_callout.py diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index ee99b848..c63cc8b1 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -136,7 +136,37 @@ 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). #### `_createPanelDOM(id, kind, pw, ph, spec)` (line 763) Builds all canvas/DOM elements for one panel (via `_buildCanvasStack`), @@ -416,7 +446,8 @@ 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, then the figure-level `calloutCanvas` + (region indications) composited LAST. Status bars / stats overlays are excluded. - Ends with `out.toDataURL('image/png')`; rejects the promise with a message on failure (no 2-D context, `toDataURL` throw). diff --git a/anyplotlib/axes/_inset_axes.py b/anyplotlib/axes/_inset_axes.py index 9a0b8523..3da9b6a2 100644 --- a/anyplotlib/axes/_inset_axes.py +++ b/anyplotlib/axes/_inset_axes.py @@ -48,7 +48,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 +67,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 +128,74 @@ 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 (typically the panel the inset overlays). + 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). + 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. + """ + 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)") + x, y, w, h = region + self._indication = { + "parent_id": pid, + "region": [float(x), float(y), float(w), float(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 +211,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/figure/_figure.py b/anyplotlib/figure/_figure.py index 6037c7d4..dfde876a 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -407,6 +407,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 +417,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 +443,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 +486,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.""" diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 03b13a62..f04f1677 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -519,6 +519,19 @@ 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'); + // 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 +793,14 @@ 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 + } } // ── _buildCanvasStack ───────────────────────────────────────────────────── @@ -1201,9 +1222,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 +1234,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,6 +1333,147 @@ 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 || []; + + // 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); + if (!inds.length) return; + + 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(); + } } function _resizePanelDOM(id, pw, ph) { @@ -7025,10 +7238,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 ──────────────────────────────────────────────────────────── @@ -7128,6 +7347,12 @@ fn fs(in : VsOut) -> @location(0) vec4 { for (const p of panels.values()) if (!p.isInset) _drawPanel(p); for (const p of panels.values()) if (p.isInset) _drawPanel(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); + let dataUrl; try { dataUrl = out.toDataURL('image/png'); @@ -7235,6 +7460,8 @@ 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) _gpuDisposeImagePanel, _gpuDisposePanel, }; @@ -7335,6 +7562,7 @@ export function mount(el, state, opts) { const api = render({ model, el }) || {}; 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/tests/test_layouts/test_inset_callout.py b/anyplotlib/tests/test_layouts/test_inset_callout.py new file mode 100644 index 00000000..f537b05b --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_inset_callout.py @@ -0,0 +1,525 @@ +""" +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_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}" + ) + + +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}" + ) From 19f692512b4636e6933f9761b34cdc4ab4803d1f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 13 Jul 2026 06:31:06 -0500 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20review=20findings=20=E2=80=94=20expo?= =?UTF-8?q?rt=20fidelity,=20layer=20guards,=20callout=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exportPNG now draws inset title bars (they are DOM divs, previously dropped from every export) and snaps element edges to the output pixel grid (rounded left/right edges, not rounded widths) so fractional DPR no longer produces 1px seams between grid panels. Layers: a shape-changing set_data on a layered plot raises a clear ValueError instead of silently stretching stale-sized layers over the new fit rect (mirrors the tile-mode guards); Layer.set accepts clim='auto' to return to data min/max after an explicit clim (None still means unchanged — the docstring now matches the behavior). indicate_region validates its parent belongs to the same figure and that the region is four finite numbers with positive extent; out-of-bounds regions remain allowed (clipping is by design). FIGURE_ESM.md documents all of the above plus a maintainer note that layout indications are rebuilt from _insets_map on every push (no stale-indication path exists until a remove_inset API is added). --- anyplotlib/FIGURE_ESM.md | 80 +++++++- anyplotlib/axes/_inset_axes.py | 39 +++- anyplotlib/figure_esm.js | 48 ++++- anyplotlib/plot2d/_layer.py | 23 ++- anyplotlib/plot2d/_plot2d.py | 51 +++++- .../tests/test_embed/test_export_png.py | 173 +++++++++++++++++- .../tests/test_layouts/test_inset_callout.py | 67 +++++++ anyplotlib/tests/test_plot2d/test_layers.py | 108 ++++++++++- 8 files changed, 563 insertions(+), 26 deletions(-) diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index c63cc8b1..e4c06be6 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -167,6 +167,35 @@ zoom/pan), at the end of `_applyAllInsetStates` (inset moved), on `applyLayout` (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`), @@ -238,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 @@ -446,8 +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, then the figure-level `calloutCanvas` - (region indications) composited LAST. 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/axes/_inset_axes.py b/anyplotlib/axes/_inset_axes.py index 3da9b6a2..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 @@ -150,12 +151,19 @@ def indicate_region(self, parent_plot, region, *, ---------- parent_plot : Plot2D The parent image plot the region lives on. Must be a 2-D image - panel in the same figure (typically the panel the inset overlays). + 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"``. @@ -168,15 +176,40 @@ def indicate_region(self, parent_plot, region, *, ------- 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)") - x, y, w, h = region + 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": [float(x), float(y), float(w), float(h)], + "region": [x, y, w, h], "color": color, "linestyle": linestyle, "linewidth": float(linewidth), diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index f04f1677..8ccd4e84 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -7314,6 +7314,14 @@ 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. + // + // 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). const _drawEl = (elm) => { if (!elm) return; const cs = window.getComputedStyle(elm); @@ -7322,10 +7330,15 @@ fn fs(in : VsOut) -> @location(0) vec4 { if (elm.width === 0 || elm.height === 0) return; const r = elm.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 (_) {} }; @@ -7343,9 +7356,34 @@ fn fs(in : VsOut) -> @location(0) vec4 { // (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. 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..c2a859d4 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -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: diff --git a/anyplotlib/tests/test_embed/test_export_png.py b/anyplotlib/tests/test_embed/test_export_png.py index 0a103dfc..e541ac17 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) @@ -219,6 +222,90 @@ def test_widgets_excluded_by_default(self, mount_page): ) +# --------------------------------------------------------------------------- +# 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 +344,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_layouts/test_inset_callout.py b/anyplotlib/tests/test_layouts/test_inset_callout.py index f537b05b..0baf5695 100644 --- a/anyplotlib/tests/test_layouts/test_inset_callout.py +++ b/anyplotlib/tests/test_layouts/test_inset_callout.py @@ -187,6 +187,73 @@ class _NoId: 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)) 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 From 503da7f07015f4dd57d31e0354d0917b601961bf Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 13 Jul 2026 10:08:48 -0500 Subject: [PATCH 4/6] feat(figure): edit chrome, figure-level markers, ArrowWidget + show_handles - ArrowWidget: draggable arrow in image-px coords (body move, head re-aim), add_arrow_widget + add_widget('arrow', ...) dispatch - show_handles option on every 2-D widget (hides handle dots; drag unchanged) - Figure edit_chrome / selected_panel traits: JS-local dashed hover outline per panel + persistent solid selection outline - figure-background pointer_down (edit mode, clicks outside all panels) via a new Figure-level CallbackRegistry - figure_markers_json: figure-fraction annotation layer (text/circle/rect/ arrow) drawn over all panels, draggable under edit_chrome with pointer_up write-back; always composited into exportPNG while edit chrome/handles never export (edit-mode export is pixel-identical to non-edit) --- anyplotlib/figure/_figure.py | 138 ++++++- anyplotlib/figure_esm.js | 355 +++++++++++++++++- anyplotlib/plot2d/_plot2d.py | 61 ++- .../tests/test_embed/test_export_png.py | 76 ++++ .../test_interactive/test_edit_chrome.py | 339 +++++++++++++++++ .../test_edit_chrome_playwright.py | 274 ++++++++++++++ anyplotlib/widgets/__init__.py | 4 +- anyplotlib/widgets/_widgets2d.py | 81 +++- ...+arrow-widget-show-handles.new_feature.rst | 4 + .../+figure-edit-chrome.new_feature.rst | 6 + 10 files changed, 1300 insertions(+), 38 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/test_edit_chrome.py create mode 100644 anyplotlib/tests/test_interactive/test_edit_chrome_playwright.py create mode 100644 upcoming_changes/+arrow-widget-show-handles.new_feature.rst create mode 100644 upcoming_changes/+figure-edit-chrome.new_feature.rst diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index dfde876a..2e0f0cf7 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] @@ -538,6 +564,20 @@ 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 + # Inset state changes handled before regular plot dispatch if event_type == "inset_state_change": inset_ax = self._insets_map.get(panel_id) @@ -611,6 +651,100 @@ 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 and figure-marker pointer events — + 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. + """ + 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"), + ) + 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 8ccd4e84..fab0e85b 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -532,6 +532,19 @@ function render({ model, el }) { 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 @@ -801,6 +814,10 @@ function render({ model, el }) { } 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 ───────────────────────────────────────────────────── @@ -1476,6 +1493,257 @@ function render({ model, el }) { } } + // ═══════════════════════════════════════════════════════════════════════════ + // 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(); + 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); + if (!_figMarkers.length) return; + 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(); + } + + // ── 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; + // Selection outline (persistent, solid) wins over hover. + if (edit && p.id === sel) { + cell.style.outline = `2px solid ${EDIT_ACCENT}`; + cell.style.outlineOffset = '-1px'; + } 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 = '-1px'; + } + }); + 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; + 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; @@ -2419,28 +2687,35 @@ 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). + // The body + hit-testing / drag are unaffected — this is purely cosmetic. + const _handles = 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) _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){ @@ -2449,13 +2724,27 @@ function render({ model, el }) { ovCtx.moveTo(px0,py0); for(let k=1;k @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 + if (Math.hypot(mx-hx, my-hy) <= HR) + return { idx:i, mode:'resize_head', snapW:{...w}, startMX:mx, startMY:my }; + // tail handle → move whole arrow + if (Math.hypot(mx-tx, my-ty) <= HR) + return { idx:i, mode:'move', 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)); @@ -6258,6 +6570,13 @@ 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 + w.u = imgMX - s.x; w.v = imgMY - s.y; + } } drawOverlay2d(p); @@ -6543,6 +6862,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { _resizePanelDOM(p.id, p.pw, p.ph); _redrawPanel(p); } + _drawFigureMarkers([nfw, nfh]); } document.addEventListener('mousemove', (e) => { @@ -6559,6 +6879,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(); @@ -7391,6 +7713,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { 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 { dataUrl = out.toDataURL('image/png'); @@ -7488,8 +7818,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 @@ -7500,6 +7837,8 @@ fn fs(in : VsOut) -> @location(0) vec4 { 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, }; diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index c2a859d4..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 @@ -1543,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, @@ -1552,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: @@ -1559,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() @@ -1574,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, @@ -1582,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() @@ -1590,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, @@ -1598,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() @@ -1631,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 e541ac17..daf4f10e 100644 --- a/anyplotlib/tests/test_embed/test_export_png.py +++ b/anyplotlib/tests/test_embed/test_export_png.py @@ -222,6 +222,82 @@ def test_widgets_excluded_by_default(self, mount_page): ) +# --------------------------------------------------------------------------- +# 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) # --------------------------------------------------------------------------- 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..f337546b --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_edit_chrome.py @@ -0,0 +1,339 @@ +""" +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 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..33766d37 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_edit_chrome_playwright.py @@ -0,0 +1,274 @@ +""" +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_body_drag_emits_pointer_up_with_fields(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 tail handle (6,6) and drag the whole arrow. + tx, ty = self._img_to_page(page, 6, 6) + page.mouse.move(tx, ty) + page.mouse.down() + page.mouse.move(tx + 30, ty + 20, steps=8) + page.mouse.up() + page.wait_for_timeout(80) + + ups = _get_events(page, "pointer_up") + assert ups, "arrow 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_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) 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/+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``. From b9038a5487525205ade2c52581a1b96d65afd57e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 13 Jul 2026 12:54:09 -0500 Subject: [PATCH 5/6] feat(figure): resize nodes, arrow reshape, panel drag-swap, export + push fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - circle gains a visible center node (radius node existed); arrow tail handle becomes a reshape node (tail moves, head anchored: x,y+=d, u,v-=d); shaft still moves the whole arrow; endpoint cursors - edit-chrome hover/selection outlines drawn fully inset (outline-offset = -width) so edge panels are no longer clipped right/bottom - panel drag-swap: per-panel move grip under edit_chrome; dropping on another panel fires figure-level callbacks with source/target panel ids (host swaps and rebuilds); source/empty drops cancel cleanly - mount() gains an onResize hook (rAF-debounced ResizeObserver) so hosts can relayout on container-only resizes - exportPNG(includeWidgets) redraws panel overlay widgets handle-free on a scratch canvas (mirrors the figure-marker export path) — visible resize nodes never bake into harvested PNGs; live canvas untouched - standalone page makeModel(): save_changes() after a parent awi_state push now fires ONLY the listeners of keys actually set (per-key dirty set) — a targeted event_json widget update was being clobbered by the stale panel__json listener re-applying old panel state in the same cascade (Python-pushed widget.set(color/geometry) never repainted in Electron) --- anyplotlib/_repr_utils.py | 40 +- anyplotlib/callbacks.py | 4 + anyplotlib/figure/_figure.py | 19 +- anyplotlib/figure_esm.js | 241 +++++++- .../tests/test_embed/test_export_png.py | 109 ++++ .../test_interactive/test_edit_chrome.py | 96 +++ .../test_edit_chrome_playwright.py | 580 +++++++++++++++++- .../+edit-mode-resize-swap.new_feature.rst | 22 + 8 files changed, 1072 insertions(+), 39 deletions(-) create mode 100644 upcoming_changes/+edit-mode-resize-swap.new_feature.rst 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/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 2e0f0cf7..52741da9 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -578,6 +578,14 @@ def _dispatch_event(self, raw: str) -> None: 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) @@ -655,10 +663,11 @@ def _dispatch_event(self, raw: str) -> None: 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 and figure-marker pointer events — - 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. + 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, @@ -672,6 +681,8 @@ def _fire_figure_event(self, event_type: str, msg: dict) -> None: 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) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index fab0e85b..725dcbc1 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) ───────────── @@ -1649,6 +1649,106 @@ function render({ model, el }) { 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. @@ -1657,10 +1757,18 @@ function render({ model, el }) { 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 = '-1px'; + cell.style.outlineOffset = '-2px'; } else if (!p._editHover || !edit) { cell.style.outline = ''; cell.style.outlineOffset = ''; @@ -1672,7 +1780,7 @@ function render({ model, el }) { p._editHover = true; if (_editOn() && p.id !== (model.get('selected_panel') || '')) { cell.style.outline = `2px dashed ${EDIT_ACCENT}`; - cell.style.outlineOffset = '-1px'; + cell.style.outlineOffset = '-2px'; } }); cell.addEventListener('mouseleave', () => { @@ -1722,6 +1830,12 @@ function render({ model, el }) { // 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. @@ -2675,10 +2789,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); @@ -2687,14 +2810,18 @@ 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). - // The body + hit-testing / drag are unaffected — this is purely cosmetic. - const _handles = w.show_handles !== false; + // 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(); - if(_handles) _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(); @@ -6060,6 +6187,7 @@ fn fs(in : VsOut) -> @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){ @@ -6470,12 +6598,12 @@ fn fs(in : VsOut) -> @location(0) vec4 { } 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 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 → move whole arrow + // tail handle → reshape (tail moves, HEAD stays anchored) if (Math.hypot(mx-tx, my-ty) <= HR) - return { idx:i, mode:'move', snapW:{...w}, startMX:mx, startMY:my }; + 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 }; @@ -6574,8 +6702,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { 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 + // 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; } } @@ -7644,13 +7778,18 @@ fn fs(in : VsOut) -> @location(0) vec4 { // 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). - const _drawEl = (elm) => { + // `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 left = (r.left - rootRect.left) * outScale; const top = (r.top - rootRect.top) * outScale; @@ -7664,6 +7803,29 @@ fn fs(in : VsOut) -> @location(0) vec4 { 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) @@ -7671,7 +7833,10 @@ 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 @@ -7768,13 +7933,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); } @@ -7920,6 +8104,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 (_) {} @@ -7935,8 +8122,10 @@ 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, …) diff --git a/anyplotlib/tests/test_embed/test_export_png.py b/anyplotlib/tests/test_embed/test_export_png.py index daf4f10e..28d59fad 100644 --- a/anyplotlib/tests/test_embed/test_export_png.py +++ b/anyplotlib/tests/test_embed/test_export_png.py @@ -221,6 +221,115 @@ 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 diff --git a/anyplotlib/tests/test_interactive/test_edit_chrome.py b/anyplotlib/tests/test_interactive/test_edit_chrome.py index f337546b..dcd8326e 100644 --- a/anyplotlib/tests/test_interactive/test_edit_chrome.py +++ b/anyplotlib/tests/test_interactive/test_edit_chrome.py @@ -337,3 +337,99 @@ def test_rect_marker_size_fields_update(self): 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 index 33766d37..d8ffbc91 100644 --- a/anyplotlib/tests/test_interactive/test_edit_chrome_playwright.py +++ b/anyplotlib/tests/test_interactive/test_edit_chrome_playwright.py @@ -80,23 +80,24 @@ def _img_to_page(self, page, ix, iy, iw=32, ih=32): oy = (r["h"] - ih * s) / 2.0 return r["left"] + ox + ix * s, r["top"] + oy + iy * s - def test_arrow_body_drag_emits_pointer_up_with_fields(self, interact_page): + 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 tail handle (6,6) and drag the whole arrow. - tx, ty = self._img_to_page(page, 6, 6) - page.mouse.move(tx, ty) + # 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(tx + 30, ty + 20, steps=8) + 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 drag should emit a 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. @@ -104,6 +105,30 @@ def test_arrow_body_drag_emits_pointer_up_with_fields(self, interact_page): 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)) @@ -272,3 +297,546 @@ def test_marker_not_draggable_when_edit_off(self, interact_page): 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/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. From c3f4d9bef55cad00ac235dccea042c9d977954f1 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 13 Jul 2026 13:35:32 -0500 Subject: [PATCH 6/6] fix(figure): empty overlay canvases must not shadow panel content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The figure-level callout overlay (calloutCanvas, added in the inset-callout work) and the figure-marker overlay (figMarkerCanvas) sized their backing store to the FULL figure even when there was nothing to draw — both draw functions set width/height to fig_width/fig_height and only THEN returned early on an empty indication/marker list. An empty full-figure transparent canvas spans the whole figure, so it is wider than any single panel and becomes the LARGEST in the DOM. Any consumer/test that samples "the largest canvas" then reads the transparent overlay instead of the panel content — which is exactly why the tiled-image render tests (test_detail_tile / test_tiled_imshow, "0 pixels rendered") regressed: the panel drew its overview/detail tile correctly, but the probe sampled the empty overlay on top. Fix: when there is nothing to draw, collapse the overlay to a 0x0 backing store (which also clears any prior drawing) and return, instead of sizing it to the full figure. The overlay grows back to full-figure size the moment an indication / marker exists (covered by the existing TestCalloutRendering and edit-chrome marker suites). Add a regression test pinning that empty overlays stay 0x0 and never shadow panel content. --- anyplotlib/figure_esm.js | 28 +++++++++++- .../tests/test_layouts/test_inset_callout.py | 44 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 725dcbc1..65101abd 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1370,6 +1370,20 @@ function render({ model, el, onResize }) { 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. @@ -1386,7 +1400,6 @@ function render({ model, el, onResize }) { } calloutCtx.setTransform(dpr, 0, 0, dpr, 0, 0); calloutCtx.clearRect(0, 0, fw, fh); - if (!inds.length) return; const base = calloutCanvas.getBoundingClientRect(); @@ -1524,6 +1537,18 @@ function render({ model, el, onResize }) { // 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'; @@ -1533,7 +1558,6 @@ function render({ model, el, onResize }) { } figMarkerCtx.setTransform(dpr, 0, 0, dpr, 0, 0); figMarkerCtx.clearRect(0, 0, fw, fh); - if (!_figMarkers.length) return; const rmin = Math.min(fw, fh); const edit = _editOn() && !forceNoHandles; diff --git a/anyplotlib/tests/test_layouts/test_inset_callout.py b/anyplotlib/tests/test_layouts/test_inset_callout.py index 0baf5695..f2b382f5 100644 --- a/anyplotlib/tests/test_layouts/test_inset_callout.py +++ b/anyplotlib/tests/test_layouts/test_inset_callout.py @@ -553,6 +553,50 @@ def test_rect_tracks_parent_zoom(self, mount_page): 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):