From 145e0eeb441701e90dcb304370d9fb6e37e1cf37 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 4 Sep 2026 07:33:17 -0500 Subject: [PATCH] feat(plot2d): exact pixel value in the 2-D hover readout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hover readout named the physical and pixel coordinates but not the value, which is usually the number a viewer is after. It now shows `v:` for a colourmapped image and `rgb:r,g,b` for a true-colour one. The value is exact rather than inferred from the transferred byte wherever it can be. Integer data whose range fits inside the 256 quantised codes is inverted locally; anything wider is answered by Python once the cursor has dwelled on a pixel (`probe_exact=True` by default, tunable through `Plot2D.set_value_probe`), and the quantised estimate is the fallback when no kernel can answer — which is what a `save_html` page always shows. Zoomed in past a detail tile the value is read from the tile's native pixels rather than the coarser overview underneath, so the readout always names the source actually on screen. The `v` key toggles the on-image pill. `Plot2D.set_readout_visible` hides it from Python while keeping the readout live, so an embedding host can render position and value in its own chrome instead: every update arrives through `mount()`'s `opts.onReadout` and as an `apl:readout` DOM event. 2-D pointer events also carry `img_x`/`img_y` now. --- Examples/PlotTypes/plot_aspect_ratio.py | 77 ++ Examples/PlotTypes/plot_subplots_adjust.py | 90 +++ anyplotlib/FIGURE_ESM.md | 37 +- anyplotlib/_utils.py | 14 + anyplotlib/axes/_axes.py | 10 +- anyplotlib/callbacks.py | 4 + anyplotlib/figure/_figure.py | 12 + anyplotlib/figure_esm.js | 311 +++++++- anyplotlib/plot2d/_plot2d.py | 154 +++- anyplotlib/plot2d/_plotmesh.py | 12 +- .../test_plot2d/test_status_pixel_value.py | 730 ++++++++++++++++++ docs/embedding.rst | 54 ++ docs/events.rst | 88 +++ .../+status-pixel-value.new_feature.rst | 16 + uv.lock | 2 +- 15 files changed, 1583 insertions(+), 28 deletions(-) create mode 100644 Examples/PlotTypes/plot_aspect_ratio.py create mode 100644 Examples/PlotTypes/plot_subplots_adjust.py create mode 100644 anyplotlib/tests/test_plot2d/test_status_pixel_value.py create mode 100644 upcoming_changes/+status-pixel-value.new_feature.rst diff --git a/Examples/PlotTypes/plot_aspect_ratio.py b/Examples/PlotTypes/plot_aspect_ratio.py new file mode 100644 index 000000000..044d9e319 --- /dev/null +++ b/Examples/PlotTypes/plot_aspect_ratio.py @@ -0,0 +1,77 @@ +""" +Aspect Ratio Control +==================== + +:meth:`~anyplotlib.Plot2D.set_aspect` constrains the displayed image canvas so +that one unit on the x-axis occupies the same number of pixels as one unit on +the y-axis. + +Three modes are shown: + +1. **Default** — the image fills the panel exactly (no constraint). +2. ``set_aspect("equal")`` — forces a 1 : 1 pixel-per-unit ratio. A square + array is displayed as a square image regardless of panel dimensions. +3. ``set_aspect(2.0)`` — makes the canvas twice as wide as it is tall. + +The scale bar, axis ticks, and colorbar all update automatically when the +canvas is resized by ``set_aspect``. +""" +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(0) + +# ── Synthetic calibrated image ──────────────────────────────────────────────── +N = 64 +x = np.linspace(0, 10, N) # nm +y = np.linspace(0, 10, N) +XX, YY = np.meshgrid(x, y) +data = (np.sin(XX) * np.cos(YY) + 0.2 * rng.standard_normal((N, N))).astype(np.float32) + +# ── 1. Default (no aspect constraint) ──────────────────────────────────────── +# %% +# Default — no aspect constraint +# -------------------------------- +# The image stretches to fill the full panel area. + +fig1, ax1 = apl.subplots(1, 1, figsize=(420, 340)) +plot1 = ax1.imshow(data, axes=[x, y], units="nm", cmap="viridis") +plot1.set_title("Default (no constraint)") +plot1.set_xlabel("x (nm)") +plot1.set_ylabel("y (nm)") + +fig1 # Interactive + +# ── 2. Equal aspect ratio ───────────────────────────────────────────────────── +# %% +# Equal aspect ratio +# ------------------ +# ``set_aspect("equal")`` (or equivalently ``set_aspect(1.0)``) ensures that +# one nm on the x-axis occupies the same number of canvas pixels as one nm on +# the y-axis. The canvas height adjusts to be equal to the canvas width. + +fig2, ax2 = apl.subplots(1, 1, figsize=(420, 340)) +plot2 = ax2.imshow(data, axes=[x, y], units="nm", cmap="viridis") +plot2.set_aspect("equal") +plot2.set_title("set_aspect('equal')") +plot2.set_xlabel("x (nm)") +plot2.set_ylabel("y (nm)") + +fig2 # Interactive + +# ── 3. Explicit ratio ───────────────────────────────────────────────────────── +# %% +# Explicit ratio (2 : 1) +# ----------------------- +# ``set_aspect(2.0)`` makes the canvas twice as wide as it is tall. Useful +# when the physical x and y scales differ (e.g. a time-series scan where one +# axis is faster than the other). + +fig3, ax3 = apl.subplots(1, 1, figsize=(420, 340)) +plot3 = ax3.imshow(data, axes=[x, y], units="nm", cmap="inferno") +plot3.set_aspect(2.0) +plot3.set_title("set_aspect(2.0)") +plot3.set_xlabel("x (nm)") +plot3.set_ylabel("y (nm)") + +fig3 # Interactive diff --git a/Examples/PlotTypes/plot_subplots_adjust.py b/Examples/PlotTypes/plot_subplots_adjust.py new file mode 100644 index 000000000..ae21c2961 --- /dev/null +++ b/Examples/PlotTypes/plot_subplots_adjust.py @@ -0,0 +1,90 @@ +""" +Subplot Spacing with subplots_adjust +===================================== + +:meth:`~anyplotlib.Figure.subplots_adjust` controls the gap between panels in +a multi-panel figure. + +* **hspace** — vertical gap as a fraction of the mean row height. + ``hspace=0.15`` adds 15 % of the average row height as space between rows. +* **wspace** — horizontal gap as a fraction of the mean column width. + ``wspace=0.10`` adds 10 % of the average column width as space between + columns. + +Both values default to ``0.0`` (panels are flush with no gap). + +The examples below compare default flush spacing against adjusted spacing on +the same 2 × 2 grid. +""" +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(42) +t = np.linspace(0, 2 * np.pi, 512) + +COLORS = ["#4fc3f7", "#ff7043", "#aed581", "#ffd54f"] +LABELS = ["α", "β", "γ", "δ"] +SIGNALS = [ + np.sin(t * (i + 1)) + rng.normal(scale=0.08, size=len(t)) + for i in range(4) +] + + +def _fill_grid(fig): + """Add four 1-D spectra to a 2×2 Figure.""" + import anyplotlib as _apl + gs = _apl.GridSpec(2, 2) + positions = [(0, 0), (0, 1), (1, 0), (1, 1)] + for idx, (r, c) in enumerate(positions): + ax = fig.add_subplot(gs[r, c]) + ax.plot(SIGNALS[idx], color=COLORS[idx], label=LABELS[idx]) + + +# ── 1. Default spacing (flush) ──────────────────────────────────────────────── +# %% +# Default spacing — panels flush +# -------------------------------- +# Without calling ``subplots_adjust`` the panels abut each other with no gap. + +fig1 = apl.Figure(2, 2, figsize=(680, 480)) +_fill_grid(fig1) + +fig1 # Interactive + +# ── 2. Vertical gap only (hspace) ──────────────────────────────────────────── +# %% +# Vertical gap only — hspace=0.15 +# --------------------------------- +# ``subplots_adjust(hspace=0.15)`` inserts a vertical gap equal to 15 % of +# the mean row height between the two rows. + +fig2 = apl.Figure(2, 2, figsize=(680, 480)) +_fill_grid(fig2) +fig2.subplots_adjust(hspace=0.15) + +fig2 # Interactive + +# ── 3. Horizontal gap only (wspace) ────────────────────────────────────────── +# %% +# Horizontal gap only — wspace=0.10 +# ----------------------------------- +# ``subplots_adjust(wspace=0.10)`` inserts a horizontal gap equal to 10 % of +# the mean column width between the two columns. + +fig3 = apl.Figure(2, 2, figsize=(680, 480)) +_fill_grid(fig3) +fig3.subplots_adjust(wspace=0.10) + +fig3 # Interactive + +# ── 4. Both gaps ───────────────────────────────────────────────────────────── +# %% +# Both hspace and wspace +# ----------------------- +# Combine both arguments to add space in both directions. + +fig4 = apl.Figure(2, 2, figsize=(680, 480)) +_fill_grid(fig4) +fig4.subplots_adjust(hspace=0.15, wspace=0.10) + +fig4 # Interactive diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index 492b551e6..5cfed3192 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -66,6 +66,8 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | `drawOverlay2d` / `drawMarkers2d` | 3395 / 3559 | | **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2533 / 2557 / 2618 | | Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 | +| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 3942 / 4024 | +| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4064 / 4079 / 4100 | | **3D drawing**: `draw3d` | 5298 | | Event emission `_emitEvent` | 6135 | | 3D event handlers `_attachEvents3d` | 6187 | @@ -259,7 +261,9 @@ Key state fields: ``` st.image_b64, st.image_width/height st.zoom, st.center_x/y -st.display_min/max, st.raw_min/max, st.scale_mode +st.display_min/max, st.raw_min/max, st.raw_is_int, st.scale_mode +st.detail_b64, st.detail_region/width/height/seq, st.detail_min/max/is_int +st.readout_visible, st.probe_ms, st.probe_x/probe_y/probe_value st.colormap_data [[r,g,b], ...] × 256 st.x_axis, st.y_axis, st.axis_visible st.markers, st.overlay_widgets, st.overlay_mask_b64/_color/_alpha @@ -281,6 +285,37 @@ Zoom model: at `zoom=1` the whole image fills the fit-rect; at `zoom=Z>1` a `1/Z` region fills it. `_imgToCanvas2d` / `_canvasToImg2d` must stay exact inverses of the blit geometry. +### Hover readout + +Visibility: `st.readout_visible` (Python, authoritative) AND `p.readoutHidden` (the +viewer's `v` key — kept off the state so a per-frame push can't undo it). + +`_updateStatus2d(p)` composes it from `p.mouseX/mouseY` and is called from BOTH the +`mousemove` handler in `_attachEvents2d` and the `change:panel__json` observer — +the latter because a probe answer landing under a stationary cursor has no mousemove +to piggyback on. It writes `p.statusBar` (unless `st.readout_visible === false`) and +hands `_readoutInfo2d`'s payload to the host via `_notifyReadout` +(`mount()`'s `opts.onReadout` + an `apl:readout` CustomEvent bubbling off `el`), so an +embedding app can render the readout in its own chrome with the pill switched off. + +Content: physical `x:`/`y:` in `st.units`, the pixel index `[ix, iy]`, and the pixel +VALUE — `v:` for a scalar image, `rgb:r,g,b` for `st.is_rgb`. + +Value precision, in order of preference: + +1. **`st.probe_value`** — the exact value Python answered for `st.probe_x/probe_y`. + `_armValueProbe` emits a `value_probe` event after the cursor dwells `st.probe_ms` + on a pixel (once per pixel, never per move); `Plot2D._answer_value_probe` answers from + the array/backend. Ignored unless it matches the pixel now under the cursor. +2. **Inverted codes** — with `raw_is_int`/`detail_is_int` and a band spanning ≤ 255 + levels, `_codeToValue` recovers the exact integer from the byte (see its comment). +3. **Quantised estimate** — `raw_min + code/255*(raw_max-raw_min)`, resolving the + band to `range/255`. This is what a kernel-less page (`save_html`) always shows. + +`_pixelValue2d` reads the codes from the SAME bytes the blit draws — including the +detail tile's native pixels above `zoom=1`, where the base is a downsampled overview +— so the readout always names the source actually on screen. + --- ## Image layers (multi-image overlay) diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py index f0353763b..9336664cb 100644 --- a/anyplotlib/_utils.py +++ b/anyplotlib/_utils.py @@ -175,6 +175,20 @@ def _image_to_data_url(image) -> str: return "data:image/png;base64," + base64.b64encode(png).decode("ascii") +def _codes_are_int(data: np.ndarray) -> bool: + """True when *data* holds integers, so the uint8 codes it quantises to can be + inverted back to the EXACT source values. + + The renderer needs this for its hover readout: with an integral source and a + range that fits in 255 levels, each code's quantisation interval is narrower + than 1 and so contains exactly ONE integer — the original value (see + ``_codeToValue`` in ``figure_esm.js``, which checks the range part). Float data + gets no such guarantee, hence the dtype test rather than a value scan: probing + 4 M pixels for integrality on every frame would cost more than the readout. + """ + return bool(np.issubdtype(np.asarray(data).dtype, np.integer)) + + def _normalize_image(data: np.ndarray, clim: "tuple | None" = None): """Normalise data to uint8, returning (img_u8, vmin, vmax) where vmin/vmax are the QUANTISATION endpoints the 8-bit codes span (the caller stores them as diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index d72ded9d4..45d450c49 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -44,7 +44,8 @@ def imshow(self, data, tile: "str | bool" = "auto", integration_method: str = "mean", overview_method: str = "mean", - tile_backend=None) -> "Plot2D": + tile_backend=None, + probe_exact: bool = True) -> "Plot2D": """Attach a 2-D image to this axes cell. Parameters @@ -71,6 +72,11 @@ def imshow(self, data, puts row 0 at the top, matching the usual image convention. ``"lower"`` puts row 0 at the bottom, matching the matplotlib convention for matrices / scientific plots. + probe_exact : bool, optional + Whether the hover readout asks Python for the EXACT value once the + cursor dwells on a pixel (default ``True``). Without it the value + comes from the 8-bit codes the renderer holds, which quantises wide + dtypes to ``range/255``. See :meth:`~anyplotlib.Plot2D.set_value_probe`. Returns ------- @@ -82,7 +88,7 @@ def imshow(self, data, cmap=cmap, vmin=vmin, vmax=vmax, origin=origin, gpu=gpu, tile=tile, integration_method=integration_method, overview_method=overview_method, - tile_backend=tile_backend) + tile_backend=tile_backend, probe_exact=probe_exact) self._attach(plot) return plot diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index d7c2a209b..bebcb30c4 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -43,6 +43,7 @@ class Event: button — 0=left 1=middle 2=right; None on move/enter/leave/settled buttons — bitmask of currently held buttons xdata, ydata — data-space coordinates (None for Plot3D) + img_x, img_y — Plot2D/PlotMesh: position in IMAGE PIXELS (column, row; row 0 at the top, origin already applied), so a handler can index the source array directly. None on other plot types. ray — Plot3D only: {"origin": [...], "direction": [...]} line_id — Plot1D only: set when pointer is over a line dwell_ms — pointer_settled only: actual dwell time @@ -80,6 +81,9 @@ class Event: buttons: int = 0 xdata: float | None = None ydata: float | None = None + # Image-pixel position (2-D panels): column/row into the displayed frame. + img_x: float | None = None + img_y: float | None = None ray: dict | None = None line_id: str | None = None dwell_ms: float | None = None diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 0c4ab0556..d7f480e86 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -652,6 +652,16 @@ def _dispatch_event(self, raw: str) -> None: plot._set_gpu_active(bool(msg.get("gpu_active", False))) return + # Hover-readout probe: the renderer wants the EXACT value under a pixel the + # cursor dwelled on (its own readout comes from 8-bit codes). Answered + # directly and NOT fired through the callback registry — this is renderer + # plumbing, so a user's wildcard handler must not see it and pause_events() + # must not stall the readout. Same treatment as gpu_status above. + if event_type == "value_probe": + if hasattr(plot, "_answer_value_probe"): + plot._answer_value_probe(msg) + return + source = None if widget_id and hasattr(plot, "_widgets"): widget = plot._widgets.get(widget_id) @@ -671,6 +681,8 @@ def _dispatch_event(self, raw: str) -> None: buttons=msg.get("buttons", 0), xdata=msg.get("xdata"), ydata=msg.get("ydata"), + img_x=msg.get("img_x"), + img_y=msg.get("img_y"), ray=msg.get("ray"), line_id=msg.get("line_id"), dwell_ms=msg.get("dwell_ms"), diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 7dc302ef2..dd0f4c46c 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, onResize }) { +function render({ model, el, onResize, onReadout }) { const dpr = window.devicePixelRatio || 1; // ── shared plot-area padding (mirrors 1D drawing constants) ───────────── @@ -1098,6 +1098,17 @@ function render({ model, el, onResize }) { catch(_) { return; } p2._hoverSi = -1; p2._hoverI = -1; _redrawPanel(p2); + // The new state may carry the exact-value answer for the pixel the cursor is + // resting on (see _armValueProbe) — refresh the readout in place, since no + // mousemove follows a stationary cursor. A push may also be a NEW frame (whose + // pixels void the previous answer), so clear "already asked" and re-arm: the + // dwell timer restarts per push, which means a fast scrub never fires a probe + // and the one that lands after it settles is for the frame on screen. + p2._probeSent = ''; + if (p2.kind === '2d' && p2._hoverIn) { + const rInfo = _updateStatus2d(p2); + if (rInfo) _armValueProbe(p2, rInfo.img_x, rInfo.img_y); + } }); if (_hasGeom) { @@ -1276,6 +1287,17 @@ function render({ model, el, onResize }) { catch(_) { return; } p2._hoverSi = -1; p2._hoverI = -1; _redrawPanel(p2); + // The new state may carry the exact-value answer for the pixel the cursor is + // resting on (see _armValueProbe) — refresh the readout in place, since no + // mousemove follows a stationary cursor. A push may also be a NEW frame (whose + // pixels void the previous answer), so clear "already asked" and re-arm: the + // dwell timer restarts per push, which means a fast scrub never fires a probe + // and the one that lands after it settles is for the frame on screen. + p2._probeSent = ''; + if (p2.kind === '2d' && p2._hoverIn) { + const rInfo = _updateStatus2d(p2); + if (rInfo) _armValueProbe(p2, rInfo.img_x, rInfo.img_y); + } }); if (_hasGeom) { @@ -4347,6 +4369,236 @@ fn fs(in : VsOut) -> @location(0) vec4 { } catch (_) { return { bytes: null, key: '' }; } } + // ── Hover readout (position + pixel value) ──────────────────────────────── + // _imageBytes / _detailBytes atob the WHOLE frame on the base64 transport, and + // the readout runs per mousemove — so cache the decode by the byte identity. + // The binary transport hands over a view (no decode) and needs no cache. + function _readoutBytes2d(p, st) { + if (st.image_b64_bytes) return _imageBytes(st); + if (!st.image_b64) return { bytes: null, key: '' }; + if (p._roImg && p._roImg.key === st.image_b64) return p._roImg; + return (p._roImg = _imageBytes(st)); + } + function _readoutDetailBytes2d(p, st) { + if (st.detail_b64_bytes) return _detailBytes(st); + if (!st.detail_b64) return { bytes: null, key: '' }; + if (p._roDet && p._roDet.key === `detail:${st.detail_b64}`) return p._roDet; + return (p._roDet = _detailBytes(st)); + } + + // uint8 code → data value over the quantisation band [lo, hi]. `step` is the + // width of one level (0 ⇒ the value is EXACT); null when there is no usable band. + // + // `isInt` (Python's raw_is_int / detail_is_int) says the quantised array held + // integers. Python's _normalize_image TRUNCATES, so code c covers the half-open + // interval [lo + c*step, lo + (c+1)*step). When step <= 1 that interval is + // narrower than 1 and therefore contains at most ONE integer — which must be the + // source value, so the quantisation is fully invertible and the readout is exact + // (uint8 frames, masks, label maps, small-range counts). The interval midpoint is + // within step/2 <= 0.5 of that integer; the epsilon breaks the step == 1 tie + // downwards (midpoint n + 0.5 must round to n, not n + 1). + function _codeToValue(lo, hi, code, isInt) { + if (lo == null || hi == null || !isFinite(lo) || !isFinite(hi)) return null; + if (!(hi > lo)) return { value: lo, step: 0 }; + const step = (hi - lo) / 255; + if (isInt && step <= 1) { + return { value: Math.round(lo + (code + 0.5) * step - 1e-9), step: 0 }; + } + return { value: lo + code * step, step }; + } + + // The data value under the cursor, reconstructed from the SAME uint8 codes the + // renderer draws: Python quantises the frame over [raw_min, raw_max] + // (_normalize_image in _utils.py), so code c means raw_min + c/255*(raw_max - + // raw_min). Only 256 levels cross the wire, so this is exact when the codes are + // invertible (see _codeToValue) and a step of range/255 otherwise — for that case + // _armValueProbe asks Python for the true value and _readoutInfo2d prefers the + // answer. This is the always-available estimate underneath that. + // + // Prefers the detail tile wherever it covers the pixel: in tile mode the base + // texture is a DOWNSAMPLED overview whose codes are block averages, while the + // tile carries true native pixels for the visible region. Image LAYERS + // (_drawLayers2d) are never probed — the base image is the primary data. + // + // Returns {value, step} for a scalar image, {rgba:[r,g,b,a]} for a true-colour + // one, or null when the bytes / the band are unavailable. + function _pixelValue2d(p, st, ix, iy) { + const iw = st.image_width, ih = st.image_height; + if (!(iw > 0) || !(ih > 0)) return null; + if (!(ix >= 0 && iy >= 0 && ix < iw && iy < ih)) return null; + // Logical px → base-texture texel (the same kx/ky scale _blit2d draws with, + // so the readout names the texel the cursor is actually over). + const bw = st.base_width || iw, bh = st.base_height || ih; + const bx = Math.min(bw - 1, Math.max(0, Math.floor(ix / iw * bw))); + const by = Math.min(bh - 1, Math.max(0, Math.floor(iy / ih * bh))); + + if (st.is_rgb) { + const b = _readoutBytes2d(p, st).bytes; + if (!b) return null; + const o = (by * bw + bx) * 4; + if (o + 4 > b.length) return null; + return { rgba: [b[o], b[o + 1], b[o + 2], b[o + 3]] }; + } + + // Only above zoom 1, where the tile is what the blit samples (_detailUV / + // _detailOverlayRect both bail at zoom <= 1) — so the readout always names + // the source of the pixel actually on screen. + const reg = st.detail_region || []; + const dw = st.detail_width | 0, dh = st.detail_height | 0; + if ((st.zoom || 1) > 1 && reg.length === 4 && dw > 0 && dh > 0 && + ix >= reg[0] && ix < reg[1] && iy >= reg[2] && iy < reg[3]) { + const d = _readoutDetailBytes2d(p, st).bytes; + if (d && d.length >= dw * dh) { + const tx = Math.min(dw - 1, Math.floor((ix - reg[0]) / (reg[1] - reg[0]) * dw)); + const ty = Math.min(dh - 1, Math.floor((iy - reg[2]) / (reg[3] - reg[2]) * dh)); + // The tile carries the band it was quantised over (tile mode reuses the + // fixed raw band; a manual set_detail matches the display window). + const v = _codeToValue( + st.detail_min != null ? st.detail_min : st.raw_min, + st.detail_max != null ? st.detail_max : st.raw_max, + d[ty * dw + tx], + st.detail_min != null ? st.detail_is_int : st.raw_is_int); + if (v) return v; + } + } + + const b = _readoutBytes2d(p, st).bytes; + if (!b) return null; + const o = by * bw + bx; + if (o >= b.length) return null; + return _codeToValue(st.raw_min, st.raw_max, b[o], st.raw_is_int); + } + + // Pixel-value format: keeps 3 significant digits where fmtVal's 2 would hide + // real signal (58 400 counts must not read "5.8e+4"), and stays compact — the + // status bar is one nowrap line over the image. + function fmtPixVal(v) { + if (v == null || !isFinite(v)) return String(v); + const a = Math.abs(v); + if (a === 0) return '0'; + if (a >= 1e5 || a < 1e-3) return v.toExponential(2); + if (a >= 100) return v.toFixed(0); + return stripZeros(v.toPrecision(3)); + } + + // An EXACT value (a Python probe answer, or a losslessly invertible code) — up to + // 6 significant digits, past which float32 source data stops being meaningful. + // Never abbreviates an integer: an exactly-known 1234567 must not read "1.23e+6". + function fmtExactVal(v) { + if (v == null || !isFinite(v)) return String(v); + if (Number.isInteger(v)) return String(v); + const a = Math.abs(v); + if (a >= 1e6 || a < 1e-4) return v.toExponential(5); + return stripZeros(v.toPrecision(6)); + } + + // The exact value Python answered for one specific pixel (see the value_probe + // event / Plot2D.set_value_probe), or null when there is no answer or it is for a + // DIFFERENT pixel — i.e. the cursor moved on before the round trip landed. + function _probeValueFor(st, px, py) { + if (st.probe_value == null) return null; + if (st.probe_x !== px || st.probe_y !== py) return null; + return st.probe_value; + } + + // Structured hover readout for the pixel under (ix, iy): what the status bar + // renders AND the payload handed to an embedding host (see _notifyReadout). + // Returns null when the cursor is off-image. + function _readoutInfo2d(p, st, ix, iy) { + const iw = st.image_width, ih = st.image_height; + if (!(ix >= 0 && iy >= 0 && ix < iw && iy < ih)) return null; + const xArr = st.x_axis || [], yArr = st.y_axis || []; + const showPhys = xArr.length >= 2 || yArr.length >= 2; + // For both imshow (centre arrays) and pcolormesh (edge arrays), ix/iw maps the + // pixel fraction into the axis array via binary search. + const physX = showPhys && xArr.length >= 2 ? _axisFracToVal(xArr, ix / iw) : ix; + const physY = showPhys && yArr.length >= 2 ? _axisFracToVal(yArr, iy / ih) : iy; + const units = st.units || 'px'; + const px = Math.floor(ix), py = Math.floor(iy); + const pv = _pixelValue2d(p, st, ix, iy); + const probe = _probeValueFor(st, px, py); + + let value = null, exact = false, rgba = null, vTxt = ''; + if (pv && pv.rgba) { + rgba = pv.rgba; exact = true; // uint8 channels — no quantising + vTxt = ` rgb:${rgba[0]},${rgba[1]},${rgba[2]}` + + (rgba[3] < 255 ? ',' + rgba[3] : ''); + } else if (probe != null) { + value = probe; exact = true; // Python answered for this pixel + vTxt = ` v:${fmtExactVal(value)}`; + } else if (pv) { + value = pv.value; exact = pv.step === 0; // step 0 ⇒ invertible codes + vTxt = ` v:${exact ? fmtExactVal(value) : fmtPixVal(value)}`; + } + const text = (showPhys + ? `x:${fmtVal(physX)} y:${fmtVal(physY)}${units ? ' ' + units : ''} [${px}, ${py}]` + : `x:${px} y:${py}`) + vTxt; + return { panel_id: p.id, img_x: ix, img_y: iy, col: px, row: py, + xdata: physX, ydata: physY, units, value, exact, rgba, text }; + } + + // Hand the readout to an embedding host so it can render the position/value in its + // OWN chrome — e.g. a status line in the corner of an Electron window, where it + // covers nothing — instead of (or as well as) the overlay pill: mount()'s + // opts.onReadout(info) plus an `apl:readout` CustomEvent bubbling off the figure + // root. `info` is null when the cursor leaves the image so the host can clear. + // Fires regardless of readout_visible — that is what makes "hide the built-in bar + // and draw it myself" work. Deduped by text so a host is not spammed at 60 Hz. + function _notifyReadout(p, info) { + const next = info ? info.text : ''; + if (next === (p._readoutTxt || '')) return; + p._readoutTxt = next; + if (typeof onReadout === 'function') { try { onReadout(info); } catch (_) {} } + try { + el.dispatchEvent(new CustomEvent('apl:readout', + { detail: info, bubbles: true })); + } catch (_) {} + } + + // Recompute the readout from the panel's last known cursor position: write the + // status bar (unless readout_visible is false) and notify the host. Called on every + // mousemove AND on every panel-state change — a probe answer landing under a + // stationary cursor must refresh the text. Returns the info (null ⇒ off-image). + function _updateStatus2d(p) { + const st = p.state; + if (!st || !p.statusBar) return null; + const imgW = p.imgW || Math.max(1, p.pw - PAD_L - PAD_R); + const imgH = p.imgH || Math.max(1, p.ph - PAD_T - PAD_B); + const [ix, iy] = _canvasToImg2d(p.mouseX, p.mouseY, st, imgW, imgH); + const info = _readoutInfo2d(p, st, ix, iy); + p.statusBar.textContent = info ? info.text : ''; + // Hidden by Python (readout_visible) or by the viewer's `v` toggle. + p.statusBar.style.display = + (info && st.readout_visible !== false && !p.readoutHidden) ? 'block' : 'none'; + _notifyReadout(p, info); + return info; + } + + // Exact-value probe (on by default; st.probe_ms <= 0 disables it): once the cursor + // DWELLS on a pixel, ask Python for the true value there — the codes in the browser + // are 8-bit, Python has the array. One message per dwell, never per move, and never + // twice for the same pixel. The answer arrives as probe_x/probe_y/probe_value and + // _updateStatus2d swaps it in. When there is no kernel (static HTML, embedded page + // with no bridge) or it is busy, nothing arrives and the quantised value just stays. + function _armValueProbe(p, ix, iy) { + const st = p.state; + clearTimeout(p._probeT); p._probeT = 0; + if (!st) return; + const ms = st.probe_ms || 0; + if (ms <= 0 || st.is_rgb) return; // RGB channels are already exact + if (!(ix >= 0 && iy >= 0 && ix < st.image_width && iy < st.image_height)) return; + const px = Math.floor(ix), py = Math.floor(iy); + if (st.probe_x === px && st.probe_y === py) return; // already answered + const key = `${px},${py}`; + if (p._probeSent === key) return; // already asked, no answer yet + p._probeT = setTimeout(() => { + p._probeT = 0; + p._probeSent = key; + _emitEvent(p.id, 'value_probe', null, + { img_x: px, img_y: py, x: p.mouseX, y: p.mouseY }); + }, ms); + } + // Write single-channel R8 bytes into a device texture, (re)creating it if the size // changed. Returns the (possibly new) texture, or null if bytes are missing/short. function _gpuWriteR8(device, tex, texWH, iw, ih, bytes) { @@ -4820,6 +5072,15 @@ fn fs(in : VsOut) -> @location(0) vec4 { gpu: p._gpu, }; }; + // Test hook: the hover status-bar text for a panel (position + pixel value), + // plus whether it is currently shown — the bar is a plain div with no stable + // selector, so a test reads it through here after a mouse move. + globalThis.__apl_statusText = function (panelId) { + const p = panels.get(panelId); + if (!p || !p.statusBar) return null; + return { text: p.statusBar.textContent, + shown: p.statusBar.style.display !== 'none' }; + }; // Tile debug: __apl_tileDebug(true) then pan/zoom, then __apl_tileDump() to read // the ring buffer of detail-tile decisions (detailUV vs FALLBACK->base) + the // window/region/uv so we can see exactly where a pan snaps or inverts. @@ -7706,19 +7967,12 @@ fn fs(in : VsOut) -> @location(0) vec4 { } const [ix,iy]=_canvasToImg2d(mx,my,st,imgW,imgH); - if(ix>=0&&ix=0&&iy=2?_axisFracToVal(xArr,ix/iw):ix; - const physY=yArr.length>=2?_axisFracToVal(yArr,iy/ih):iy; - const units=st.units||'px'; - const showPhys=xArr.length>=2||yArr.length>=2; - p.statusBar.textContent = showPhys - ? `x:${fmtVal(physX)} y:${fmtVal(physY)}${units?' '+units:''} [${Math.floor(ix)}, ${Math.floor(iy)}]` - : `x:${Math.floor(ix)} y:${Math.floor(iy)}`; - p.statusBar.style.display='block'; + // Position + pixel value → the status bar and/or the embedding host, then arm + // the dwell probe that upgrades the value to full precision. + p._hoverIn = true; + const info = _updateStatus2d(p); + _armValueProbe(p, ix, iy); + if(info){ const mhit=_markerHitTest2d(mx,my,st,imgW,imgH); const newSi=mhit?mhit.si:-1; if(newSi!==p._hoverSi){ @@ -7727,7 +7981,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { } if(mhit&&(mhit.collectionLabel||mhit.markerLabel)){const parts=[];if(mhit.collectionLabel)parts.push(mhit.collectionLabel);if(mhit.markerLabel)parts.push(mhit.markerLabel);_showTooltip(parts.join('\n'),e.clientX,e.clientY);settled.clear();return;} tooltip.style.display='none'; - } else { p.statusBar.style.display='none'; tooltip.style.display='none'; + } else { tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} } settled.arm(mx, my, e, () => { @@ -7746,8 +8000,11 @@ fn fs(in : VsOut) -> @location(0) vec4 { }); overlayCanvas.addEventListener('mouseleave',(e)=>{ settled.clear(); + clearTimeout(p._probeT); p._probeT=0; _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); + p._hoverIn=false; p.statusBar.style.display='none';tooltip.style.display='none'; + _notifyReadout(p, null); // host clears its own readout too if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} }); overlayCanvas.addEventListener('dblclick',(e)=>{ @@ -7833,8 +8090,8 @@ fn fs(in : VsOut) -> @location(0) vec4 { },{passive:true}); // Keyboard shortcuts - // Built-ins: r=reset zoom, c=colorbar toggle, l=log scale, s=symlog scale. - // All keys are forwarded to Python unconditionally. + // Built-ins: r=reset zoom, c=colorbar toggle, l=log scale, s=symlog scale, + // v=hover-readout toggle. All keys are forwarded to Python unconditionally. overlayCanvas.addEventListener('keydown',(e)=>{ const st=p.state; if(!st) return; const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); @@ -7870,6 +8127,16 @@ fn fs(in : VsOut) -> @location(0) vec4 { st.scale_mode=st.scale_mode==='symlog'?'linear':'symlog'; draw2d(p); model.set(`panel_${p.id}_json`,_viewStateJson(p)); model.save_changes(); e.stopPropagation(); e.preventDefault(); + } else if(key==='v'){ + // Local show/hide of the on-image readout. Held on the PANEL rather than in + // the state: a Python push (a movie scrub pushes every frame) must not undo + // the viewer's toggle, and hover chrome has no business in the exported + // state. set_readout_visible(False) from Python still hides it; pressing v + // again re-shows it. The payload keeps flowing to an embedding host either + // way (see _notifyReadout), so this only governs the pill. + p.readoutHidden = !p.readoutHidden; + _updateStatus2d(p); + e.stopPropagation(); e.preventDefault(); } }); overlayCanvas.addEventListener('keyup',(e)=>{ @@ -10052,6 +10319,13 @@ export function createLocalModel(initialState) { // 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)). +// opts.onReadout(info) — 2-D hover readout: {panel_id, img_x, img_y, col, row, +// xdata, ydata, units, value, exact, rgba, text}, or +// null when the cursor leaves the image. Render it in +// your own chrome (e.g. a status line in the window +// corner) and call Plot2D.set_readout_visible(False) to +// drop the on-image pill. The same payload also arrives +// as an `apl:readout` CustomEvent bubbling off `el`. 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 (_) {} @@ -10070,7 +10344,8 @@ export function mount(el, state, opts) { // 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 }) || {}; + const api = render({ model, el, + onResize: o.onResize, onReadout: o.onReadout }) || {}; return { model, api, // internal render() API (panels, calloutCanvas, _drawCallouts, …) diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index cb8664269..fc47053ec 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -30,7 +30,7 @@ BrushWidget, VLineWidget, HLineWidget, ) from anyplotlib._utils import (_normalize_image, _build_colormap_lut, - _build_tint_lut, _to_rgba_u8) + _build_tint_lut, _to_rgba_u8, _codes_are_int) def _binary_transport_active() -> bool: @@ -147,7 +147,9 @@ def __init__(self, data, tile: "str | bool" = "auto", integration_method: str = "mean", overview_method: str = "mean", - tile_backend=None): + tile_backend=None, + probe_exact: bool = True, + probe_ms: int = 250): self._id: str = "" # assigned by Axes._attach self._fig: object = None # assigned by Axes._attach @@ -312,6 +314,10 @@ def __init__(self, data, "display_max": disp_max, "raw_min": raw_vmin, "raw_max": raw_vmax, + # Integral source → the renderer inverts the codes back to the exact + # values for its hover readout (see _codes_are_int). A tile-mode + # overview is an average, so this is False there even for integer data. + "raw_is_int": (not self._is_rgb) and _codes_are_int(data), "show_colorbar": False, # None => the renderer's default 6 px image-to-strip gap. "colorbar_pad": None, @@ -332,6 +338,26 @@ def __init__(self, data, "detail_height": 0, "detail_seq": 0, # bumped per set_detail so the renderer re-uploads # a re-sampled tile even at the same size/region + # The band the tile's uint8 codes span (None = no tile). The renderer + # reconstructs data values from it for the hover readout, since the + # tile may be quantised over a different range than the base. + "detail_min": None, + "detail_max": None, + "detail_is_int": False, + # ── Hover readout ──────────────────────────────────────────────── + # The on-image pill showing position + pixel value. False hides it — + # an embedding host can render the same payload in its own chrome + # instead (mount()'s opts.onReadout / the `apl:readout` DOM event). + "readout_visible": True, + # Exact-value probe: dwell time in ms before the renderer asks Python + # for the TRUE value under the cursor (0 disables). The 8-bit codes in + # the browser only resolve the data range to 1/255, so this is what + # makes the readout exact for wide dtypes. See _answer_value_probe. + "probe_ms": probe_ms if probe_exact else 0, + # The answer: which pixel it is for, and its exact value. + "probe_x": None, + "probe_y": None, + "probe_value": None, "overlay_widgets": [], "markers": [], # Image LAYERS (see add_layer): each entry is a small metadata dict @@ -383,6 +409,105 @@ def __init__(self, data, self._layers: list = [] self._layer_raw: dict = {} + # ------------------------------------------------------------------ + # Hover readout (position + pixel value) + # ------------------------------------------------------------------ + def set_readout_visible(self, visible: bool) -> None: + """Show or hide the on-image hover readout (the pill naming the physical + position, the pixel index and the pixel value). + + Hiding it does NOT stop the readout being computed: an embedding host still + receives every update through ``mount()``'s ``opts.onReadout`` callback and + the ``apl:readout`` DOM event, so an Electron app can render the same text in + its own status line (e.g. the bottom-right of the window) where it covers no + data. See ``docs/embedding.rst``. + """ + self._state["readout_visible"] = bool(visible) + self._push() + + def set_value_probe(self, enabled: bool = True, ms: int = 250) -> None: + """Enable/disable the EXACT-value probe behind the hover readout (on by + default; ``Plot2D(..., probe_exact=False)`` opts out at construction). + + The value the renderer can compute locally comes from the 8-bit codes it + draws, so it is exact for integer data whose range fits 255 levels and + quantised to ``range/255`` otherwise. With the probe on, the renderer asks + Python for the true value once the cursor has DWELLED ``ms`` milliseconds on + a pixel; the answer replaces the estimate in place. It costs one small + message per dwelled-on pixel — never one per mouse move — and degrades + silently to the local estimate when there is no live kernel (a ``save_html`` + page) or the kernel is busy. + + ``ms=0`` (or ``enabled=False``) disables it. + """ + self._state["probe_ms"] = int(ms) if (enabled and ms and ms > 0) else 0 + self._push() + + def _invalidate_probe(self, fields: dict | None = None) -> None: + """Drop any exact value answered for the PREVIOUS frame's pixels. + + Every path that replaces displayed pixels must call this: with the cursor + parked (a movie scrub is the common case) the frontend would otherwise keep + showing the old frame's exact value over the new frame's data. Pass a + pending update dict to fold the reset into that same push. + """ + reset = {"probe_x": None, "probe_y": None, "probe_value": None} + (fields if fields is not None else self._state).update(reset) + + def _exact_value(self, col: int, row: int): + """The TRUE data value at display pixel (col, row), or None when it can't be + resolved (out of bounds, RGB image, or a backend that raised). + + Display pixel means the same coordinate system the readout and the + ``img_x``/``img_y`` event fields use: row 0 at the top, ``origin`` already + applied — matching :attr:`data`. In tile mode the full-resolution frame + lives in the backend (``self._data`` is only the coarse overview there), so + the probe samples a 1x1 region from it rather than reading the overview. + """ + if self._is_rgb: + return None + h, w = int(self._state["image_height"]), int(self._state["image_width"]) + if not (0 <= col < w and 0 <= row < h): + return None + try: + if self._tile_on and self._tile_backend is not None: + b = self._tile_backend + # Backend rows are in SOURCE order; a 'lower' origin mirrors them + # for display, so undo that to hit the pixel the cursor is over. + sy = (h - 1 - row) if b.origin == "lower" else row + # A 1x1 sample is the pixel itself — no reduction, so the + # integration method is irrelevant here. + return float(np.asarray(b.sample(col, col + 1, sy, sy + 1, 1, 1))[0, 0]) + arr = np.asarray(self._data) # already display-oriented + if arr.ndim != 2 or row >= arr.shape[0] or col >= arr.shape[1]: + return None + return float(arr[row, col]) + except Exception as e: + _TLOG.debug("[PROBE] exact value at (%d, %d) failed: %s", col, row, e) + return None + + def _answer_value_probe(self, msg: dict) -> None: + """Answer the renderer's dwell probe: look up the exact value under the + cursor and push it back with the pixel it belongs to, so the frontend can + tell a fresh answer from a stale one (the cursor may have moved on). + + Called straight from ``Figure._dispatch_event`` with the raw message — + deliberately not routed through the callback registry (renderer plumbing, + not a user event).""" + if not self._state.get("probe_ms"): + return # probe disabled → ignore + try: + col = int(round(float(msg.get("img_x")))) + row = int(round(float(msg.get("img_y")))) + except (TypeError, ValueError): + return + value = self._exact_value(col, row) + if value is None: + return + self._state.update({"probe_x": col, "probe_y": row, + "probe_value": value}) + self._push() + @property def gpu_active(self) -> bool: """True when this image is being rendered by the WebGPU path (reported by @@ -537,7 +662,9 @@ def _disable_tile(self) -> None: self._state["base_width"] = 0 self._state["base_height"] = 0 self._state.update({"detail_b64": "", "detail_region": [], - "detail_width": 0, "detail_height": 0}) + "detail_width": 0, "detail_height": 0, + "detail_min": None, "detail_max": None, + "detail_is_int": False}) _TLOG.debug("[TILEDBG] _disable_tile: tile mode OFF (forced plain)") def update_tile_source(self, array=None) -> None: @@ -610,6 +737,10 @@ def _refresh_overview(self) -> None: self._state["image_b64"] = self._encode_pixels("image_b64", img_u8) self._state["base_width"] = int(ow) self._state["base_height"] = int(oh) + # The overview's own dtype decides code invertibility (a mean reduction + # of integer data is float, so this usually turns the flag off). + self._state["raw_is_int"] = _codes_are_int(ov) + self._invalidate_probe() # new pixels → old answer is void self._overview_stale = False # min/max are two more full passes over the overview — same guard rule # as the FETCH diagnostic below (they are .debug() ARGUMENTS, so lazy @@ -1096,12 +1227,16 @@ def set_data(self, data: np.ndarray, "display_max": disp_max, "raw_min": vmin, "raw_max": vmax, + "raw_is_int": (not is_rgb) and _codes_are_int(data), } + self._invalidate_probe(fields) # new frame → old exact value is void # A new base frame invalidates any detail tile of the OLD frame — clear it # so the shader doesn't sample a stale hi-res crop over the new image. if self._state.get("detail_b64"): fields.update({"detail_b64": "", "detail_region": [], - "detail_width": 0, "detail_height": 0}) + "detail_width": 0, "detail_height": 0, + "detail_min": None, "detail_max": None, + "detail_is_int": False}) # RGB images never use the colormap LUT — skip the (costly) rebuild and # leave the existing entry untouched. Only recompute for scalar data. if not is_rgb: @@ -1635,6 +1770,8 @@ def set_detail(self, tile=None, x0=None, x1=None, y0=None, y1=None) -> None: self._state.update({ "detail_b64": "", "detail_region": [], "detail_width": 0, "detail_height": 0, + "detail_min": None, "detail_max": None, + "detail_is_int": False, }) self._push() return @@ -1650,19 +1787,26 @@ def set_detail(self, tile=None, x0=None, x1=None, y0=None, y1=None) -> None: clim = (self._state.get("display_min"), self._state.get("display_max")) if clim[0] is None or clim[1] is None or not (clim[1] > clim[0]): clim = None - img_u8, _vmin, _vmax = _normalize_image(tile, clim=clim) + img_u8, tile_min, tile_max = _normalize_image(tile, clim=clim) th, tw = tile.shape # Monotonic sequence so the renderer's dedup key CHANGES on every pushed tile # even when length + region are identical (a live movie scrub re-samples the # SAME region every frame — without this the JS skips the re-upload and the # zoomed-in view freezes on the first frame). See _detailBytes in figure_esm.js. self._detail_seq = getattr(self, "_detail_seq", 0) + 1 + self._invalidate_probe() # the tile is new pixels for this region self._state.update({ "detail_b64": self._encode_pixels("detail_b64", img_u8), "detail_region": [int(x0), int(x1), int(y0), int(y1)], "detail_width": int(tw), "detail_height": int(th), "detail_seq": self._detail_seq, + # The band these codes span, so the renderer's hover readout can + # reconstruct values from the tile (it need not match the base band), + # plus whether the tile's own dtype makes them exactly invertible. + "detail_min": tile_min, + "detail_max": tile_max, + "detail_is_int": _codes_are_int(tile), }) self._push() diff --git a/anyplotlib/plot2d/_plotmesh.py b/anyplotlib/plot2d/_plotmesh.py index 3210b493f..0bb910569 100644 --- a/anyplotlib/plot2d/_plotmesh.py +++ b/anyplotlib/plot2d/_plotmesh.py @@ -10,7 +10,8 @@ from anyplotlib.markers import MarkerRegistry from anyplotlib.plot2d._plot2d import Plot2D -from anyplotlib._utils import _normalize_image, _build_colormap_lut, _resample_mesh +from anyplotlib._utils import (_normalize_image, _build_colormap_lut, + _resample_mesh, _codes_are_int) class PlotMesh(Plot2D): @@ -90,6 +91,10 @@ def set_data(self, data: np.ndarray, resampled = _resample_mesh(data, xe, ye) img_u8, vmin, vmax = _normalize_image(resampled) self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax + # The resampled grid IS the displayed frame (one texel per cell), so it is + # what `.data` reads back and what the hover probe looks values up in — + # leaving the previous frame here would answer both with stale values. + self._data = resampled self._state.update({ "image_b64": self._encode_pixels("image_b64", img_u8), @@ -101,7 +106,12 @@ def set_data(self, data: np.ndarray, "display_max": vmax, "raw_min": vmin, "raw_max": vmax, + "raw_is_int": _codes_are_int(resampled), "colormap_data": _build_colormap_lut(self._state["colormap_name"]), + # A new frame invalidates any exact value answered for the old one. + "probe_x": None, + "probe_y": None, + "probe_value": None, }) if units is not None: self._state["units"] = units diff --git a/anyplotlib/tests/test_plot2d/test_status_pixel_value.py b/anyplotlib/tests/test_plot2d/test_status_pixel_value.py new file mode 100644 index 000000000..42c5cc96b --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_status_pixel_value.py @@ -0,0 +1,730 @@ +"""Hover readout: the 2-D status bar names the VALUE of the pixel under the +cursor next to its physical + pixel coordinates. + +Three precision tiers, all covered here: + +* **Invertible codes** — an integral source whose range fits the 256 codes is + reconstructed EXACTLY from the bytes the renderer already holds, no kernel + needed (``raw_is_int`` / ``_codeToValue``). +* **Quantised estimate** — anything wider resolves to ``range/255``; the + assertions allow exactly that one step and no more. +* **Exact probe** — on a dwell the renderer asks Python for the true value and + swaps it in (``value_probe`` → ``Plot2D._answer_value_probe``). The test pages have + no live kernel, so the JS half is driven by injecting the answer into the panel + state and the Python half by dispatching the event directly. +""" +from __future__ import annotations + +import json +import pathlib +import re +import tempfile + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, _get_events, +) + +IMG = 64 +FIG = 320 + +_OVERLAY = "[...document.querySelectorAll('canvas')].find(x => x.style.zIndex === '5')" + + +def _hover_pixel(page, plot, ix, iy): + """Move the mouse to the centre of logical image pixel (ix, iy); return the + status-bar text the renderer wrote (or None when it stayed hidden).""" + pt = page.evaluate( + "([pid, ix, iy]) => globalThis.__apl_imgToCanvas(pid, ix, iy)", + [plot._id, ix, iy]) + assert pt, "__apl_imgToCanvas returned no point — panel not a live 2-D panel?" + box = page.evaluate( + f"() => {{ const c = {_OVERLAY}; const r = c.getBoundingClientRect();" + f" return {{x: r.x, y: r.y}}; }}") + page.mouse.move(box["x"] + pt[0], box["y"] + pt[1]) + page.wait_for_timeout(60) + status = page.evaluate("(pid) => globalThis.__apl_statusText(pid)", plot._id) + assert status is not None, "no status bar on the panel" + return status["text"] if status["shown"] else None + + +def _value_of(text): + """The number after ``v:`` in a status-bar line.""" + m = re.search(r"v:(-?[\d.]+(?:[eE][-+]?\d+)?)", text or "") + assert m, f"no v: in status text {text!r}" + return float(m.group(1)) + + +def _set_probe_answer(page, plot, col, row, value): + """Inject the answer Python would push for a dwell probe (the test page has no + kernel, so this stands in for the round trip).""" + page.evaluate( + """([pid, col, row, value]) => { + const key = 'panel_' + pid + '_json'; + const st = JSON.parse(window._aplModel.get(key)); + st.probe_x = col; st.probe_y = row; st.probe_value = value; + window._aplModel.set(key, JSON.stringify(st)); + }""", [plot._id, col, row, value]) + page.wait_for_timeout(60) + + +def _watch_readout(page): + """Record every ``apl:readout`` payload the figure dispatches to its host.""" + page.evaluate("""() => { + window._aplReadouts = []; + document.addEventListener('apl:readout', + (e) => window._aplReadouts.push(e.detail), true); + }""") + + +def _readouts(page): + return page.evaluate("() => window._aplReadouts") + + +class TestScalarPixelValue: + def test_value_shown_with_pixel_and_physical_coords(self, interact_page): + """x/y in axis units, [ix, iy] in pixels, and now v: — all three.""" + data = np.arange(IMG * IMG, dtype=np.float32).reshape(IMG, IMG) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False, units="nm", + axes=[np.linspace(0, 6.3, IMG), np.linspace(0, 6.3, IMG)]) + page = interact_page(fig) + + step = (float(data.max()) - float(data.min())) / 255.0 + for ix, iy in [(10, 20), (0, 0), (IMG - 1, IMG - 1), (33, 5)]: + text = _hover_pixel(page, plot, ix, iy) + assert text, f"status bar hidden while hovering pixel ({ix}, {iy})" + assert f"[{ix}, {iy}]" in text, f"pixel coords wrong: {text!r}" + assert "nm" in text, f"physical coords/units missing: {text!r}" + expected = float(data[iy, ix]) + got = _value_of(text) + assert abs(got - expected) <= step, ( + f"pixel ({ix}, {iy}): status shows v:{got}, data is {expected} " + f"(one quantisation step is {step:.3f}) — text {text!r}") + + def test_value_shown_without_explicit_axes(self, interact_page): + """No axis arrays given (coords fall back to pixel indices in "px") — the + value rides along the same way.""" + data = np.linspace(0.0, 1.0, IMG * IMG, dtype=np.float32).reshape(IMG, IMG) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False) + page = interact_page(fig) + + text = _hover_pixel(page, plot, 12, 40) + assert text and "[12, 40]" in text and "px" in text, f"coords wrong: {text!r}" + assert abs(_value_of(text) - float(data[40, 12])) <= 1.0 / 255.0, text + + def test_constant_image_reads_exact_value(self, interact_page): + """raw_min == raw_max (a flat frame): every code is that one value, exactly.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(np.full((32, 32), 7.5, np.float32), cmap="gray", gpu=False) + page = interact_page(fig) + + text = _hover_pixel(page, plot, 16, 16) + assert _value_of(text) == 7.5, text + + def test_reports_data_not_the_display_window(self, interact_page): + """vmin/vmax clip the COLORMAP, not the data: a pixel far above vmax still + reads its own value (the codes span the data range, the LUT does the + clipping) — the readout is a data probe, not a colour probe.""" + data = np.zeros((32, 32), np.float32) + data[8, 8] = 100.0 # saturates the display window + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", vmin=0.0, vmax=2.0, gpu=False) + page = interact_page(fig) + + assert _value_of(_hover_pixel(page, plot, 8, 8)) == 100.0 + assert _value_of(_hover_pixel(page, plot, 0, 0)) == 0.0 + + def test_set_data_clim_saturates_the_readout(self, interact_page): + """``set_data(clim=…)`` quantises over the clim itself (so the signal keeps + all 256 codes instead of a hot pixel stealing them) — outliers therefore + saturate the readout at the band edge, which is the honest report of what + crossed the wire.""" + data = np.zeros((32, 32), np.float32) + data[8, 8] = 100.0 + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False) + plot.set_data(data, clim=(0.0, 2.0)) + page = interact_page(fig) + + assert _value_of(_hover_pixel(page, plot, 8, 8)) == 2.0, "must clamp to the clim" + + def test_value_hidden_off_image(self, interact_page): + """Cursor in the letterbox margin beside the image → the bar hides, value + and all (a wide image in a square panel leaves margin inside the canvas).""" + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(np.zeros((32, IMG), np.float32), cmap="gray", gpu=False) + page = interact_page(fig) + + assert _hover_pixel(page, plot, 32, 16) is not None # on the image + assert _hover_pixel(page, plot, 32, -8) is None # above it + + +class TestMeshPixelValue: + def test_pcolormesh_cell_value(self, interact_page): + """Mesh panels share the 2-D hover handler — the readout names the cell + value under the cursor (from the resampled display grid it draws).""" + data = np.arange(16 * 16, dtype=np.float32).reshape(16, 16) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.pcolormesh(data, x_edges=np.arange(17.0), + y_edges=np.arange(17.0), units="keV") + page = interact_page(fig) + + step = (float(data.max()) - float(data.min())) / 255.0 + text = _hover_pixel(page, plot, 5, 9) + assert abs(_value_of(text) - float(data[9, 5])) <= step, text + + +class TestRgbPixelValue: + def test_rgb_image_shows_channel_triplet(self, interact_page): + """True-colour images have no scalar value — report the RGB channels.""" + rgb = np.zeros((16, 16, 3), np.uint8) + rgb[:, :, 0] = 255 # pure red + rgb[4, 6] = (10, 20, 30) # one known pixel + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(rgb, gpu=False) + page = interact_page(fig) + + assert "rgb:255,0,0" in _hover_pixel(page, plot, 2, 2) + assert "rgb:10,20,30" in _hover_pixel(page, plot, 6, 4) + + +class TestDetailTilePixelValue: + def test_zoomed_readout_comes_from_the_detail_tile(self, interact_page): + """Zoomed into a detail region, the screen shows the TILE's native pixels — + so the readout must come from the tile too, not the coarser base.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(np.zeros((IMG, IMG), np.float32), + cmap="gray", vmin=0.0, vmax=1.0, gpu=False) + # Base is all 0; the tile covering [16:48, 16:48] is all 1. (State must be + # final before the page opens — the test page is a snapshot, no kernel.) + plot.set_detail(np.ones((32, 32), np.float32), 16, 48, 16, 48) + page = interact_page(fig) + # zoom 2 centred → the visible window is exactly the tile region. + page.evaluate("(pid) => globalThis.__apl_setZoom(pid, 2.0, 0.5, 0.5)", plot._id) + page.wait_for_timeout(120) + + text = _hover_pixel(page, plot, 32, 32) + assert abs(_value_of(text) - 1.0) <= 1.0 / 255.0, ( + f"zoomed readout ignored the detail tile (base value leaked): {text!r}") + + +class TestDetailBandState: + """The tile's quantisation band travels with it — the renderer cannot + reconstruct tile values without it, and it need not match the base band.""" + + def test_set_detail_records_band(self): + p = apl.subplots(1, 1)[1].imshow(np.zeros((64, 64), np.float32), + vmin=0.0, vmax=2.0) + p.set_detail(np.ones((32, 32), np.float32), 16, 48, 16, 48) + assert p._state["detail_min"] == 0.0 and p._state["detail_max"] == 2.0 + + def test_clearing_detail_clears_band(self): + p = apl.subplots(1, 1)[1].imshow(np.zeros((64, 64), np.float32), + vmin=0.0, vmax=2.0) + p.set_detail(np.ones((32, 32), np.float32), 16, 48, 16, 48) + p.set_detail(None) + assert p._state["detail_min"] is None and p._state["detail_max"] is None + + def test_new_base_frame_clears_band(self): + p = apl.subplots(1, 1)[1].imshow(np.zeros((64, 64), np.float32), + vmin=0.0, vmax=2.0) + p.set_detail(np.ones((32, 32), np.float32), 16, 48, 16, 48) + p.set_data(np.zeros((64, 64), np.float32)) + assert p._state["detail_min"] is None and p._state["detail_max"] is None + + +# ══════════════════════════════════════════════════════════════════════════════ +# Tier 1 — invertible codes: exact values with NO round trip +# ══════════════════════════════════════════════════════════════════════════════ + +class TestIntegerCodesAreExact: + """An integral source whose range fits in 256 codes is fully recoverable from + the bytes already in the browser — interpolating the band instead would report + e.g. 6.27 for a 7. No kernel involved, so this holds in a static HTML export.""" + + def test_uint8_values_are_exact(self, interact_page): + rng = np.random.default_rng(0) + data = rng.integers(3, 201, size=(32, 32)).astype(np.uint8) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False) + assert plot._state["raw_is_int"] is True + page = interact_page(fig) + + for ix, iy in [(0, 0), (7, 3), (31, 31), (12, 20)]: + text = _hover_pixel(page, plot, ix, iy) + assert _value_of(text) == float(data[iy, ix]), ( + f"pixel ({ix}, {iy}) must read its exact integer: {text!r}") + + def test_small_range_int_labels_are_exact(self, interact_page): + """A label/mask map (0..4) — every class index must read back exactly.""" + data = (np.arange(16 * 16).reshape(16, 16) % 5).astype(np.int32) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False) + page = interact_page(fig) + + for ix, iy in [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (9, 7)]: + assert _value_of(_hover_pixel(page, plot, ix, iy)) == float(data[iy, ix]) + + def test_float_source_is_not_claimed_exact(self): + """Float data gets no invertibility promise (0.5 is not recoverable).""" + p = apl.subplots(1, 1)[1].imshow(np.linspace(0, 1, 64).reshape(8, 8)) + assert p._state["raw_is_int"] is False + + def test_wide_int_range_stays_quantised(self, interact_page): + """Range beyond 255 levels genuinely loses information — the readout must + land within one step (and the probe, with no kernel here, changes nothing).""" + data = (np.arange(32 * 32, dtype=np.uint16) * 60).reshape(32, 32) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False) + page = interact_page(fig) + + step = (float(data.max()) - float(data.min())) / 255.0 + assert step > 1, "test needs a range wider than 255 levels" + got = _value_of(_hover_pixel(page, plot, 20, 20)) + assert abs(got - float(data[20, 20])) <= step + + def test_tile_overview_is_not_claimed_exact(self): + """A mean-reduced overview holds averages, not the source integers.""" + big = (np.arange(1500 * 1500, dtype=np.uint16) % 4000).reshape(1500, 1500) + p = apl.subplots(1, 1)[1].imshow(big, tile=True) + assert p._state["tile_enabled"] is True + assert p._state["raw_is_int"] is False + + +# ══════════════════════════════════════════════════════════════════════════════ +# Tier 3 — the exact-value probe (JS half) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestExactProbeFrontend: + def _wide_plot(self, interact_page, **kw): + data = (np.arange(32 * 32, dtype=np.uint16) * 60).reshape(32, 32) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False, **kw) + page = interact_page(fig) + _collect_events(page) + return data, plot, page + + def test_probe_fires_after_dwell(self, interact_page): + data, plot, page = self._wide_plot(interact_page) + assert plot._state["probe_ms"] == 250, "probe is on by default" + _hover_pixel(page, plot, 11, 6) + page.wait_for_timeout(500) # outlast the dwell + evs = _get_events(page, "value_probe") + assert evs, "no value_probe emitted after the cursor dwelled" + assert (evs[-1]["img_x"], evs[-1]["img_y"]) == (11, 6), evs[-1] + + def test_probe_not_emitted_when_disabled(self, interact_page): + data, plot, page = self._wide_plot(interact_page, probe_exact=False) + assert plot._state["probe_ms"] == 0 + _hover_pixel(page, plot, 11, 6) + page.wait_for_timeout(500) + assert not _get_events(page, "value_probe"), ( + "probe_exact=False must not send anything to Python") + + def test_probe_answer_replaces_quantised_value(self, interact_page): + data, plot, page = self._wide_plot(interact_page) + exact = float(data[6, 11]) + quantised = _value_of(_hover_pixel(page, plot, 11, 6)) + assert quantised != exact, "test needs a lossy band" + _set_probe_answer(page, plot, 11, 6, exact) + assert _value_of(_hover_pixel(page, plot, 11, 6)) == exact + + def test_probe_answer_refreshes_a_stationary_cursor(self, interact_page): + """The answer lands while the cursor is still — no mousemove follows, so the + state observer has to refresh the bar itself.""" + data, plot, page = self._wide_plot(interact_page) + exact = float(data[6, 11]) + _hover_pixel(page, plot, 11, 6) # park the cursor + _set_probe_answer(page, plot, 11, 6, exact) # answer, no further move + text = page.evaluate("(pid) => globalThis.__apl_statusText(pid)", + plot._id)["text"] + assert _value_of(text) == exact, ( + f"status bar did not pick up the probe answer in place: {text!r}") + + def test_stale_probe_answer_is_ignored(self, interact_page): + """An answer for a different pixel must not be shown for this one.""" + data, plot, page = self._wide_plot(interact_page) + _set_probe_answer(page, plot, 30, 30, -12345.0) + text = _hover_pixel(page, plot, 11, 6) + assert _value_of(text) != -12345.0, f"stale answer leaked: {text!r}" + + def test_rgb_image_never_probes(self, interact_page): + """True-colour channels are already exact — no round trip is warranted.""" + rgb = np.zeros((16, 16, 3), np.uint8) + rgb[:, :, 1] = 128 + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(rgb, gpu=False) + page = interact_page(fig) + _collect_events(page) + _hover_pixel(page, plot, 8, 8) + page.wait_for_timeout(500) + assert not _get_events(page, "value_probe") + + +# ══════════════════════════════════════════════════════════════════════════════ +# Tier 3 — the exact-value probe (Python half) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestExactProbeBackend: + """``value_probe`` → the exact value, pushed back with the pixel it belongs to.""" + + def _probe(self, fig, plot, col, row): + fig._dispatch_event(json.dumps({ + "event_type": "value_probe", "panel_id": plot._id, + "img_x": col, "img_y": row, "x": 10, "y": 10, + })) + return plot._state + + def test_answers_through_the_real_dispatch(self): + data = (np.arange(64 * 64, dtype=np.uint16) * 15).reshape(64, 64) + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(data) + st = self._probe(fig, plot, 33, 5) + assert (st["probe_x"], st["probe_y"]) == (33, 5) + assert st["probe_value"] == float(data[5, 33]) + + def test_origin_lower_maps_to_the_displayed_pixel(self): + data = (np.arange(16 * 16, dtype=np.uint16)).reshape(16, 16) + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(data, origin="lower") + # Display row 0 is the LAST array row when origin='lower'. + st = self._probe(fig, plot, 3, 0) + assert st["probe_value"] == float(data[-1, 3]) + + def test_tile_mode_probes_full_resolution(self): + """The overview in state is decimated; the answer must come from the + backend's native pixels, not the average the base texture holds.""" + big = (np.arange(1500 * 1500, dtype=np.uint16) % 5000).reshape(1500, 1500) + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(big, tile=True) + st = self._probe(fig, plot, 700, 800) + assert st["probe_value"] == float(big[800, 700]) + + def test_out_of_bounds_is_ignored(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((8, 8), np.uint8)) + st = self._probe(fig, plot, 99, 3) + assert st["probe_value"] is None + + def test_disabled_probe_ignores_the_event(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.arange(64, dtype=np.uint16).reshape(8, 8), + probe_exact=False) + st = self._probe(fig, plot, 3, 3) + assert st["probe_value"] is None + + def test_new_frame_clears_a_stale_answer(self): + data = np.arange(64, dtype=np.uint16).reshape(8, 8) + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(data) + self._probe(fig, plot, 3, 3) + assert plot._state["probe_value"] is not None + plot.set_data(data * 2) + assert plot._state["probe_value"] is None, ( + "an answer for the OLD frame must not survive a new one") + + def test_set_value_probe_toggles(self): + plot = apl.subplots(1, 1)[1].imshow(np.zeros((8, 8), np.uint8)) + plot.set_value_probe(False) + assert plot._state["probe_ms"] == 0 + plot.set_value_probe(True, ms=80) + assert plot._state["probe_ms"] == 80 + + def test_img_coords_reach_python_handlers(self): + """img_x/img_y are now real Event fields, so a user handler can index the + source array directly instead of mapping axis units back.""" + seen = [] + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((8, 8), np.uint8)) + plot.add_event_handler("pointer_down")(lambda e: seen.append((e.img_x, e.img_y))) + fig._dispatch_event(json.dumps({ + "event_type": "pointer_down", "panel_id": plot._id, + "img_x": 2.5, "img_y": 6.5, "xdata": 2.5, "ydata": 6.5, + })) + assert seen == [(2.5, 6.5)] + + +# ══════════════════════════════════════════════════════════════════════════════ +# Toggling the overlay + handing the readout to an embedding host +# ══════════════════════════════════════════════════════════════════════════════ + +class TestReadoutVisibility: + def test_hidden_overlay_still_reports_to_the_host(self, interact_page): + """set_readout_visible(False) drops the on-image pill but keeps computing the + readout — that is what lets an Electron app draw it in its own status line.""" + data = np.arange(16 * 16, dtype=np.uint8).reshape(16, 16) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False) + plot.set_readout_visible(False) + assert plot._state["readout_visible"] is False + page = interact_page(fig) + _watch_readout(page) + + assert _hover_pixel(page, plot, 5, 9) is None, "pill must stay hidden" + outs = _readouts(page) + assert outs, "host got no readout while the overlay was hidden" + last = outs[-1] + assert (last["col"], last["row"]) == (5, 9) + assert last["value"] == float(data[9, 5]) and last["exact"] is True + assert "[5, 9]" in last["text"] + + def test_visible_overlay_also_reports_to_the_host(self, interact_page): + data = np.arange(16 * 16, dtype=np.uint8).reshape(16, 16) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False) + page = interact_page(fig) + _watch_readout(page) + + text = _hover_pixel(page, plot, 5, 9) + assert text, "pill should be visible by default" + assert _readouts(page)[-1]["text"] == text + + def test_leaving_the_image_clears_the_host_readout(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(np.zeros((32, IMG), np.uint8), cmap="gray", gpu=False) + page = interact_page(fig) + _watch_readout(page) + + _hover_pixel(page, plot, 32, 16) + assert _readouts(page)[-1] is not None + _hover_pixel(page, plot, 32, -8) # into the letterbox margin + assert _readouts(page)[-1] is None, ( + "host must be told the cursor left so it can clear its display") + + def test_toggle_is_live(self, interact_page): + """Flipping the flag on a running figure takes effect without a reload.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(np.zeros((16, 16), np.uint8), cmap="gray", gpu=False) + page = interact_page(fig) + assert _hover_pixel(page, plot, 8, 8) is not None + page.evaluate( + """([pid]) => { + const key = 'panel_' + pid + '_json'; + const st = JSON.parse(window._aplModel.get(key)); + st.readout_visible = false; + window._aplModel.set(key, JSON.stringify(st)); + }""", [plot._id]) + assert _hover_pixel(page, plot, 8, 8) is None + + +_MOUNT_READOUT_PAGE = """ + + +
+ +""" + + +class TestMountReadoutCallback: + """The Electron contract: mount(el, state, {onReadout}) — the host renders the + position/value wherever it likes (e.g. the window's bottom-right corner).""" + + @pytest.fixture + def readout_mount_page(self, _pw_browser): + from anyplotlib.embed import esm_path, figure_state + pages, paths = [], [] + + def _open(fig): + html = (_MOUNT_READOUT_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) + page.evaluate("() => new Promise(r => requestAnimationFrame(" + "() => requestAnimationFrame(r)))") + return page + + yield _open + for p in pages: + try: + p.close() + except Exception: + pass + for path in paths: + path.unlink(missing_ok=True) + + def test_on_readout_callback_receives_the_value(self, readout_mount_page): + data = np.arange(16 * 16, dtype=np.uint8).reshape(16, 16) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False) + plot.set_readout_visible(False) # host draws it instead + page = readout_mount_page(fig) + + pt = page.evaluate("([pid, ix, iy]) => globalThis.__apl_imgToCanvas(pid, ix, iy)", + [plot._id, 5, 9]) + box = page.evaluate( + f"() => {{ const c = {_OVERLAY}; const r = c.getBoundingClientRect();" + f" return {{x: r.x, y: r.y}}; }}") + page.mouse.move(box["x"] + pt[0], box["y"] + pt[1]) + page.wait_for_timeout(80) + + outs = page.evaluate("() => window._readouts") + assert outs, "mount(opts.onReadout) was never called" + last = outs[-1] + assert (last["col"], last["row"]) == (5, 9) + assert last["value"] == float(data[9, 5]) + assert last["exact"] is True + # And the built-in pill really is gone in the embedded page. + shown = page.evaluate("(pid) => globalThis.__apl_statusText(pid).shown", + plot._id) + assert shown is False + + +class TestProbeStaleness: + """A parked cursor must never keep showing the PREVIOUS frame's exact value — + the movie-scrub case, where only the pixels change under a still mouse.""" + + def test_detail_tile_push_invalidates_the_answer(self): + data = np.arange(64 * 64, dtype=np.uint16).reshape(64, 64) + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(data, vmin=0, vmax=4095) + fig._dispatch_event(json.dumps({ + "event_type": "value_probe", "panel_id": plot._id, + "img_x": 20, "img_y": 20})) + assert plot._state["probe_value"] is not None + plot.set_detail(np.ones((32, 32), np.uint16), 16, 48, 16, 48) + assert plot._state["probe_value"] is None + + def test_tiled_scrub_invalidates_the_answer(self): + big = (np.arange(1500 * 1500, dtype=np.uint16) % 5000).reshape(1500, 1500) + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(big, tile=True) + fig._dispatch_event(json.dumps({ + "event_type": "value_probe", "panel_id": plot._id, + "img_x": 700, "img_y": 800})) + assert plot._state["probe_value"] == float(big[800, 700]) + plot.update_tile_source((big + 1).astype(np.uint16)) + assert plot._state["probe_value"] is None, ( + "a scrubbed frame must void the previous frame's exact value") + + def test_mesh_set_data_invalidates_the_answer(self): + data = np.arange(16 * 16, dtype=np.uint16).reshape(16, 16) + fig, ax = apl.subplots(1, 1) + plot = ax.pcolormesh(data, x_edges=np.arange(17.0), y_edges=np.arange(17.0)) + fig._dispatch_event(json.dumps({ + "event_type": "value_probe", "panel_id": plot._id, + "img_x": 5, "img_y": 9})) + assert plot._state["probe_value"] == float(data[9, 5]) + plot.set_data(data * 3) + assert plot._state["probe_value"] is None + # …and the next probe answers from the NEW frame, not the stale copy. + fig._dispatch_event(json.dumps({ + "event_type": "value_probe", "panel_id": plot._id, + "img_x": 5, "img_y": 9})) + assert plot._state["probe_value"] == float(data[9, 5] * 3) + + def test_probe_is_not_a_user_event(self): + """It is renderer plumbing: a wildcard handler must not see it, and + pause_events() must not stall the readout.""" + seen = [] + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.arange(64, dtype=np.uint16).reshape(8, 8)) + plot.add_event_handler("*")(lambda e: seen.append(e.event_type)) + with plot.pause_events(): + fig._dispatch_event(json.dumps({ + "event_type": "value_probe", "panel_id": plot._id, + "img_x": 3, "img_y": 3})) + assert seen == [], f"value_probe leaked into user handlers: {seen}" + assert plot._state["probe_value"] is not None, ( + "the readout must keep working while user events are paused") + + def test_frontend_rearms_after_a_frame_lands(self, interact_page): + """With the cursor parked, a state push re-arms the dwell probe — so the + value becomes exact again once a scrub settles, without a mouse move.""" + data = (np.arange(32 * 32, dtype=np.uint16) * 60).reshape(32, 32) + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(data, cmap="gray", gpu=False) + page = interact_page(fig) + _collect_events(page) + + _hover_pixel(page, plot, 9, 4) + page.wait_for_timeout(500) + assert _get_events(page, "value_probe"), "first dwell probe missing" + # A new frame arrives (probe fields cleared, as Python does) — no mousemove. + page.evaluate( + """([pid]) => { + const key = 'panel_' + pid + '_json'; + const st = JSON.parse(window._aplModel.get(key)); + st.probe_x = null; st.probe_y = null; st.probe_value = null; + window._aplModel.set(key, JSON.stringify(st)); + }""", [plot._id]) + page.wait_for_timeout(500) + probes = _get_events(page, "value_probe") + assert len(probes) >= 2, ( + "a settled frame under a parked cursor must re-probe for the new pixels") + assert (probes[-1]["img_x"], probes[-1]["img_y"]) == (9, 4) + + +class TestReadoutKeyToggle: + """`v` over the plot hides/shows the pill — the same family as r/c/l/s.""" + + def _hover(self, page, plot, ix, iy): + return _hover_pixel(page, plot, ix, iy) + + def test_v_toggles_the_pill(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(np.zeros((16, 16), np.uint8), cmap="gray", gpu=False) + page = interact_page(fig) + _watch_readout(page) + + assert self._hover(page, plot, 8, 8) is not None + page.keyboard.press("v") + assert self._hover(page, plot, 8, 8) is None, "v must hide the pill" + page.keyboard.press("v") + assert self._hover(page, plot, 8, 8) is not None, "v again must restore it" + + def test_hidden_by_key_still_reports_to_the_host(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(np.arange(256, dtype=np.uint8).reshape(16, 16), + cmap="gray", gpu=False) + page = interact_page(fig) + _watch_readout(page) + + self._hover(page, plot, 8, 8) + page.keyboard.press("v") + self._hover(page, plot, 9, 8) + assert _readouts(page)[-1]["col"] == 9, ( + "host must keep receiving the readout while the pill is toggled off") + + def test_state_push_does_not_undo_the_toggle(self, interact_page): + """A movie scrub pushes state every frame; it must not re-show the pill the + viewer just dismissed.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG, FIG)) + plot = ax.imshow(np.zeros((16, 16), np.uint8), cmap="gray", gpu=False) + page = interact_page(fig) + + self._hover(page, plot, 8, 8) + page.keyboard.press("v") + page.evaluate( + """([pid]) => { // a Python-style state push + const key = 'panel_' + pid + '_json'; + const st = JSON.parse(window._aplModel.get(key)); + st.title = 'frame 2'; + window._aplModel.set(key, JSON.stringify(st)); + }""", [plot._id]) + page.wait_for_timeout(80) + assert self._hover(page, plot, 8, 8) is None diff --git a/docs/embedding.rst b/docs/embedding.rst index b90a9ec8f..2aa76b34a 100644 --- a/docs/embedding.rst +++ b/docs/embedding.rst @@ -52,6 +52,9 @@ JavaScript: if (ev.event_type === 'pointer_down') console.log('clicked data coords', ev.xdata, ev.ydata); }, + // 2-D hover readout (position + pixel value) for your own status bar — + // see "Owning the hover readout" below. + onReadout: (info) => { statusEl.textContent = info ? info.text : ''; }, }); // Live updates — replace one panel's state and it re-renders: @@ -168,6 +171,57 @@ JS handle reference Python's :class:`~anyplotlib.Event` carries); ``opts.onSync(key, value)`` receives every outbound model write for bridging to Python. +.. _embed-readout: + +Owning the hover readout +------------------------ + +2-D panels show the cursor's position and pixel value in a small pill drawn on the +image (see :ref:`hover-readout`). In a desktop app you usually want that in your own +chrome instead — a status line pinned to the bottom-right of the window, where it +never covers data. Hide the pill from Python and take the payload from +``opts.onReadout``: + +.. code-block:: python + + plot.set_readout_visible(False) # before figure_state(fig) + +.. code-block:: javascript + + const statusEl = document.getElementById('status-bar'); // your own chrome + + mount(host, state, { + onReadout: (info) => { + // info === null when the cursor leaves the image + statusEl.textContent = info ? info.text : ''; + }, + }); + +The same payload is also dispatched as an ``apl:readout`` :class:`CustomEvent` that +bubbles off the mount container, which is handy when the listener lives somewhere +other than the ``mount()`` call site:: + + host.addEventListener('apl:readout', (e) => render(e.detail)); + +``info`` fields: + +====================== ====================================================== +``panel_id`` Which panel the cursor is over. +``img_x``, ``img_y`` Fractional position in image pixels. +``col``, ``row`` Integer pixel index (``img_x``/``img_y`` floored). +``xdata``, ``ydata`` Physical position in ``units``. +``units`` Axis units string (``"px"`` when unset). +``value`` Pixel value, or ``null`` for a true-colour image. +``exact`` ``true`` when ``value`` is the true datum rather than a + quantised estimate (see :ref:`hover-readout`). +``rgba`` ``[r, g, b, a]`` for a true-colour image, else ``null``. +``text`` The formatted one-line string the built-in pill uses. +====================== ====================================================== + +Updates are deduplicated by ``text``, so a cursor moving inside one pixel does not +call back repeatedly. ``exact`` flips to ``true`` for the same pixel when a value +probe resolves, which fires one more callback — render it, don't ignore it. + Notes and caveats ================= diff --git a/docs/events.rst b/docs/events.rst index c896449c3..8693e03c2 100644 --- a/docs/events.rst +++ b/docs/events.rst @@ -169,6 +169,12 @@ Present on ``pointer_down``, ``pointer_up``, ``pointer_move``, - ``float | None`` - Data-space coordinates. Available on Plot1D, Plot2D, PlotMesh. ``None`` on Plot3D (use ``ray`` instead) and PlotBar. + * - ``img_x``, ``img_y`` + - ``float | None`` + - Plot2D / PlotMesh only: position in **image pixels** (column, row — + row 0 at the top, ``origin`` already applied), so a handler can index the + source array directly without mapping axis units back. ``None`` on other + plot types. * - ``ray`` - ``dict | None`` - Plot3D only: ``{"origin": [x,y,z], "direction": [dx,dy,dz]}``. @@ -181,6 +187,20 @@ Present on ``pointer_down``, ``pointer_up``, ``pointer_move``, - ``float | None`` - ``pointer_settled`` only: actual elapsed dwell time in milliseconds. +.. note:: + + 2-D panels (:class:`~anyplotlib.Plot2D`, :class:`~anyplotlib.PlotMesh`) + already have a built-in readout — see :ref:`hover-readout` — so you rarely + need a handler just to show a value. When you do want one, ``img_x`` / + ``img_y`` index the source array directly: + + .. code-block:: python + + @plot.add_event_handler("pointer_settled", ms=200) + def probe(event): + row, col = int(event.img_y), int(event.img_x) + label.value = f"{data[row, col]:.6g}" + PlotBar additional fields on ``pointer_down`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -246,6 +266,74 @@ Key fields Lets key handlers operate on the most-recently-selected widget. +.. _hover-readout: + +Hover readout (2-D) +------------------- + +Hovering a :class:`~anyplotlib.Plot2D` / :class:`~anyplotlib.PlotMesh` panel shows a +small readout naming the physical position, the pixel index and the **value** of the +pixel under the cursor — no handler required:: + + x:1.2 y:3.4 nm [12, 34] v:5295 # colourmapped image + x:5 y:9 px [5, 9] rgb:10,20,30 # true-colour image (RGBA adds ,a) + +How exact is that value? +~~~~~~~~~~~~~~~~~~~~~~~~ + +Pixels reach the browser as 8-bit codes over the ``raw_min``/``raw_max`` band (that +is what keeps a 4k movie interactive), so the readout works in three tiers: + +.. list-table:: + :header-rows: 1 + :widths: 26 74 + + * - Tier + - When + * - **Exact, no round trip** + - Integer data whose range fits in 255 levels (``uint8`` frames, masks, label + maps, small-range counts): each code maps back to exactly one integer, so the + renderer inverts the quantisation locally. Also true-colour images, whose + channels *are* the transferred bytes. + * - **Exact, via Python** + - Anything else, once the cursor has dwelled ~250 ms on a pixel: the renderer + asks Python for the true value and swaps it in. On by default + (``probe_exact``); costs one small message per dwelled-on pixel, never one + per mouse move. + * - **Quantised estimate** + - The fallback shown immediately, and the final answer when no live kernel can + reply (a :func:`~anyplotlib.save_html` page, a busy kernel). Resolves the + transferred range to ``range/255``. + +Note that ``vmin``/``vmax`` clip the *colourmap*, not the data — a pixel far above +``vmax`` still reads its own value. ``set_data(clim=...)`` is different: it +quantises over the clim itself, so outliers saturate at the band edge. + +.. code-block:: python + + plot = ax.imshow(counts) # probe on by default + plot.set_value_probe(False) # local (quantised) values only + plot.set_value_probe(True, ms=100) # snappier dwell + +Turning it off, and putting it somewhere else +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Pressing **v** over the plot toggles the pill (alongside the other built-ins — +``r`` reset zoom, ``c`` colourbar, ``l``/``s`` log/symlog). The toggle is local to +the viewer, so a live update pushing new frames will not undo it. + +From Python, ``set_readout_visible(False)`` turns the pill off for good — and the +readout keeps being computed, so an embedding host can draw it wherever it likes: a +status line in the corner of an Electron window, for instance, where it covers no +data. + +.. code-block:: python + + plot.set_readout_visible(False) + +The payload then reaches the host through ``mount()``'s ``opts.onReadout`` callback +and an ``apl:readout`` DOM event; see :ref:`embed-readout`. + Per-line filtering on Plot1D ---------------------------- diff --git a/upcoming_changes/+status-pixel-value.new_feature.rst b/upcoming_changes/+status-pixel-value.new_feature.rst new file mode 100644 index 000000000..1d8fa60d0 --- /dev/null +++ b/upcoming_changes/+status-pixel-value.new_feature.rst @@ -0,0 +1,16 @@ +The 2-D hover readout now also names the **value** of the pixel under the +cursor — ``v:`` for a colourmapped image, ``rgb:r,g,b`` for a true-colour +one — alongside the existing physical and pixel coordinates. It is exact: +integer data whose range fits the 256 transferred codes is inverted locally, +and for anything wider the renderer asks Python for the true value once the +cursor dwells on a pixel (``imshow(..., probe_exact=True)`` by default, tunable +via :meth:`~anyplotlib.Plot2D.set_value_probe`), falling back to the quantised +estimate when no kernel can answer. Zoomed into a detail tile the value comes +from the tile's native pixels rather than the coarser overview. +The **v** key toggles the on-image pill, and +:meth:`~anyplotlib.Plot2D.set_readout_visible` turns it off from Python while +keeping the readout live: embedding hosts receive every update through +``mount()``'s ``opts.onReadout`` callback and an ``apl:readout`` DOM event — so +an Electron app can render position and value in its own status bar instead, +where it covers no data. 2-D pointer events also carry ``img_x``/``img_y`` now, +the cursor's position in image pixels. diff --git a/uv.lock b/uv.lock index 55c6e37db..2cc12559b 100644 --- a/uv.lock +++ b/uv.lock @@ -45,7 +45,7 @@ wheels = [ [[package]] name = "anyplotlib" -version = "0.5.0" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "anywidget" },