diff --git a/Examples/PlotTypes/plot_key_overlays.py b/Examples/PlotTypes/plot_key_overlays.py new file mode 100644 index 00000000..35b6602a --- /dev/null +++ b/Examples/PlotTypes/plot_key_overlays.py @@ -0,0 +1,169 @@ +""" +Floating keys — IPF triangles and colour wheels +=============================================== + +Some plots are coloured by a *direction*, not by a magnitude, and a colorbar +cannot say what the colours mean. An orientation map is coloured by which +crystal axis points at you; a polarization map by which way the moment lies in +the plane. Both need a small picture as their legend: an inverse pole figure +triangle, or a hue wheel. + +:meth:`~anyplotlib.Plot2D.add_key` pins that picture over the panel in *screen* +space — it does not pan or zoom with the data, exactly like the scale bar it is +modelled on. + +This is deliberately not :meth:`~anyplotlib.Figure.add_inset`, which is a +draggable window with a title bar and its own canvas stack. That is the right +tool when the overlay is a live plot; a key is a static picture that should +read as part of the figure. +""" +import numpy as np + +import anyplotlib as apl + +# %% +# The IPF colour key +# ------------------ +# The standard cubic stereographic triangle. Each pixel's colour is its +# barycentric distance to the three corners, which is the classic IPF key: a +# grain pointing [1 0 0] at the detector reads red, [1 1 0] green, [1 1 1] blue. +# +# The triangle is built as an ``(H, W, 4)`` RGBA array, and **alpha 0 outside +# the triangle** is what lets it sit on the map without a rectangular card +# around it. + +KEY_N = 220 + + +def ipf_triangle(n=KEY_N): + """RGBA image of the 001–011–111 stereographic triangle.""" + yy, xx = np.mgrid[0:n, 0:n] + u = xx / (n - 1) + v = 1.0 - yy / (n - 1) + inside = v <= u + 1e-9 # lower-right half + + # Barycentric-ish weights: distance to each corner, normalised. + d100 = np.hypot(u, v) # corner (0, 0) + d110 = np.hypot(u - 1.0, v) # corner (1, 0) + d111 = np.hypot(u - 1.0, v - 1.0) # corner (1, 1) + far = np.maximum.reduce([d100, d110, d111]) + rgb = np.stack([1 - d100 / far, 1 - d110 / far, 1 - d111 / far], -1) + rgb /= rgb.max(-1, keepdims=True) + 1e-9 # full saturation at the corners + + img = np.zeros((n, n, 4), np.uint8) + img[..., :3] = np.clip(rgb, 0, 1) * 255 + img[..., 3] = np.where(inside, 255, 0) + return img + + +# %% +# A synthetic orientation map +# --------------------------- +# Voronoi grains, each with a random orientation, coloured through the same key +# so the map and its legend agree by construction. + +rng = np.random.default_rng(11) +H, W, NGRAIN = 210, 280, 40 + +cy, cx = rng.uniform(0, H, NGRAIN), rng.uniform(0, W, NGRAIN) +yy, xx = np.mgrid[0:H, 0:W] +grain = np.hypot(yy[..., None] - cy, xx[..., None] - cx).argmin(-1) + +# Each grain gets a point in the triangle, then reads its colour off the key. +gu = rng.uniform(0, 1, NGRAIN) +gv = rng.uniform(0, 1, NGRAIN) * gu # keep it inside v <= u +key_img = ipf_triangle() +kx = np.clip((gu * (KEY_N - 1)).astype(int), 0, KEY_N - 1) +ky = np.clip(((1 - gv) * (KEY_N - 1)).astype(int), 0, KEY_N - 1) +grain_rgb = key_img[ky, kx, :3] +ipf_map = grain_rgb[grain] # (H, W, 3) true colour + +# %% +# Pinning the key +# --------------- +# ``labels`` draws text *inside* the picture, positioned as fractions of the +# key image, so the corner indices stay on the corners at any ``size``. + +fig, ax = apl.subplots(1, 1, figsize=(520, 420)) +vmap = ax.imshow(ipf_map) +vmap.set_title("orientation map") + +vmap.add_key( + key_img, + corner="bottom-right", + size=0.34, + # `align` keeps a label inside the key: centring text on a corner would + # hang half of it off the edge, where the panel clips it. + labels=[ + {"x": 0.02, "y": 0.93, "text": "[1 0 0]", "align": "left"}, + {"x": 0.98, "y": 0.93, "text": "[1 1 0]", "align": "right"}, + {"x": 0.98, "y": 0.08, "text": "[1 1 1]", "align": "right"}, + ], + name="ipf", +) + +fig + +# %% +# A colour wheel over a polarization map +# -------------------------------------- +# Same mechanism, different legend. Here the key gets a translucent card +# (``bgcolor``) because the field underneath is saturated everywhere and a bare +# wheel would fight with it. + +def hue_wheel(n=KEY_N): + """RGBA colour wheel: hue = in-plane angle, value = magnitude.""" + yy, xx = np.mgrid[0:n, 0:n] + ang = (np.arctan2(-(yy - n / 2), xx - n / 2) + np.pi) / (2 * np.pi) + rad = np.hypot(yy - n / 2, xx - n / 2) / (n / 2) + h6 = ang * 6.0 + chan = np.clip( + np.abs(((h6 + np.array([0, 4, 2])[:, None, None]) % 6) - 3) - 1, 0, 1) + img = np.zeros((n, n, 4), np.uint8) + img[..., :3] = chan.transpose(1, 2, 0) * 255 * np.clip(rad, 0, 1)[..., None] + img[..., 3] = np.where(rad <= 1.0, 255, 0) + return img + + +# A vortex: the moment angle winds once around the centre. +ang = np.arctan2(yy - H / 2, xx - W / 2) +mag = np.clip(np.hypot(yy - H / 2, xx - W / 2) / (0.5 * min(H, W)), 0, 1) +a6 = ((ang + np.pi) / (2 * np.pi)) * 6.0 +chan = np.clip(np.abs(((a6 + np.array([0, 4, 2])[:, None, None]) % 6) - 3) - 1, 0, 1) +polar_map = (chan.transpose(1, 2, 0) * 255 * (0.3 + 0.7 * mag)[..., None]).astype(np.uint8) + +fig2, ax2 = apl.subplots(1, 1, figsize=(520, 420)) +vpol = ax2.imshow(polar_map) +vpol.set_title("in-plane magnetic polarization") + +vpol.add_key( + hue_wheel(), + corner="top-right", + size=0.26, + bgcolor="rgba(0,0,0,0.45)", # legible over a busy field + border="#ffffff", + label="moment direction", + labels=[ + (0.5, 0.06, "N"), (0.94, 0.5, "E"), + (0.5, 0.94, "S"), (0.06, 0.5, "W"), + ], + name="wheel", +) + +fig2 + +# %% +# Keeping it out of the way +# ------------------------- +# ``hover_only=True`` shows the key only while the pointer is over the panel — +# a reading aid that does not sit on the data while you study it. PNG export +# renders the panel as though the pointer were there, so an exported figure +# still carries the key. +# +# Everything is live: :meth:`~anyplotlib.KeyOverlay.set` restyles a key without +# re-sending the picture, and :meth:`~anyplotlib.KeyOverlay.set_image` swaps +# the picture without disturbing the placement:: +# +# key = vmap.get_key("ipf") +# key.set(size=0.4, corner="top-left") +# key.visible = False diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index 3c0d3a07..abb45776 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -1,6 +1,6 @@ # FIGURE_ESM.md — Navigator for `figure_esm.js` -`figure_esm.js` is **~9,300 lines** and one big closure. Everything lives inside +`figure_esm.js` is **~9,470 lines** and one big closure. Everything lives inside `function render({ model, el })` so that all helpers share the same scope (`theme`, `PAD_*`, `panels` Map, etc.). This document is a section map so you can jump straight to the relevant code without reading the whole file. @@ -55,23 +55,24 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | **2D gutter geometry**: `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 301 / 313 / 323 / 333 | | **Layout engine** `applyLayout` | 774 | | `_buildCanvasStack` | 857 | -| `_createPanelDOM` | 989 | -| `_createInsetDOM` / `_applyAllInsetStates` | 1118 / 1500 | -| `_resizePanelDOM` | 2213 | -| **2D drawing**: `_imgFitRect` | 2372 | -| `draw2d` | 2680 | -| `drawScaleBar2d` / `drawColorbar2d` | 2875 / 2961 | -| `_drawAxes2d` (ticks, labels, title) | 3016 | -| `drawOverlay2d` / `drawMarkers2d` | 3169 / 3333 | -| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2500 / 2524 / 2585 | +| `_createPanelDOM` | 999 | +| `_createInsetDOM` / `_applyAllInsetStates` | 1129 / 1512 | +| `_resizePanelDOM` | 2225 | +| **2D drawing**: `_imgFitRect` | 2384 | +| `draw2d` | 2692 | +| `drawScaleBar2d` / `drawColorbar2d` | 2887 / 3125 | +| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 2986 / 3009 / 3022 | +| `_drawAxes2d` (ticks, labels, title) | 3180 | +| `drawOverlay2d` / `drawMarkers2d` | 3333 / 3497 | +| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2512 / 2536 / 2597 | | Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 | -| **3D drawing**: `draw3d` | 5072 | -| Event emission `_emitEvent` | 5909 | -| 3D event handlers `_attachEvents3d` | 5961 | -| **1D drawing**: `draw1d` | 6182 | -| `_drawLine` (1D series + markers) | 6335 | -| `drawOverlay1d` / `drawMarkers1d` | 6628 / 6712 | -| Marker hit-test `_markerHitTest2d` | 6980 | +| **3D drawing**: `draw3d` | 5236 | +| Event emission `_emitEvent` | 6073 | +| 3D event handlers `_attachEvents3d` | 6125 | +| **1D drawing**: `draw1d` | 6346 | +| `_drawLine` (1D series + markers) | 6499 | +| `drawOverlay1d` / `drawMarkers1d` | 6792 / 6876 | +| Marker hit-test `_markerHitTest2d` | 7144 | > **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'` > branch that blits a single RGBA image across data-coord `extent` (the fast @@ -80,16 +81,16 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: > redraws never re-transmit them; the decoded `OffscreenCanvas` is cached on > the marker set (`ms._rasterBmp`/`_rasterKey`). The shared `clip_path` block > clips it to a curved sector. -| Panel event dispatch `_attachPanelEvents` | 7237 | -| 2D events `_attachEvents2d` | 7261 | -| 1D events `_attachEvents1d` | 7645 | -| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 7917 / 8190 | -| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8103 / 8117 / 8146 / 8181 | -| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8313 / 8386 | -| Shared-axis propagation `_getShareGroups` | 8457 | -| Figure resize `_applyFigResizeDOM` | 8521 | -| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8712 / 8775 / 9151 | -| Generic redraw `_redrawPanel` | 9341 | +| Panel event dispatch `_attachPanelEvents` | 7401 | +| 2D events `_attachEvents2d` | 7443 | +| 1D events `_attachEvents1d` | 7827 | +| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8099 / 8372 | +| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8285 / 8299 / 8328 / 8363 | +| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8495 / 8568 | +| Shared-axis propagation `_getShareGroups` | 8639 | +| Figure resize `_applyFigResizeDOM` | 8703 | +| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8894 / 8957 / 9333 | +| Generic redraw `_redrawPanel` | 9523 | > **`brush` widget (2-D)** — the one widget whose drag is *modal*, and the one > that must NOT write the model per tick. `_ovHitTest2d` takes an extra `mods` diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index fed3c613..8e14d32d 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -15,6 +15,7 @@ from anyplotlib.callbacks import CallbackRegistry, Event from anyplotlib import embed from anyplotlib.markers import MarkerRegistry, MarkerGroup +from anyplotlib.keys import KeyOverlay from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, BrushWidget, @@ -43,7 +44,7 @@ def get_color_cycle() -> list[str]: "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", "Line1D", "CallbackRegistry", "Event", - "MarkerRegistry", "MarkerGroup", + "MarkerRegistry", "MarkerGroup", "KeyOverlay", "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget", "BrushWidget", diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py index 15576253..1dfe69ce 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -76,6 +76,172 @@ def set_axis_on(self) -> None: self._state["axis_visible"] = True self._push() + # ------------------------------------------------------------------ + # Floating image keys (see anyplotlib/keys.py) + # ------------------------------------------------------------------ + def add_key(self, image, *, corner: str = "top-right", anchor=None, + size: float = 0.22, margin: float = 10.0, + bgcolor=None, border=None, border_width: float = 1.0, + radius: float = 4.0, alpha: float = 1.0, + hover_only: bool = False, visible: bool = True, + label=None, label_size: float = 10.0, label_color=None, + labels=None, name=None) -> "KeyOverlay": + """Pin a floating image key over this panel. + + A key is a small picture that floats in screen space over the plot + area — it does not pan or zoom with the data. Use it for a colour + legend a colorbar cannot express: an inverse pole figure triangle over + an orientation map, a hue wheel over a polarization field, a phase key + over a segmentation:: + + key = plot.add_key(ipf_triangle, corner="bottom-right", size=0.28) + wheel = plot.add_key(hue_wheel, corner="top-left", + bgcolor="none", hover_only=True) + + This is the lightweight sibling of :meth:`Figure.add_inset`. An inset + is a draggable window with a title bar and its own canvas stack — the + right tool when the overlay is a live plot. A key is a static picture + with no chrome unless you ask for it, so it reads as part of the + figure rather than as a floating panel. + + Parameters + ---------- + image : array-like, bytes, or path + An ``(H, W, 3|4)`` colour array (uint8, or float 0–1), the raw + bytes of a PNG/JPEG/GIF/WebP, or a path to such a file. An RGBA + array is the usual choice: alpha 0 outside the shape lets a + triangle or a disc sit on the image without a rectangular card + around it. + corner : str, optional + ``"top-right"`` (default), ``"top-left"``, ``"bottom-right"`` or + ``"bottom-left"``. Ignored when *anchor* is given. + anchor : (x_frac, y_frac), optional + Free placement: the key's centre as a fraction of the plot area, + from its top-left. Overrides *corner*. + size : float, optional + Width as a fraction of the plot area's **shorter** side, so a key + keeps its proportions when the panel is resized. Default 0.22. + Height follows the image's aspect ratio. + margin : float, optional + Gap in CSS px between the key and the plot-area edge, for corner + placement. Default 10. + bgcolor : str, optional + CSS colour painted behind the image — e.g. ``"rgba(0,0,0,0.45)"`` + for a legible card over busy data. Default ``None`` (fully + transparent); ``"none"`` means the same thing. + border : str, optional + CSS colour for a hairline around the card. Default ``None``. + border_width : float, optional + Border stroke width in px. Default 1. + radius : float, optional + Corner radius of the card in px. Default 4. + alpha : float, optional + Opacity of the whole key, 0–1. Default 1. + hover_only : bool, optional + Show the key only while the pointer is over the panel. Default + ``False``. Useful for a reading aid you do not want sitting over + the data all the time. PNG export renders the panel as though the + pointer were over it, so a hover-only key **is** included in an + exported figure — what you save is what you see while reading. + visible : bool, optional + Draw the key at all. Default ``True``. + label : str, optional + Caption drawn under the image, mini-TeX enabled like axis labels. + label_size : float, optional + Caption size in px. Default 10. + label_color : str, optional + Caption colour. Default ``None`` (the theme's tick-label colour). + labels : list, optional + Text drawn *inside* the key, for annotating the picture itself — + an IPF triangle's corner indices, a wheel's compass points. Each + entry is ``(x, y, text)`` or a dict with those keys plus optional + ``size`` / ``color`` / ``align``. ``x`` and ``y`` are fractions of + the key image, so the text follows the picture when the key is + resized. Mini-TeX works here as in any label:: + + tri.add_key(ipf, labels=[ + (0.02, 0.97, "[1 0 0]"), + (0.98, 0.97, "[1 1 0]"), + (0.98, 0.03, "[1 1 1]"), + ]) + name : str, optional + Handle for :meth:`get_key`. Defaults to the generated id. + + Returns + ------- + KeyOverlay + + See Also + -------- + remove_key, list_keys, get_key + anyplotlib.Figure.add_inset : a full floating axes, for live plots. + """ + from anyplotlib._utils import _image_to_data_url + from anyplotlib.keys import KeyOverlay, _validate + + fields = _validate(dict( + corner=corner, anchor=anchor, size=size, margin=margin, + bgcolor=bgcolor, border=border, border_width=border_width, + radius=radius, alpha=alpha, hover_only=hover_only, + visible=visible, label=label, label_size=label_size, + label_color=label_color, labels=labels)) + key = KeyOverlay(self, _image_to_data_url(image), name=name, **fields) + if any(k.name == key.name for k in self._key_map.values()): + raise ValueError(f"a key named {key.name!r} already exists on this " + "panel; pass a different name=") + self._key_map[key.id] = key + self._push_keys() + return key + + @property + def _key_map(self) -> dict: + """Lazily-created ``{id: KeyOverlay}`` — panels predate this feature.""" + if getattr(self, "_keys_dict", None) is None: + self._keys_dict = {} + return self._keys_dict + + def _push_keys(self) -> None: + """Re-serialise the key list + image table, then push. + + The pictures ride the geometry channel under ``key_images`` so that + restyling a key — or a hover toggle — never re-transmits them. + """ + keys = list(self._key_map.values()) + self._state["keys"] = [k.to_dict() for k in keys] + self._state["key_images"] = {k.id: k.image_url for k in keys} + self._push() + + def get_key(self, name): + """Return the key with this *name* (or id). + + Raises + ------ + KeyError + If no key matches. + """ + for k in self._key_map.values(): + if k.name == name or k.id == name: + return k + raise KeyError(f"no key named {name!r}") + + def remove_key(self, key) -> None: + """Remove a key, by object, name, or id.""" + from anyplotlib.keys import KeyOverlay + kid = key.id if isinstance(key, KeyOverlay) else self.get_key(key).id + if kid not in self._key_map: + raise KeyError(kid) + del self._key_map[kid] + self._push_keys() + + def list_keys(self) -> list: + """Every key on this panel, in creation order.""" + return list(self._key_map.values()) + + def clear_keys(self) -> None: + """Remove every key from this panel.""" + self._key_map.clear() + self._push_keys() + @contextmanager def _python_view_push(self): """Context manager for view setters that must signal _view_from_python. diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index f4516490..44adebd5 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -980,8 +980,18 @@ function render({ model, el, onResize }) { wrapNode = wrap; } + // Floating image keys (Plot.add_key) — ONE canvas per panel holding every + // key, covering the whole panel box, appended last so it sits above the + // scale bar. Built generically for all three kinds: every branch above + // ends with a positioned wrap in `wrapNode`, and a key means the same + // thing over a 2-D map as over a 3-D scatter. + const keyCanvas = document.createElement('canvas'); + keyCanvas.style.cssText = + 'position:absolute;top:0;left:0;pointer-events:none;z-index:7;display:none;'; + if (wrapNode) wrapNode.appendChild(keyCanvas); + return { plotCanvas, overlayCanvas, markersCanvas, statusBar, - xAxisCanvas, yAxisCanvas, scaleBar, + xAxisCanvas, yAxisCanvas, scaleBar, keyCanvas, cbCanvas, cbCtx, plotWrap, wrapNode, titleCanvas, gpuCanvas: stack3dGpuCanvas || stack2dGpuCanvas }; } @@ -1022,6 +1032,7 @@ function render({ model, el, onResize }) { titleCanvas: stack.titleCanvas || null, titleCtx, scaleBar: stack.scaleBar, + keyCanvas: stack.keyCanvas, statusBar: stack.statusBar, statsDiv, frameTimes: [], @@ -1193,6 +1204,7 @@ function render({ model, el, onResize }) { yAxisCanvas: stack.yAxisCanvas, xCtx, yCtx, scaleBar: stack.scaleBar, + keyCanvas: stack.keyCanvas, statusBar: stack.statusBar, statsDiv, frameTimes: [], blitCache, @@ -2958,6 +2970,200 @@ function render({ model, el, onResize }) { sb.style.display='block'; } + // ── Floating image keys (Plot.add_key) ──────────────────────────────────── + // The scale bar's sibling: a picture pinned in SCREEN space over the plot + // area, so it neither pans nor zooms with the data. Every key on a panel + // shares one canvas (p.keyCanvas, z 7). Because that canvas is separate from + // plotCanvas, an ordinary data redraw does NOT erase it — keys are redrawn + // only when the key state, the panel size, or the hover flag changes, which + // is what makes `hover_only` free. + // + // Images decode asynchronously, exactly like a 3-D surface texture: the + // first frame that sees a new URL draws nothing for that key and schedules + // a redraw from onload. + const _keyCache = new Map(); // url -> {img, ready, w, h} + + function _keyEnsure(url, onReady) { + let e = _keyCache.get(url); + if (e) return e.ready ? e : null; + e = { img: null, ready: false, w: 0, h: 0 }; + _keyCache.set(url, e); + const img = new Image(); + img.onload = () => { + e.img = img; + e.w = img.naturalWidth || img.width; + e.h = img.naturalHeight || img.height; + e.ready = e.w > 0 && e.h > 0; + if (e.ready) onReady(); + }; + img.onerror = () => console.warn('[anyplotlib] key image failed to decode'); + img.src = url; + return null; + } + + // The rect keys are placed against — the panel's IMAGE / plot area, the same + // box the scale bar pins itself to (see _resizePanelDOM, which drives the + // scale bar off exactly these four numbers). Deliberately not the letterbox + // fit-rect: a key belongs to the axes box, so it stays put when the data's + // aspect ratio changes, which is what you want while stepping frames. + function _keyRect(p) { + if (p.kind === '2d' && p.imgW && p.imgH) { + return { x: p.imgX || 0, y: p.imgY || 0, w: p.imgW, h: p.imgH }; + } + if (p.kind === '3d') return { x: 0, y: 0, w: p.pw, h: p.ph }; + return { x: PAD_L, y: p._padT || PAD_T, + w: Math.max(1, p.pw - PAD_L - PAD_R), + h: Math.max(1, p.ph - (p._padT || PAD_T) - PAD_B) }; + } + + // `target` overrides p.keyCanvas — used by the export path to re-render the + // keys onto a scratch canvas with hover suppressed. Returns true if it drew + // anything, so the caller can skip compositing an empty canvas. + function drawKeys(p, target) { + const kc = target || p.keyCanvas; if (!kc) return false; + const st = p.state || {}; + const keys = st.keys || []; + const imgs = st.key_images || {}; + // `hover_only` keys are drawn only while the pointer is over the panel. + // p._hover is set by the panel's mouseenter/mouseleave handlers. + // Two filters, and the difference matters. `declared` decides whether the + // canvas EXISTS on screen; `shown` decides what gets painted on it. A panel + // whose only keys are hover_only still keeps a sized, displayed (but empty) + // canvas, because the export path measures against its bounding rect — and + // a display:none canvas measures 0x0, which would silently drop the key. + const declared = keys.filter(k => k && k.visible !== false); + if (!declared.length) { if (!target) kc.style.display = 'none'; return false; } + const shown = declared.filter(k => !k.hover_only || p._hover); + + const pw = p.pw, ph = p.ph; + const W = Math.round(pw * dpr), H = Math.round(ph * dpr); + if (kc.width !== W || kc.height !== H) { kc.width = W; kc.height = H; } + if (!target) { + kc.style.width = pw + 'px'; kc.style.height = ph + 'px'; + kc.style.display = 'block'; + } + const ctx = kc.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, pw, ph); + + // Start decoding EVERY declared key, not just the ones drawn this frame. + // A hover_only key that has never been on screen would otherwise not have + // begun decoding, so the first hover would reveal nothing until a second + // redraw — and an export would silently omit it, since the export path + // renders once with no chance to wait for onload. + for (const k of declared) { + const u = imgs[k.id]; + if (u) _keyEnsure(u, () => drawKeys(p)); + } + + const R = _keyRect(p); + let drew = false; + for (const k of shown) { + const url = imgs[k.id]; if (!url) continue; + const e = _keyEnsure(url, () => drawKeys(p)); + if (!e) continue; // still decoding + drew = true; + + // `size` is a fraction of the SHORTER side, so a key keeps its + // proportions when the panel is resized to any aspect ratio. + const kw = Math.max(1, (k.size != null ? k.size : 0.22) * Math.min(R.w, R.h)); + const kh = kw * (e.h / e.w); + const lblSize = k.label ? (k.label_size || 10) : 0; + const lblGap = k.label ? 3 : 0; + const pad = (k.bgcolor && k.bgcolor !== 'none') || k.border ? 5 : 0; + const cardW = kw + pad * 2; + const cardH = kh + lblSize + lblGap + pad * 2; + + let cx, cy; // card top-left, panel coords + if (k.anchor) { // free placement: anchor = CENTRE + cx = R.x + k.anchor[0] * R.w - cardW / 2; + cy = R.y + k.anchor[1] * R.h - cardH / 2; + } else { + const m = k.margin != null ? k.margin : 10; + const corner = k.corner || 'top-right'; + cx = corner.endsWith('left') ? R.x + m : R.x + R.w - cardW - m; + cy = corner.startsWith('top') ? R.y + m : R.y + R.h - cardH - m; + } + + ctx.save(); + ctx.globalAlpha = k.alpha != null ? k.alpha : 1; + const rad = k.radius != null ? k.radius : 4; + if ((k.bgcolor && k.bgcolor !== 'none') || k.border) { + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(cx, cy, cardW, cardH, rad); + else ctx.rect(cx, cy, cardW, cardH); + if (k.bgcolor && k.bgcolor !== 'none') { + ctx.fillStyle = k.bgcolor; ctx.fill(); + } + if (k.border) { + ctx.strokeStyle = k.border; + ctx.lineWidth = k.border_width != null ? k.border_width : 1; + ctx.stroke(); + } + } + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(e.img, cx + pad, cy + pad, kw, kh); + + // Text drawn INSIDE the picture — an IPF triangle's corner indices, a + // wheel's compass points. Positions are fractions of the image box, so + // they track the picture through a resize. Drawn over the image with a + // thin dark halo, because a key's own colours are arbitrary and there is + // no background colour to key the text off. + for (const L of (k.labels || [])) { + const tx = cx + pad + (L.x || 0) * kw, ty = cy + pad + (L.y || 0) * kh; + const ts = L.size || 9; + ctx.textBaseline = 'middle'; + ctx.lineWidth = 2.5; + ctx.strokeStyle = 'rgba(0,0,0,0.55)'; + ctx.lineJoin = 'round'; + const prevFill = ctx.fillStyle; + // _drawTex strokes nothing, so lay a halo down with the same call + // under a stroke-only style, then paint the glyphs on top. + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + for (const [ox, oy] of [[-1,0],[1,0],[0,-1],[0,1]]) { + _drawTex(ctx, L.text, tx + ox, ty + oy, ts, + { align: L.align || 'center' }); + } + ctx.fillStyle = L.color || '#ffffff'; + _drawTex(ctx, L.text, tx, ty, ts, { align: L.align || 'center' }); + ctx.fillStyle = prevFill; + } + + if (k.label) { + // A caption on a card follows the card, not the figure: the usual card + // is a translucent dark slab, and theme.tickText is near-black under a + // light theme, which would put dark text on a dark pill. With no card + // the label sits on the figure and takes the tick colour as usual. + const onCard = k.bgcolor && k.bgcolor !== 'none'; + ctx.fillStyle = k.label_color || (onCard ? '#f0f0f0' : theme.tickText); + ctx.textBaseline = 'top'; + _drawTex(ctx, k.label, cx + cardW / 2, cy + pad + kh + lblGap, + lblSize, { align: 'center' }); + } + ctx.restore(); + } + return drew; + } + + // Export helper: an exported figure shows EVERY key, `hover_only` included — + // the export renders the panel as though the pointer were over it, so what + // you save is what you would see while reading the plot. The live canvas + // already shows them when the pointer happens to be there; otherwise + // re-render onto a scratch with the flag forced on, mirroring the + // _drawPanelWidgetsNoHandles precedent. Returns null when the live canvas is + // already correct (the common case), undefined when there is nothing to draw. + function _drawPanelKeysForExport(p) { + const st = p.state || {}; + if (p._hover || !(st.keys || []).some(k => k && k.hover_only)) return null; + const scratch = document.createElement('canvas'); + const saved = p._hover; + p._hover = true; + let drew; + try { drew = drawKeys(p, scratch); } finally { p._hover = saved; } + return drew ? scratch : undefined; + } + function drawColorbar2d(p) { const st=p.state; if(!st||!p.cbCanvas||!p.cbCtx) return; const vis=(st.show_colorbar&&!st.is_rgb)||false; // no LUT for RGB images @@ -7240,6 +7446,24 @@ fn fs(in : VsOut) -> @location(0) vec4 { else if (p.kind === 'bar') _attachEventsBar(p); else _attachEvents1d(p); _attachTouch(p); // touch bridge — translates gestures to mouse/wheel + + // Hover flag for `hover_only` keys. Attached HERE rather than inside each + // per-kind handler because it is the one panel behaviour that is identical + // for all four, and it must not depend on a kind remembering to wire it. + // Redraws only the key canvas, never the data — this fires on every + // pointer entry, so it has to stay cheap. + if (p.overlayCanvas) { + p.overlayCanvas.addEventListener('mouseenter', () => { + if (p._hover) return; + p._hover = true; + if ((p.state?.keys || []).some(k => k && k.hover_only)) drawKeys(p); + }); + p.overlayCanvas.addEventListener('mouseleave', () => { + if (!p._hover) return; + p._hover = false; + if ((p.state?.keys || []).some(k => k && k.hover_only)) drawKeys(p); + }); + } } function _canvasToImg2d(px, py, st, pw, ph) { @@ -9344,6 +9568,10 @@ fn fs(in : VsOut) -> @location(0) vec4 { else if(p.kind==='3d') draw3d(p); else if(p.kind==='bar') drawBar(p); else draw1d(p); + // Keys live on their own canvas, so a data redraw does not erase them — + // but the panel may have resized or the key state changed, and this is + // the one path every state change and resize funnels through. + drawKeys(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 @@ -9495,6 +9723,14 @@ fn fs(in : VsOut) -> @location(0) vec4 { } _drawEl(p.markersCanvas); // z 6 — static marker groups _drawEl(p.scaleBar); // z 7 — physical scale bar + // z 7 — floating image keys, `hover_only` ones included: the export + // renders as though the pointer were over the panel. When one is not + // currently on screen the scratch re-render below stands in for the live + // canvas (null = live canvas already correct, undefined = nothing to + // draw at all). + const keyScratch = _drawPanelKeysForExport(p); + if (keyScratch === null) _drawEl(p.keyCanvas); + else if (keyScratch) _drawEl(keyScratch, p.keyCanvas); _drawEl(p.titleCanvas); // z 8 — 2-D title strip // (statusBar / statsDiv are hover chrome — intentionally excluded.) }; diff --git a/anyplotlib/keys.py b/anyplotlib/keys.py new file mode 100644 index 00000000..4de24ba8 --- /dev/null +++ b/anyplotlib/keys.py @@ -0,0 +1,252 @@ +""" +keys.py +======= +Floating image *keys* — a colour legend pinned over a panel. + +A key is the scale bar's sibling: a small picture that floats in a corner of +the plot area, in screen space, and does not pan or zoom with the data. It is +what you reach for when the colours in a plot mean something a colorbar cannot +say — an inverse pole figure triangle over an orientation map, a hue wheel +over a polarization vector field, a phase legend over a segmentation. + +Deliberately *not* an inset axes. :meth:`Figure.add_inset` gives you a +draggable window with a title bar, a border and a full canvas stack — the +right thing when the overlay is itself a plot you want to interact with, and +much too heavy when it is a static picture that should read as part of the +figure. A key has no chrome unless you ask for it, no event wiring, and one +canvas shared by every key on the panel. + +See :meth:`anyplotlib._base_plot._BasePlot.add_key`. +""" + +from __future__ import annotations + +import uuid as _uuid + +CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right") + + +class KeyOverlay: + """A floating image key pinned to a panel corner. + + Created by :meth:`~anyplotlib.Plot2D.add_key`; not constructed directly. + + Every property is settable through :meth:`set`, which pushes to the + renderer in one update:: + + key = plot.add_key(ipf_triangle, corner="bottom-right") + key.set(size=0.3, bgcolor="none") + key.visible = False # plain attribute assignment also pushes + + Attributes + ---------- + id : str + Short unique identifier. + name : str + Caller-supplied name, or the id when none was given. Use it with + :meth:`~anyplotlib.Plot2D.get_key`. + """ + + #: Fields that live on the light view channel. The image itself does NOT + #: appear here — it rides the geometry channel keyed by id, so restyling a + #: key (or toggling `visible`) never re-transmits the picture. + _FIELDS = ( + "corner", "anchor", "size", "margin", "bgcolor", "border", + "border_width", "radius", "alpha", "hover_only", "visible", + "label", "label_size", "label_color", "labels", + ) + + def __init__(self, plot, image_url: str, *, name=None, **kwargs): + self._id: str = str(_uuid.uuid4())[:8] + self._plot = plot + self._url: str = image_url + self._name: str = str(name) if name is not None else self._id + self._data: dict = {"id": self._id} + self._data.update(kwargs) + + # ── identity ────────────────────────────────────────────────────── + + @property + def id(self) -> str: + return self._id + + @property + def name(self) -> str: + return self._name + + # ── attribute access ────────────────────────────────────────────── + + def __getattr__(self, key: str): + if key.startswith("_"): + raise AttributeError(key) + try: + return self._data[key] + except KeyError: + raise AttributeError( + f"{type(self).__name__!s} has no property {key!r}") from None + + def __setattr__(self, key: str, value) -> None: + if key.startswith("_") or key in ("id", "name"): + object.__setattr__(self, key, value) + elif key in self._FIELDS: + self.set(**{key: value}) + else: + raise AttributeError( + f"{type(self).__name__!s} has no property {key!r}") + + # ── mutation ────────────────────────────────────────────────────── + + def set(self, **kwargs) -> None: + """Update one or more properties and push once. + + Accepts any of the constructor's keyword arguments except ``image`` + (use :meth:`set_image` — it travels on a different channel). + + Raises + ------ + ValueError + On an unknown property name, or a value the constructor would + also have rejected. + """ + unknown = set(kwargs) - set(self._FIELDS) + if unknown: + raise ValueError( + f"unknown key property {sorted(unknown)!r}; " + f"valid: {sorted(self._FIELDS)!r}") + self._data.update(_validate(kwargs)) + self._sync() + + def set_image(self, image) -> None: + """Replace the picture, keeping placement and styling. + + Parameters + ---------- + image : array-like, bytes, or path + Same input :meth:`~anyplotlib.Plot2D.add_key` accepts. + """ + from anyplotlib._utils import _image_to_data_url + self._url = _image_to_data_url(image) + self._sync() + + def remove(self) -> None: + """Remove this key from its panel.""" + if self._plot is not None: + self._plot.remove_key(self) + + # ── wire format ─────────────────────────────────────────────────── + + def to_dict(self) -> dict: + """The light view-channel payload (no image bytes).""" + return dict(self._data) + + @property + def image_url(self) -> str: + """The ``data:`` URL the renderer decodes.""" + return self._url + + def _sync(self) -> None: + if self._plot is not None: + self._plot._push_keys() + + def __repr__(self) -> str: # pragma: no cover + where = (f"anchor={self._data['anchor']}" + if self._data.get("anchor") else + f"corner={self._data.get('corner')!r}") + return (f"") + + +def _validate(kw: dict) -> dict: + """Coerce and range-check the subset of key properties present in *kw*.""" + out = dict(kw) + + if "corner" in out and out["corner"] is not None: + c = str(out["corner"]).lower() + if c not in CORNERS: + raise ValueError( + f"corner must be one of {list(CORNERS)}, got {out['corner']!r}") + out["corner"] = c + + if out.get("anchor") is not None: + a = out["anchor"] + if len(a) != 2: + raise ValueError(f"anchor must be (x_frac, y_frac), got {a!r}") + out["anchor"] = [float(a[0]), float(a[1])] + + if "size" in out: + s = float(out["size"]) + if not 0.0 < s <= 1.0: + raise ValueError( + f"size is a fraction of the plot area's shorter side and must " + f"be in (0, 1], got {s}") + out["size"] = s + + if "alpha" in out: + a = float(out["alpha"]) + if not 0.0 <= a <= 1.0: + raise ValueError(f"alpha must be in [0, 1], got {a}") + out["alpha"] = a + + for k in ("margin", "border_width", "radius", "label_size"): + if k in out and out[k] is not None: + v = float(out[k]) + if v < 0: + raise ValueError(f"{k} must be >= 0, got {v}") + out[k] = v + + for k in ("hover_only", "visible"): + if k in out: + out[k] = bool(out[k]) + + for k in ("bgcolor", "border", "label_color", "label"): + if k in out and out[k] is not None: + out[k] = str(out[k]) + + if "labels" in out: + out["labels"] = _coerce_labels(out["labels"]) + + return out + + +def _coerce_labels(labels) -> list: + """Normalise in-image text annotations to plain JSON-able dicts. + + Accepts ``(x, y, text)`` triples or dicts with the same keys plus optional + ``size`` / ``color`` / ``align``. ``x``/``y`` are fractions of the key + IMAGE box, so they follow the picture when the key is resized — which is + the point: an IPF triangle's corner labels have to stay on the corners. + """ + if labels is None: + return [] + out = [] + for i, item in enumerate(labels): + if isinstance(item, dict): + d = dict(item) + else: + seq = list(item) + if len(seq) != 3: + raise ValueError( + f"labels[{i}]: expected (x, y, text) or a dict, got " + f"{len(seq)} values") + d = {"x": seq[0], "y": seq[1], "text": seq[2]} + missing = {"x", "y", "text"} - set(d) + if missing: + raise ValueError(f"labels[{i}] is missing {sorted(missing)!r}") + unknown = set(d) - {"x", "y", "text", "size", "color", "align"} + if unknown: + raise ValueError( + f"labels[{i}]: unknown key(s) {sorted(unknown)!r}; valid: " + "x, y, text, size, color, align") + if d.get("align") not in (None, "left", "center", "right"): + raise ValueError( + f"labels[{i}]: align must be 'left', 'center' or 'right', " + f"got {d['align']!r}") + entry = {"x": float(d["x"]), "y": float(d["y"]), "text": str(d["text"])} + if d.get("size") is not None: + entry["size"] = float(d["size"]) + if d.get("color") is not None: + entry["color"] = str(d["color"]) + if d.get("align") is not None: + entry["align"] = d["align"] + out.append(entry) + return out diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 4ab50fdf..32d1d9f9 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -71,7 +71,7 @@ class Plot2D(_BasePlot, _PanelMixin, _MarkerMixin): #: 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"}) + "detail_b64", "key_images"}) @property def _GEOM_KEYS(self) -> frozenset: diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 0ba10e79..32771005 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -200,7 +200,7 @@ class Plot3D(_BasePlot): #: (highlight / camera / planes) never re-transmit them. _GEOM_KEYS = frozenset({ "vertices_b64", "faces_b64", "z_values_b64", "point_colors_b64", - "colormap_data", "texture_url", "texture_uv_b64", + "colormap_data", "texture_url", "texture_uv_b64", "key_images", }) def __init__(self, geom_type: str, diff --git a/anyplotlib/plotxy/_plotxy.py b/anyplotlib/plotxy/_plotxy.py index 7714e4a1..a28e2ca3 100644 --- a/anyplotlib/plotxy/_plotxy.py +++ b/anyplotlib/plotxy/_plotxy.py @@ -80,7 +80,7 @@ class PlotXY(Plot1D): #: ``pcolormesh`` raster (keyed by marker-set id), so re-aiming the view #: never re-transmits the image. Declared here rather than on Plot1D so #: plain line/bar panels keep the single-trait path (no empty geom trait). - _GEOM_KEYS = frozenset({"raster_geom"}) + _GEOM_KEYS = frozenset({"raster_geom", "key_images"}) def __init__(self, *, xlim=(0.0, 1.0), ylim=(0.0, 1.0), aspect=None, units: str = "", y_units: str = ""): diff --git a/anyplotlib/tests/conftest.py b/anyplotlib/tests/conftest.py index 0b693f57..7eb0b5c8 100644 --- a/anyplotlib/tests/conftest.py +++ b/anyplotlib/tests/conftest.py @@ -646,3 +646,74 @@ def _run_bench(page, panel_id, *, n_warmup=3, n_samples=15, finally: page.set_default_timeout(30_000) # restore Playwright default + + +# --------------------------------------------------------------------------- +# Mount-API page fixture +# --------------------------------------------------------------------------- +# `interact_page` above calls renderFn({model, el}) directly, which is right for +# driving widgets but exposes no mount handle — so no exportPNG and no panels +# map. Tests that need both a live pointer AND the public mount API use this. +# test_embed/test_export_png.py and test_plot3d/test_texture.py each predate it +# and keep their own local copies; new tests should use this one. + +_MOUNT_PAGE_HTML = """ + + +
+ +""" + + +@pytest.fixture +def mount_page(_pw_browser): + """Open a figure through the public ``mount()`` API; return the live Page. + + The page exposes ``window._handle`` — ``exportPNG(opts)`` and ``api.panels`` + — and accepts ordinary Playwright mouse events, so one fixture covers + "render it, point at it, export it". + """ + import json as _json + import pathlib as _pathlib + import tempfile as _tempfile + + from anyplotlib.embed import esm_path, figure_state + + pages, paths = [], [] + + def _open(fig): + html = (_MOUNT_PAGE_HTML + .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 f in paths: + f.unlink(missing_ok=True) diff --git a/anyplotlib/tests/test_keys/test_keys.py b/anyplotlib/tests/test_keys/test_keys.py new file mode 100644 index 00000000..a935f092 --- /dev/null +++ b/anyplotlib/tests/test_keys/test_keys.py @@ -0,0 +1,413 @@ +""" +tests/test_keys/test_keys.py +============================ + +Floating image keys (``Plot.add_key``) — see ``anyplotlib/keys.py``. + +Covers: + * lifecycle and validation on the Python side + * the picture riding the geometry channel, separate from the styling + * Playwright: corner and anchor placement, the card, ``hover_only`` + show/hide, and the two decode/measure traps that made a hover-only key + vanish from an export +""" +from __future__ import annotations + +import base64 + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.keys import KeyOverlay +from anyplotlib.tests._png_utils import decode_png + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +FIG_W, FIG_H, IMG = 400, 340, 64 + + +def _disc(n=96, rgb=(255, 40, 40)): + """An RGBA disc: alpha 0 outside, so a bare key must draw NO square card.""" + yy, xx = np.mgrid[0:n, 0:n] + r = np.hypot(yy - n / 2, xx - n / 2) / (n / 2) + a = np.zeros((n, n, 4), np.uint8) + a[..., 0], a[..., 1], a[..., 2] = rgb + a[..., 3] = np.where(r <= 1.0, 255, 0) + return a + + +def _panel(): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + return fig, ax.imshow(np.zeros((IMG, IMG), np.float32)) + + +def _export(page, scale=1): + url = page.evaluate( + "(s) => window._handle.exportPNG({scale: s}).then(r => r.dataUrl)", scale) + return decode_png(base64.b64decode(url.split(",", 1)[1])).astype(int) + + +def _mask(arr, rgb, tol=70): + """Pixels close to *rgb* — locates a key by its colour.""" + d = np.abs(arr[..., :3] - np.asarray(rgb)).max(2) + return d < tol + + +def _centroid(arr, rgb): + m = _mask(arr, rgb) + ys, xs = np.nonzero(m) + assert len(ys), f"no {rgb} pixels found" + return xs.mean(), ys.mean(), int(m.sum()) + + +def _hover(page, panel_id): + box = page.evaluate("""(pid) => { + const r = window._handle.api.panels.get(pid) + .overlayCanvas.getBoundingClientRect(); + return {x: r.x + r.width / 2, y: r.y + r.height / 2}; + }""", panel_id) + page.mouse.move(box["x"], box["y"]) + page.wait_for_timeout(200) + + +# --------------------------------------------------------------------------- +# Python side +# --------------------------------------------------------------------------- + +class TestApi: + def test_add_key_returns_a_handle_and_registers_it(self): + _, p = _panel() + k = p.add_key(_disc(), name="ipf") + assert isinstance(k, KeyOverlay) + assert p.list_keys() == [k] + assert p.get_key("ipf") is k and p.get_key(k.id) is k + + def test_defaults(self): + _, p = _panel() + k = p.add_key(_disc()) + d = p._state["keys"][0] + assert d["corner"] == "top-right" and d["anchor"] is None + assert d["size"] == pytest.approx(0.22) + assert d["bgcolor"] is None and d["hover_only"] is False + assert d["visible"] is True + assert k.name == k.id # name defaults to the id + + def test_the_picture_rides_the_geometry_channel(self): + """Styling is light; the image is heavy and must not re-transmit.""" + _, p = _panel() + k = p.add_key(_disc()) + assert "key_images" in p._GEOM_KEYS + assert p._state["key_images"][k.id].startswith("data:image/png;base64,") + # …and does NOT appear in the per-key view payload. + assert not any("url" in key or "image" in key + for key in p._state["keys"][0]) + + def test_set_updates_and_pushes(self): + _, p = _panel() + k = p.add_key(_disc()) + k.set(size=0.4, bgcolor="none", corner="bottom-left") + d = p._state["keys"][0] + assert (d["size"], d["bgcolor"], d["corner"]) == (0.4, "none", "bottom-left") + + def test_attribute_assignment_pushes(self): + _, p = _panel() + k = p.add_key(_disc()) + k.visible = False + assert p._state["keys"][0]["visible"] is False + assert k.visible is False + + def test_set_image_swaps_the_picture_only(self): + _, p = _panel() + k = p.add_key(_disc(), size=0.4) + before = p._state["key_images"][k.id] + k.set_image(_disc(rgb=(0, 0, 255))) + assert p._state["key_images"][k.id] != before + assert p._state["keys"][0]["size"] == pytest.approx(0.4) + + def test_remove_by_object_name_and_handle(self): + _, p = _panel() + a = p.add_key(_disc(), name="a") + b = p.add_key(_disc(), name="b") + c = p.add_key(_disc(), name="c") + p.remove_key(a) # by object + p.remove_key("b") # by name + c.remove() # via the handle + assert p.list_keys() == [] + assert p._state["keys"] == [] and p._state["key_images"] == {} + + def test_clear_keys(self): + _, p = _panel() + p.add_key(_disc()); p.add_key(_disc()) + p.clear_keys() + assert p.list_keys() == [] + + def test_every_panel_kind_takes_a_key(self): + """A colour wheel means the same thing over a map and a scatter.""" + fig, ax = apl.subplots(1, 1) + g = np.linspace(0, 1, 4) + X, Y = np.meshgrid(g, g) + for plot in (ax.imshow(np.zeros((8, 8), np.float32)), + ax.plot_surface(X, Y, np.zeros((4, 4))), + ax.plot(np.zeros(8))): + plot.add_key(_disc()) + assert len(plot._state["keys"]) == 1 + + +class TestValidation: + @pytest.mark.parametrize("size", [0, -0.1, 1.5]) + def test_size_out_of_range_raises(self, size): + _, p = _panel() + with pytest.raises(ValueError, match="size is a fraction"): + p.add_key(_disc(), size=size) + + @pytest.mark.parametrize("alpha", [-0.1, 1.5]) + def test_alpha_out_of_range_raises(self, alpha): + _, p = _panel() + with pytest.raises(ValueError, match=r"alpha must be in \[0, 1\]"): + p.add_key(_disc(), alpha=alpha) + + def test_unknown_corner_raises(self): + _, p = _panel() + with pytest.raises(ValueError, match="corner must be one of"): + p.add_key(_disc(), corner="middle") + + def test_bad_anchor_raises(self): + _, p = _panel() + with pytest.raises(ValueError, match="anchor must be"): + p.add_key(_disc(), anchor=(0.5, 0.5, 0.5)) + + def test_duplicate_name_raises(self): + _, p = _panel() + p.add_key(_disc(), name="ipf") + with pytest.raises(ValueError, match="already exists"): + p.add_key(_disc(), name="ipf") + + def test_unknown_property_raises(self): + _, p = _panel() + k = p.add_key(_disc()) + with pytest.raises(ValueError, match="unknown key property"): + k.set(colour="red") + + def test_get_missing_key_raises(self): + _, p = _panel() + with pytest.raises(KeyError): + p.get_key("nope") + + +# --------------------------------------------------------------------------- +# Rendering — the placement and hover logic lives in figure_esm.js, so a +# Python-only test proves nothing about what is actually drawn. +# --------------------------------------------------------------------------- + +RED, BLUE = (255, 40, 40), (40, 40, 255) + + +@pytest.mark.usefixtures("mount_page") +class TestRender: + def test_a_bare_key_draws_no_card(self, mount_page): + """An RGBA disc must land as a disc — alpha 0 stays transparent.""" + fig, p = _panel() + p.add_key(_disc(), corner="top-right", size=0.3) + arr = _export(mount_page(fig)) + cx, cy, n = _centroid(arr, RED) + assert n > 400, "the key did not draw" + # A filled square would be 4/pi times the disc's area; allow slack for + # antialiasing but not enough to admit a card. + side = 0.3 * min(p._state["image_width"], p._state["image_height"]) + assert n < 400 * 400, "far too many pixels — a card was drawn" + + @pytest.mark.parametrize("corner,right,bottom", [ + ("top-left", False, False), ("top-right", True, False), + ("bottom-left", False, True), ("bottom-right", True, True), + ]) + def test_each_corner_places_the_key(self, mount_page, corner, + right, bottom): + fig, p = _panel() + p.add_key(_disc(), corner=corner, size=0.2) + arr = _export(mount_page(fig)) + cx, cy, _ = _centroid(arr, RED) + h, w = arr.shape[:2] + # bool(): the comparison yields np.bool_, which is never `is True`. + assert bool(cx > w / 2) is right, f"{corner}: wrong horizontal half" + assert bool(cy > h / 2) is bottom, f"{corner}: wrong vertical half" + + def test_anchor_overrides_corner(self, mount_page): + fig, p = _panel() + p.add_key(_disc(), corner="top-right", anchor=(0.5, 0.5), size=0.2) + arr = _export(mount_page(fig)) + cx, cy, _ = _centroid(arr, RED) + h, w = arr.shape[:2] + assert abs(cx - w / 2) < w * 0.1 and abs(cy - h / 2) < h * 0.1 + + def test_size_scales_the_key(self, mount_page): + fig, p = _panel() + p.add_key(_disc(), size=0.2, name="small") + small = _centroid(_export(mount_page(fig)), RED)[2] + + fig2, p2 = _panel() + p2.add_key(_disc(), size=0.4, name="big") + big = _centroid(_export(mount_page(fig2)), RED)[2] + # Area goes as size²; 0.4 vs 0.2 is ~4x. + assert 3.0 < big / small < 5.0, f"{small} → {big}" + + def test_keys_pin_to_the_axes_box_not_the_letterbox(self, mount_page): + """A key belongs to the axes box, like the scale bar. + + The panel is much wider than the square image, so the letterboxed + picture is far narrower than the axes box. A top-left key must sit at + the axes-box edge, not at the picture's left edge. + """ + fig, ax = apl.subplots(1, 1, figsize=(560, 260)) + p = ax.imshow(np.zeros((IMG, IMG), np.float32)) + p.add_key(_disc(), corner="top-left", size=0.15) + page = mount_page(fig) + arr = _export(page) + cx, _, _ = _centroid(arr, RED) + box = page.evaluate("""(pid) => { + const p = window._handle.api.panels.get(pid); + return {imgX: p.imgX, imgW: p.imgW}; + }""", p._id) + # The key's left edge tracks imgX (+ margin), well left of where the + # centred square picture starts. + assert cx < box["imgX"] + box["imgW"] * 0.35, ( + f"key centroid {cx:.0f} is not near the axes-box left edge " + f"(imgX={box['imgX']})") + + +@pytest.mark.usefixtures("mount_page") +class TestHoverOnly: + def test_hidden_until_the_pointer_arrives(self, mount_page): + fig, p = _panel() + p.add_key(_disc(), hover_only=True, size=0.3) + page = mount_page(fig) + shown = lambda: page.evaluate("""(pid) => { + const kc = window._handle.api.panels.get(pid).keyCanvas; + const c = kc.getContext('2d'); + const d = c.getImageData(0, 0, kc.width, kc.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) if (d[i] > 10) n++; + return n; + }""", p._id) + assert shown() == 0, "hover-only key drew before the pointer arrived" + _hover(page, p._id) + assert shown() > 400, "hover-only key did not appear on hover" + + def test_a_plain_key_is_unaffected_by_hover(self, mount_page): + fig, p = _panel() + p.add_key(_disc(), size=0.3) + page = mount_page(fig) + before = _centroid(_export(page), RED)[2] + _hover(page, p._id) + assert _centroid(_export(page), RED)[2] == before + + +@pytest.mark.usefixtures("mount_page") +class TestExport: + """A saved figure shows every key, hover_only included. + + Both cases below are regressions. Images were once decoded lazily from + inside the DRAW loop, so a hover-only key that had never been on screen had + never begun decoding and the export silently omitted it; and a panel whose + only keys are hover-only kept a display:none canvas, which the export path + measures at 0x0 and drops. + """ + + def test_hover_only_key_is_exported_cold(self, mount_page): + fig, p = _panel() + p.add_key(_disc(rgb=BLUE), corner="bottom-left", size=0.25) + p.add_key(_disc(), corner="top-right", size=0.25, hover_only=True) + page = mount_page(fig) + cold = _export(page) # pointer never entered + assert _centroid(cold, RED)[2] > 400, "hover-only key missing from export" + assert _centroid(cold, BLUE)[2] > 400 + + def test_export_is_identical_cold_and_hovered(self, mount_page): + fig, p = _panel() + p.add_key(_disc(rgb=BLUE), corner="bottom-left", size=0.25) + p.add_key(_disc(), corner="top-right", size=0.25, hover_only=True) + page = mount_page(fig) + cold = _export(page) + _hover(page, p._id) + assert np.array_equal(cold, _export(page)) + + def test_a_lone_hover_only_key_still_exports(self, mount_page): + """The display:none / 0x0-rect trap: no other key keeps the canvas up.""" + fig, p = _panel() + p.add_key(_disc(), corner="top-right", size=0.25, hover_only=True) + assert _centroid(_export(mount_page(fig)), RED)[2] > 400 + + def test_an_invisible_key_is_not_exported(self, mount_page): + fig, p = _panel() + k = p.add_key(_disc(), size=0.3) + k.visible = False + arr = _export(mount_page(fig)) + assert not _mask(arr, RED).any() + + +class TestLabels: + """Text drawn inside the key — an IPF triangle's corner indices.""" + + def test_tuples_and_dicts_both_coerce(self): + _, p = _panel() + k = p.add_key(_disc(), labels=[ + (0.1, 0.2, "[1 0 0]"), + {"x": 0.9, "y": 0.2, "text": "[1 1 0]", "align": "right", + "size": 8, "color": "#fff"}, + ]) + got = p._state["keys"][0]["labels"] + assert got[0] == {"x": 0.1, "y": 0.2, "text": "[1 0 0]"} + assert got[1]["align"] == "right" and got[1]["size"] == 8.0 + + def test_missing_key_raises(self): + _, p = _panel() + with pytest.raises(ValueError, match=r"missing \['text'\]"): + p.add_key(_disc(), labels=[{"x": 0.1, "y": 0.2}]) + + def test_wrong_tuple_length_raises(self): + _, p = _panel() + with pytest.raises(ValueError, match=r"expected \(x, y, text\)"): + p.add_key(_disc(), labels=[(0.1, 0.2)]) + + def test_unknown_label_key_raises(self): + _, p = _panel() + with pytest.raises(ValueError, match="unknown key"): + p.add_key(_disc(), labels=[{"x": 0, "y": 0, "text": "a", "bold": 1}]) + + def test_bad_align_raises(self): + _, p = _panel() + with pytest.raises(ValueError, match="align must be"): + p.add_key(_disc(), labels=[{"x": 0, "y": 0, "text": "a", + "align": "middle"}]) + + def _glyph_px(self, arr): + """Non-red (glyph) pixels well INSIDE the disc. + + The red mask's bounding box also contains the four background corners + outside the circle, which are near-white themselves — cropping to the + central 60% keeps the measurement on the key's own colours. + """ + ys, xs = np.nonzero(_mask(arr, RED)) + h0, h1, w0, w1 = ys.min(), ys.max(), xs.min(), xs.max() + dy, dx = (h1 - h0) * 0.2, (w1 - w0) * 0.2 + sub = arr[int(h0 + dy):int(h1 - dy), int(w0 + dx):int(w1 - dx), :3] + # The disc is pure red, so any pixel with strong green AND blue is + # glyph, not key. Cheaper and far less brittle than an exact white. + return int(((sub[..., 1] > 140) & (sub[..., 2] > 140)).sum()) + + def test_labels_are_drawn_inside_the_key(self, mount_page): + """White glyphs with a dark halo must appear over the key's colours.""" + fig, p = _panel() + p.add_key(_disc(), corner="top-right", size=0.4, + labels=[{"x": 0.5, "y": 0.5, "text": "[1 0 0]"}]) + n = self._glyph_px(_export(mount_page(fig), scale=2)) + assert n > 30, f"no label glyphs found inside the key ({n} px)" + + def test_no_labels_means_no_glyphs(self, mount_page): + """Control: the same crop must be free of white without labels.""" + fig, p = _panel() + p.add_key(_disc(), corner="top-right", size=0.4) + assert self._glyph_px(_export(mount_page(fig), scale=2)) == 0 diff --git a/docs/api/index.rst b/docs/api/index.rst index 266d80a5..a48ec206 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -13,6 +13,7 @@ directly to a class or function. figure figure_plots markers + keys widgets callbacks diff --git a/docs/api/keys.rst b/docs/api/keys.rst new file mode 100644 index 00000000..be78e353 --- /dev/null +++ b/docs/api/keys.rst @@ -0,0 +1,42 @@ +==== +Keys +==== + +.. automodule:: anyplotlib.keys + :no-members: + :no-undoc-members: + +.. currentmodule:: anyplotlib.keys + +.. rubric:: Classes + +.. autosummary:: + :nosignatures: + + KeyOverlay + +.. rubric:: Full Reference + +.. autoclass:: anyplotlib.keys.KeyOverlay + :members: + :show-inheritance: + :member-order: bysource + +.. rubric:: Creating a key + +Keys are created from a panel, not constructed directly: + +.. automethod:: anyplotlib._base_plot._BasePlot.add_key + :no-index: + +.. automethod:: anyplotlib._base_plot._BasePlot.get_key + :no-index: + +.. automethod:: anyplotlib._base_plot._BasePlot.remove_key + :no-index: + +.. automethod:: anyplotlib._base_plot._BasePlot.list_keys + :no-index: + +.. automethod:: anyplotlib._base_plot._BasePlot.clear_keys + :no-index: diff --git a/upcoming_changes/+plot_keys.new_feature.rst b/upcoming_changes/+plot_keys.new_feature.rst new file mode 100644 index 00000000..15055b12 --- /dev/null +++ b/upcoming_changes/+plot_keys.new_feature.rst @@ -0,0 +1,10 @@ +Added :meth:`~anyplotlib.Plot2D.add_key` for pinning a floating image *key* over +a panel — an inverse pole figure triangle over an orientation map, a hue wheel +over a polarization field, a phase key over a segmentation. A key is the scale +bar's sibling: it floats in screen space and neither pans nor zooms with the +data, it takes an RGBA image so a triangle or a disc needs no rectangular card +around it, and ``labels=`` annotates the picture itself (an IPF triangle's +corner indices) in fractions of the key image. Optional ``bgcolor`` / +``border`` / ``alpha`` give it a card when the data underneath is busy, and +``hover_only=True`` reveals it only while the pointer is over the panel. +Available on every panel type, and included in PNG export.