From fd3f611cebbecbbd7bd208333343823a9e14b56b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 30 Jul 2026 23:42:20 -0500 Subject: [PATCH 1/6] feat(keys): floating image keys pinned over a panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A key is the scale bar's sibling: a small picture that floats in screen space over the plot area and does not pan or zoom with the data. It is 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. Deliberately NOT an inset axes. Figure.add_inset gives a draggable window with a title bar, a theme border and a full canvas stack; Python exposes no way to turn any of that off. That is right when the overlay is a live plot you 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 asked for, no event wiring, and (once the renderer lands) one canvas shared by every key on the panel. Lives on _BasePlot, so every panel type has it — the same picture makes sense over a 2-D map and a 3-D scatter. The picture and the styling travel on separate channels. `keys` carries placement and style on the light view trait; `key_images` carries the data: URLs and is declared in _GEOM_KEYS wherever the panel class has one, so restyling a key — or a hover toggle, once that is wired — never re-transmits the image. Plot1D has no geom trait by design, and needs no special case: `key_images` simply rides its single trait as any other state field does. This is the Python layer only: state, validation and lifecycle. The renderer does not draw keys yet, so add_key currently has no visible effect; the JS follows in the next commit. --- anyplotlib/__init__.py | 3 +- anyplotlib/_base_plot.py | 153 ++++++++++++++++++++++++++ anyplotlib/keys.py | 205 +++++++++++++++++++++++++++++++++++ anyplotlib/plot2d/_plot2d.py | 2 +- anyplotlib/plot3d/_plot3d.py | 2 +- anyplotlib/plotxy/_plotxy.py | 2 +- 6 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 anyplotlib/keys.py 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..11b05efe 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -76,6 +76,159 @@ 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, + 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 when the key is informative but you do not want + it in an exported figure or in the way while reading the data. + Note that a hover-only key is **omitted from PNG export**, since + export runs with no pointer over the panel. + 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). + 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)) + 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/keys.py b/anyplotlib/keys.py new file mode 100644 index 00000000..b75e5f73 --- /dev/null +++ b/anyplotlib/keys.py @@ -0,0 +1,205 @@ +""" +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", + ) + + 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]) + + 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 = ""): From bc2c6745de968aaa68ddb3dd3b36a410f1580a09 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 31 Jul 2026 07:06:50 -0500 Subject: [PATCH 2/6] feat(keys): render floating keys, excluded from export when hover-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer half of add_key. Every key on a panel shares ONE canvas (p.keyCanvas, z 7, built generically in _buildCanvasStack for all three panel kinds — each branch already ends with a positioned wrap). Because that canvas is separate from plotCanvas, an ordinary data redraw does not erase it. Keys are therefore redrawn only when the key state, the panel size, or the hover flag changes, which is what makes `hover_only` cheap enough to hang off mouseenter/mouseleave. Those two listeners are attached in _attachPanelEvents rather than in each per-kind handler: it is the one panel behaviour identical across all four, and it must not depend on a kind remembering to wire it. Keys pin to the panel's IMAGE / plot box — the same four numbers _resizePanelDOM drives the scale bar off — not the letterbox fit-rect, so a key stays put when the data's aspect ratio changes underneath it. hover_only keys are kept OUT of PNG export, and that needed real work rather than the comment I first wrote claiming it fell out for free. The key canvas is persistent: if the pointer happens to be over the panel when exportPNG runs, the key is already painted and composites straight through. Verified by rendering, hovering, and re-exporting — max channel delta was 210 before the fix, 0 after. _drawPanelKeysNoHover re-renders onto a scratch canvas with the flag suppressed, mirroring the existing _drawPanelWidgetsNoHandles precedent. A caption on a card defaults to near-white rather than theme.tickText: the usual card is a translucent dark slab and tickText is near-black under a light theme, so the default put dark text on a dark pill. With no card the label sits on the figure and takes the tick colour as before. FIGURE_ESM.md anchors re-verified against all 9,466 lines. --- anyplotlib/FIGURE_ESM.md | 55 +++++------ anyplotlib/figure_esm.js | 195 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 222 insertions(+), 28 deletions(-) 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/figure_esm.js b/anyplotlib/figure_esm.js index f4516490..e0c17391 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,158 @@ 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. + const shown = keys.filter(k => k && k.visible !== false + && (!k.hover_only || p._hover)); + if (!shown.length) { if (!target) kc.style.display = 'none'; return false; } + + 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); + + 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); + 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: a `hover_only` key must not be baked into a saved figure, + // but the key canvas is PERSISTENT — if the pointer happens to be over the + // panel when exportPNG runs, that key is already painted on it and would + // composite straight through. Re-render onto a scratch canvas with the + // hover flag suppressed, mirroring _drawPanelWidgetsNoHandles. Returns null + // when the live canvas is already correct (the common case). + function _drawPanelKeysNoHover(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 = false; + let drew; + try { drew = drawKeys(p, scratch); } finally { p._hover = saved; } + return drew ? scratch : undefined; // undefined = "drew nothing, skip" + } + 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 +7404,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 +9526,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 +9681,13 @@ 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` keys are excluded from the + // export; when one is 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 = _drawPanelKeysNoHover(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.) }; From 64541b40ed0346de2cdc11275f16ac035d016ddf Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 31 Jul 2026 07:16:48 -0500 Subject: [PATCH 3/6] feat(keys): export every key, hover_only included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the export rule: a saved figure now shows every key. The export renders the panel as though the pointer were over it, so what you save is what you see while reading the plot. Fixing this surfaced a real bug that was not about export at all. Images were decoded lazily from inside the DRAW loop, which only ever visits the keys shown this frame — so a hover_only key that had never been on screen had never begun decoding. The first hover would reveal nothing until some later redraw happened to come along, and an export could not wait for onload at all, so it silently omitted the key. Every DECLARED key now starts decoding, whether or not it is drawn. Second trap, in the same area: a panel whose only keys are hover_only kept a display:none canvas while the pointer was away, and the export path measures against that canvas's bounding rect — which is 0x0 when hidden, so _drawEl dropped the key on the floor. The canvas is now sized and shown whenever any key is declared, and merely left empty when none are currently visible. `declared` and `shown` are now separate filters for exactly this reason. Verified both ways round: with mixed keys and with hover_only keys alone, the cold export (pointer away) and the warm export (pointer over the panel) are byte-identical and both contain the key. --- anyplotlib/_base_plot.py | 8 +++--- anyplotlib/figure_esm.js | 53 ++++++++++++++++++++++++++-------------- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py index 11b05efe..45333ad2 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -139,10 +139,10 @@ def add_key(self, image, *, corner: str = "top-right", anchor=None, 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 when the key is informative but you do not want - it in an exported figure or in the way while reading the data. - Note that a hover-only key is **omitted from PNG export**, since - export runs with no pointer over the panel. + ``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 diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index e0c17391..9480c762 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -3026,9 +3026,14 @@ function render({ model, el, onResize }) { 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. - const shown = keys.filter(k => k && k.visible !== false - && (!k.hover_only || p._hover)); - if (!shown.length) { if (!target) kc.style.display = 'none'; return false; } + // 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); @@ -3041,6 +3046,16 @@ function render({ model, el, onResize }) { 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) { @@ -3105,21 +3120,22 @@ function render({ model, el, onResize }) { return drew; } - // Export helper: a `hover_only` key must not be baked into a saved figure, - // but the key canvas is PERSISTENT — if the pointer happens to be over the - // panel when exportPNG runs, that key is already painted on it and would - // composite straight through. Re-render onto a scratch canvas with the - // hover flag suppressed, mirroring _drawPanelWidgetsNoHandles. Returns null - // when the live canvas is already correct (the common case). - function _drawPanelKeysNoHover(p) { + // 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; + if (p._hover || !(st.keys || []).some(k => k && k.hover_only)) return null; const scratch = document.createElement('canvas'); const saved = p._hover; - p._hover = false; + p._hover = true; let drew; try { drew = drawKeys(p, scratch); } finally { p._hover = saved; } - return drew ? scratch : undefined; // undefined = "drew nothing, skip" + return drew ? scratch : undefined; } function drawColorbar2d(p) { @@ -9681,11 +9697,12 @@ 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` keys are excluded from the - // export; when one is 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 = _drawPanelKeysNoHover(p); + // 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 From 0b0df9abc17ad00451ebcffded1c02b1a3bb5033 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 31 Jul 2026 07:45:03 -0500 Subject: [PATCH 4/6] test(keys): lifecycle, placement, hover and export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 33 tests. The Python half covers the lifecycle and validation; the render half covers what only the browser can answer. Notable cases, each pinned to a decision rather than to current output: * a bare RGBA key draws NO card — alpha 0 stays transparent, which is the whole reason a triangle or a disc can sit on an image * keys pin to the AXES BOX, not the letterboxed picture. The panel is made much wider than its square image so the two differ, and the key must track imgX. This is the scale bar's rule and the test says so. * hover_only stays dark until the pointer arrives, and a plain key is unaffected by hovering * three export regressions: a hover_only key must appear in an export taken with the pointer away; cold and hovered exports must be byte-identical; and a LONE hover_only key must still export — that last one is the display:none / 0x0-rect trap, which no other key is around to mask. Adds a shared `mount_page` fixture to tests/conftest.py. `interact_page` calls renderFn directly and exposes no mount handle, so it can drive a pointer but cannot export or reach the panels map; these tests need both at once. test_export_png.py and test_texture.py predate it and keep their own local copies. --- anyplotlib/tests/conftest.py | 71 +++++ anyplotlib/tests/test_keys/test_keys.py | 348 ++++++++++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 anyplotlib/tests/test_keys/test_keys.py 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..e90dc7c1 --- /dev/null +++ b/anyplotlib/tests/test_keys/test_keys.py @@ -0,0 +1,348 @@ +""" +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() From 672e12552993908df88bcfc514217c638116dc64 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 31 Jul 2026 07:56:55 -0500 Subject: [PATCH 5/6] feat(keys): in-image labels, gallery example, changelog and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `labels=` draws text INSIDE the key, positioned in fractions of the key image so it tracks the picture through a resize. This is what an IPF triangle actually needs — its corner indices belong on the corners, not in the caption underneath — and it reuses the existing mini-TeX `_drawTex`, so `$[1\bar{1}0]$` works as in any other label. Entries are `(x, y, text)` or a dict adding `size` / `color` / `align`. `align` matters more than it looks: centring text on a corner hangs half of it off the edge, where the panel clips it, which is exactly what the first draft of the example did. Glyphs are drawn white over a dark halo rather than in a theme colour. A key's own colours are arbitrary — an IPF triangle runs through the entire hue circle — so there is no background to key the text off, and any single flat colour is illegible against part of it. Adds Examples/PlotTypes/plot_key_overlays.py: an IPF colour key over a synthetic orientation map (grains coloured by reading the key image itself, so map and legend agree by construction) and a hue wheel over a vortex polarization field. Plus the changelog fragment and a docs/api/keys.rst page. Tests: 40, up from 33. The label pair is a positive/control pair, and the control earned its place — it caught the first version of the positive test, which counted "near-white pixels in the red mask's bounding box" and so was really counting the page background showing through the disc's corners. Both now crop to the disc interior and detect glyphs by "strong green AND blue over a pure-red key", which does not depend on antialiasing. --- Examples/PlotTypes/plot_key_overlays.py | 169 ++++++++++++++++++++ anyplotlib/_base_plot.py | 17 +- anyplotlib/figure_esm.js | 26 +++ anyplotlib/keys.py | 49 +++++- anyplotlib/tests/test_keys/test_keys.py | 65 ++++++++ docs/api/index.rst | 1 + docs/api/keys.rst | 42 +++++ upcoming_changes/+plot_keys.new_feature.rst | 10 ++ 8 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 Examples/PlotTypes/plot_key_overlays.py create mode 100644 docs/api/keys.rst create mode 100644 upcoming_changes/+plot_keys.new_feature.rst 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/_base_plot.py b/anyplotlib/_base_plot.py index 45333ad2..1dfe69ce 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -85,7 +85,7 @@ def add_key(self, image, *, corner: str = "top-right", anchor=None, 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, - name=None) -> "KeyOverlay": + 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 @@ -151,6 +151,19 @@ def add_key(self, image, *, corner: str = "top-right", anchor=None, 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. @@ -171,7 +184,7 @@ def add_key(self, image, *, corner: str = "top-right", anchor=None, 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)) + 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 " diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 9480c762..44adebd5 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -3104,6 +3104,32 @@ function render({ model, el, onResize }) { 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 diff --git a/anyplotlib/keys.py b/anyplotlib/keys.py index b75e5f73..4de24ba8 100644 --- a/anyplotlib/keys.py +++ b/anyplotlib/keys.py @@ -53,7 +53,7 @@ class KeyOverlay: _FIELDS = ( "corner", "anchor", "size", "margin", "bgcolor", "border", "border_width", "radius", "alpha", "hover_only", "visible", - "label", "label_size", "label_color", + "label", "label_size", "label_color", "labels", ) def __init__(self, plot, image_url: str, *, name=None, **kwargs): @@ -202,4 +202,51 @@ def _validate(kw: dict) -> dict: 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/tests/test_keys/test_keys.py b/anyplotlib/tests/test_keys/test_keys.py index e90dc7c1..a935f092 100644 --- a/anyplotlib/tests/test_keys/test_keys.py +++ b/anyplotlib/tests/test_keys/test_keys.py @@ -346,3 +346,68 @@ def test_an_invisible_key_is_not_exported(self, mount_page): 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. From 37439e41ff95987a0a54544fb9d4d416272467f4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 31 Jul 2026 13:39:25 -0500 Subject: [PATCH 6/6] chore: prepare release v0.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.7.0 is the plot-keys feature that missed the 0.6.0 boat: #49 merged into feat/3d-colormap-surface at 09:12, but that branch had already merged to main at 06:50 as #48, so its six commits were stranded and v0.6.0 shipped set_texture without add_key. Mirrors what the Prepare Release workflow does for bump=minor, stable: version in pyproject.toml and docs/conf.py, towncrier build, switcher.json entry, and the root redirect. NB uv.lock still records version 0.5.0 — it was already stale on main (the workflow never touches it), so it is left alone here rather than mixed into a release commit. --- CHANGELOG.rst | 18 ++++++++++++++++++ docs/_root/index.html | 6 +++--- docs/_root/switcher.json | 5 +++++ docs/conf.py | 2 +- pyproject.toml | 2 +- upcoming_changes/+plot_keys.new_feature.rst | 10 ---------- 6 files changed, 28 insertions(+), 15 deletions(-) delete mode 100644 upcoming_changes/+plot_keys.new_feature.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a1126d0e..6bb86ed8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,24 @@ Fragment files in ``upcoming_changes/`` are assembled into this file by .. towncrier release notes start +0.7.0 (2026-07-31) +================== + +New Features +------------ + +- 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. + + 0.6.0 (2026-07-31) ================== diff --git a/docs/_root/index.html b/docs/_root/index.html index c630d799..8a3cebbc 100644 --- a/docs/_root/index.html +++ b/docs/_root/index.html @@ -4,12 +4,12 @@ anyplotlib – redirecting… - - + +

- Redirecting to v0.6.0 documentation… + Redirecting to v0.7.0 documentation

diff --git a/docs/_root/switcher.json b/docs/_root/switcher.json index 62295a05..cf546caf 100644 --- a/docs/_root/switcher.json +++ b/docs/_root/switcher.json @@ -4,6 +4,11 @@ "version": "dev", "url": "https://cssfrancis.github.io/anyplotlib/dev/" }, + { + "name": "v0.7.0 (stable)", + "version": "v0.7.0", + "url": "https://cssfrancis.github.io/anyplotlib/v0.7.0/" + }, { "name": "v0.6.0 (stable)", "version": "v0.6.0", diff --git a/docs/conf.py b/docs/conf.py index 010269a3..5a344503 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,7 +17,7 @@ project = "anyplotlib" copyright = "2026, anyplotlib contributors" author = "anyplotlib contributors" -release = "0.6.0" +release = "0.7.0" # When built in CI the workflow sets DOCS_VERSION to the tag name (e.g. # "v0.1.0") or "dev". Fall back to "dev" for local builds. diff --git a/pyproject.toml b/pyproject.toml index 57eb192b..ab81e276 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ exclude = [ [project] name = "anyplotlib" -version = "0.6.0" +version = "0.7.0" description = "A plotting library using python, javascript and anywidget for performant in browser plotting." readme = "README.md" license = { text = "MIT" } diff --git a/upcoming_changes/+plot_keys.new_feature.rst b/upcoming_changes/+plot_keys.new_feature.rst deleted file mode 100644 index 15055b12..00000000 --- a/upcoming_changes/+plot_keys.new_feature.rst +++ /dev/null @@ -1,10 +0,0 @@ -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.